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