mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

1<script setup lang="ts"> 2import { ref } from 'vue' 3import type { DashboardPayload, DashboardRepo } from '~~/server/api/me/dashboard.get' 4 5definePageMeta({ 6 middleware: ['authenticated'], 7}) 8 9useSeoMeta({ 10 title: 'Dashboard · synchub.to', 11 description: 'Your synchub.to mirror status.', 12}) 13 14const { data, refresh, error } = await useFetch<DashboardPayload>('/api/me/dashboard') 15 16const flash = ref<string | null>(null) 17const pendingAction = ref<string | null>(null) 18 19function actionKey(scope: string, id: number | string) { 20 return `${scope}:${id}` 21} 22 23async function runAction(key: string, fn: () => Promise<unknown>, successMessage: string) { 24 pendingAction.value = key 25 flash.value = null 26 try { 27 await fn() 28 flash.value = successMessage 29 await refresh() 30 } 31 catch (err: unknown) { 32 const message = (err as { data?: { statusMessage?: string }, message?: string } | null)?.data?.statusMessage 33 ?? (err as Error | null)?.message 34 ?? 'something went wrong' 35 flash.value = `Error: ${message}` 36 } 37 finally { 38 pendingAction.value = null 39 } 40} 41 42// Typed Nitro routes use `:id` placeholders; passing the dynamic path to 43// `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting 44// a no-body request through native `fetch` and parsing the JSON manually 45// sidesteps that. Same-origin, so cookies are sent automatically. 46async function postAction(url: string): Promise<unknown> { 47 const response = await fetch(url, { method: 'POST', credentials: 'same-origin' }) 48 if (!response.ok) { 49 let statusMessage = response.statusText 50 try { 51 const body: unknown = await response.json() 52 if (body && typeof body === 'object' && 'statusMessage' in body && typeof body.statusMessage === 'string') { 53 statusMessage = body.statusMessage 54 } 55 } 56 catch { 57 // body wasn't json; keep statusText 58 } 59 throw Object.assign(new Error(statusMessage), { statusCode: response.status }) 60 } 61 return response.json() 62} 63 64function resync(repo: DashboardRepo) { 65 return runAction( 66 actionKey('resync', repo.id), 67 () => postAction(`/api/repos/${repo.id}/resync`), 68 `Queued resync for ${repo.githubFullName}.`, 69 ) 70} 71 72function disable(repo: DashboardRepo) { 73 return runAction( 74 actionKey('disable', repo.id), 75 () => postAction(`/api/repos/${repo.id}/disable`), 76 `Disabled sync for ${repo.githubFullName}.`, 77 ) 78} 79 80function enable(repo: DashboardRepo) { 81 return runAction( 82 actionKey('enable', repo.id), 83 () => postAction(`/api/repos/${repo.id}/enable`), 84 `Re-enabled sync for ${repo.githubFullName}.`, 85 ) 86} 87 88function rotateKey() { 89 return runAction( 90 'rotate-key', 91 () => postAction('/api/me/rotate-key'), 92 'Queued SSH key rotation.', 93 ) 94} 95 96function summariseRefs(refs: Record<string, string>): string { 97 const entries = Object.entries(refs) 98 if (entries.length === 0) return '—' 99 if (entries.length === 1) { 100 const [ref, sha] = entries[0]! 101 return `${ref} @ ${sha.slice(0, 7)}` 102 } 103 return `${entries.length} refs` 104} 105 106function fmtDate(iso: string | null): string { 107 if (!iso) return '—' 108 try { 109 return new Date(iso).toLocaleString() 110 } 111 catch { 112 return iso 113 } 114} 115</script> 116 117<template> 118 <div class="page"> 119 <header> 120 <h1>synchub.to</h1> 121 <p class="muted"> 122 Mirror status for your connected GitHub installation. 123 </p> 124 </header> 125 126 <div v-if="error" class="card error"> 127 Failed to load dashboard: {{ error.message }} 128 </div> 129 130 <div v-else-if="data"> 131 <section class="card"> 132 <h2>Identity</h2> 133 <dl> 134 <dt>tangled DID</dt> 135 <dd><code>{{ data.did }}</code></dd> 136 <template v-if="data.handle"> 137 <dt>handle</dt> 138 <dd>@{{ data.handle }}</dd> 139 </template> 140 <template v-if="data.installation"> 141 <dt>GitHub installation</dt> 142 <dd> 143 {{ data.installation.accountLogin }} 144 <span class="muted">({{ data.installation.accountType }}, install #{{ data.installation.id }})</span> 145 <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span> 146 </dd> 147 </template> 148 <template v-else> 149 <dt>GitHub installation</dt> 150 <dd class="muted"> 151 no matching install row 152 </dd> 153 </template> 154 </dl> 155 <p class="muted small"> 156 Connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>. 157 <a href="https://github.com/apps/synchub-to/installations/new">Connect a different installation?</a> 158 </p> 159 </section> 160 161 <section class="card"> 162 <h2>SSH key</h2> 163 <template v-if="data.sshKey"> 164 <p class="small muted"> 165 Created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>. 166 </p> 167 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre> 168 </template> 169 <p v-else class="muted"> 170 No key on file yet. It's created automatically on first sign-in. 171 </p> 172 <button 173 type="button" 174 :disabled="pendingAction !== null" 175 @click="rotateKey" 176 > 177 {{ pendingAction === 'rotate-key' ? 'Rotating' : 'Rotate SSH key' }} 178 </button> 179 </section> 180 181 <section class="card"> 182 <h2>Repositories ({{ data.repos.length }})</h2> 183 <p v-if="data.repos.length === 0" class="muted"> 184 No repositories enrolled yet. New installs are backfilled in the background; refresh in a minute. 185 </p> 186 <table v-else> 187 <thead> 188 <tr> 189 <th>GitHub</th> 190 <th>Tangled</th> 191 <th>Status</th> 192 <th>Last synced</th> 193 <th>Refs</th> 194 <th>Actions</th> 195 </tr> 196 </thead> 197 <tbody> 198 <tr v-for="repo in data.repos" :key="repo.id"> 199 <td> 200 <a :href="`https://github.com/${repo.githubFullName}`" rel="noopener">{{ repo.githubFullName }}</a> 201 </td> 202 <td> 203 <span v-if="repo.tangledFullName" class="small">{{ repo.tangledFullName }}</span> 204 <span v-else class="muted small">—</span> 205 </td> 206 <td> 207 <span class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 208 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 209 <div v-if="repo.lastError" class="muted small"> 210 {{ repo.lastError }} 211 </div> 212 </td> 213 <td class="small"> 214 {{ fmtDate(repo.lastSyncedAt) }} 215 </td> 216 <td class="small"> 217 {{ summariseRefs(repo.lastSyncedRefs) }} 218 </td> 219 <td> 220 <div class="actions"> 221 <button 222 type="button" 223 :disabled="pendingAction !== null" 224 @click="resync(repo)" 225 > 226 {{ pendingAction === actionKey('resync', repo.id) ? '' : 'Resync' }} 227 </button> 228 <button 229 v-if="repo.disabledAt" 230 type="button" 231 :disabled="pendingAction !== null" 232 @click="enable(repo)" 233 > 234 {{ pendingAction === actionKey('enable', repo.id) ? '' : 'Enable' }} 235 </button> 236 <button 237 v-else 238 type="button" 239 :disabled="pendingAction !== null" 240 @click="disable(repo)" 241 > 242 {{ pendingAction === actionKey('disable', repo.id) ? '' : 'Disable' }} 243 </button> 244 </div> 245 </td> 246 </tr> 247 </tbody> 248 </table> 249 </section> 250 251 <p v-if="flash" class="flash" role="status"> 252 {{ flash }} 253 </p> 254 </div> 255 </div> 256</template> 257 258<style scoped> 259:root { 260 --bg: #fafafa; 261 --fg: #1a1a1a; 262 --muted: #6b6b6b; 263 --border: #e2e2e2; 264 --accent: #2563eb; 265 --error: #b91c1c; 266 --warn: #b45309; 267 --ok: #15803d; 268} 269 270.page { 271 max-width: 64rem; 272 margin: 0 auto; 273 padding: 2rem 1rem; 274 font-family: system-ui, sans-serif; 275 color: var(--fg); 276} 277 278h1 { 279 margin: 0 0 0.25rem; 280} 281 282h2 { 283 margin: 0 0 1rem; 284 font-size: 1.125rem; 285} 286 287.muted { 288 color: var(--muted); 289} 290 291.small { 292 font-size: 0.875rem; 293} 294 295.card { 296 background: #fff; 297 border: 1px solid var(--border); 298 border-radius: 0.5rem; 299 padding: 1.25rem; 300 margin: 1rem 0; 301} 302 303.error { 304 border-color: var(--error); 305 color: var(--error); 306} 307 308dl { 309 display: grid; 310 grid-template-columns: max-content 1fr; 311 gap: 0.25rem 1rem; 312 margin: 0 0 0.75rem; 313} 314 315dt { 316 font-weight: 600; 317} 318 319dd { 320 margin: 0; 321} 322 323code { 324 font-family: ui-monospace, monospace; 325 font-size: 0.875rem; 326} 327 328.pubkey { 329 font-family: ui-monospace, monospace; 330 font-size: 0.75rem; 331 background: #f3f3f3; 332 padding: 0.5rem; 333 border-radius: 0.25rem; 334 overflow-x: auto; 335 white-space: pre-wrap; 336 word-break: break-all; 337 margin: 0.5rem 0 1rem; 338} 339 340table { 341 width: 100%; 342 border-collapse: collapse; 343 font-size: 0.875rem; 344} 345 346th, td { 347 text-align: left; 348 padding: 0.5rem 0.5rem; 349 border-bottom: 1px solid var(--border); 350 vertical-align: top; 351} 352 353th { 354 font-weight: 600; 355 background: #f3f3f3; 356} 357 358.badge { 359 display: inline-block; 360 padding: 0.125rem 0.5rem; 361 border-radius: 999px; 362 font-size: 0.75rem; 363 background: #eee; 364 color: #333; 365 margin-right: 0.25rem; 366} 367 368.badge-active { background: #dcfce7; color: var(--ok); } 369.badge-pending { background: #fef3c7; color: var(--warn); } 370.badge-enrolling { background: #fef3c7; color: var(--warn); } 371.badge-error { background: #fee2e2; color: var(--error); } 372.badge-disabled { background: #e0e7ff; color: var(--accent); } 373 374.actions { 375 display: flex; 376 gap: 0.25rem; 377 flex-wrap: wrap; 378} 379 380button { 381 font: inherit; 382 padding: 0.25rem 0.75rem; 383 border-radius: 0.25rem; 384 border: 1px solid var(--border); 385 background: #fff; 386 cursor: pointer; 387} 388 389button:disabled { 390 opacity: 0.5; 391 cursor: progress; 392} 393 394.flash { 395 position: fixed; 396 right: 1rem; 397 bottom: 1rem; 398 padding: 0.5rem 0.75rem; 399 background: #111; 400 color: #fff; 401 border-radius: 0.25rem; 402 font-size: 0.875rem; 403} 404</style>