mirror your GitHub repos to tangled.org automatically
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 flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false)
18const pendingAction = ref<string | null>(null)
19
20const heading = useTemplateRef('heading')
21const router = useRouter()
22router.afterEach(() => {
23 nextTick(() => heading.value?.focus({ focusVisible: false }))
24})
25
26function actionKey(scope: string, id: number | string) {
27 return `${scope}:${id}`
28}
29
30async function runAction(key: string, fn: () => Promise<unknown>, successMessage: string) {
31 pendingAction.value = key
32 flash.value = null
33 try {
34 await fn()
35 flash.value = successMessage
36 await refresh()
37 }
38 catch (err: unknown) {
39 const message = (err as { data?: { statusMessage?: string }, message?: string } | null)?.data?.statusMessage
40 ?? (err as Error | null)?.message
41 ?? 'something went wrong'
42 flash.value = `Error: ${message}`
43 }
44 finally {
45 pendingAction.value = null
46 }
47}
48
49// Typed Nitro routes use `:id` placeholders; passing the dynamic path to
50// `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting
51// a no-body request through native `fetch` and parsing the JSON manually
52// sidesteps that. Same-origin, so cookies are sent automatically.
53async function postAction(url: string): Promise<unknown> {
54 const response = await fetch(url, { method: 'POST', credentials: 'same-origin' })
55 if (!response.ok) {
56 let statusMessage = response.statusText
57 try {
58 const body: unknown = await response.json()
59 if (body && typeof body === 'object' && 'statusMessage' in body && typeof body.statusMessage === 'string') {
60 statusMessage = body.statusMessage
61 }
62 }
63 catch {
64 // body wasn't json; keep statusText
65 }
66 throw Object.assign(new Error(statusMessage), { statusCode: response.status })
67 }
68 return response.json()
69}
70
71function resync(repo: DashboardRepo) {
72 return runAction(
73 actionKey('resync', repo.id),
74 () => postAction(`/api/repos/${repo.id}/resync`),
75 `Queued resync for ${repo.githubFullName}.`,
76 )
77}
78
79function disable(repo: DashboardRepo) {
80 return runAction(
81 actionKey('disable', repo.id),
82 () => postAction(`/api/repos/${repo.id}/disable`),
83 `Disabled sync for ${repo.githubFullName}.`,
84 )
85}
86
87function enable(repo: DashboardRepo) {
88 return runAction(
89 actionKey('enable', repo.id),
90 () => postAction(`/api/repos/${repo.id}/enable`),
91 `Re-enabled sync for ${repo.githubFullName}.`,
92 )
93}
94
95function rotateKey() {
96 return runAction(
97 'rotate-key',
98 () => postAction('/api/me/rotate-key'),
99 'Queued SSH key rotation.',
100 )
101}
102
103async function logout() {
104 pendingAction.value = 'logout'
105 try {
106 await postAction('/api/me/logout')
107 await navigateTo('/')
108 }
109 catch {
110 pendingAction.value = null
111 flash.value = 'Error: could not sign out.'
112 }
113}
114
115function summariseRefs(refs: Record<string, string>): string {
116 const entries = Object.entries(refs)
117 if (entries.length === 0) return '—'
118 if (entries.length === 1) {
119 const [ref, sha] = entries[0]!
120 return `${ref} @ ${sha.slice(0, 7)}`
121 }
122 return `${entries.length} refs`
123}
124
125function fmtDate(iso: string | null): string {
126 if (!iso) return '—'
127 try {
128 return new Date(iso).toLocaleString()
129 }
130 catch {
131 return iso
132 }
133}
134</script>
135
136<template>
137 <div class="page">
138 <a class="skip-link" href="#main">skip to main content</a>
139 <header class="nav-term">
140 <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">></span> <a class="nav-term__mark" href="/dashboard" aria-label="synchub.to dashboard"><SynchubMark :wordmark="true" :size="18" /></a> <button type="button" class="nav-term__logout" :disabled="pendingAction !== null" :aria-busy="pendingAction === 'logout'" @click="logout">{{ pendingAction === 'logout' ? 'logging out…' : '--logout' }}</button></nav>
141 </header>
142
143 <div id="main" class="intro">
144 <h1 ref="heading" tabindex="-1" class="heading-target">mirror status</h1>
145 <p class="muted">
146 everything synchub is syncing for your connected GitHub installation.
147 </p>
148 </div>
149
150 <div v-if="error" class="card error" role="alert">
151 <span aria-hidden="true">⚠</span> failed to load dashboard: {{ error.message }}
152 </div>
153
154 <div v-else-if="data">
155 <section class="card">
156 <h2>identity</h2>
157 <dl>
158 <dt>tangled DID</dt>
159 <dd><code>{{ data.did }}</code></dd>
160 <template v-if="data.handle">
161 <dt>handle</dt>
162 <dd>@{{ data.handle }}</dd>
163 </template>
164 <template v-if="data.installation">
165 <dt>GitHub installation</dt>
166 <dd>
167 {{ data.installation.accountLogin }}
168 <span class="muted">({{ data.installation.accountType }}, install #{{ data.installation.id }})</span>
169 <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span>
170 </dd>
171 </template>
172 <template v-else>
173 <dt>GitHub installation</dt>
174 <dd class="muted">
175 no matching install row
176 </dd>
177 </template>
178 </dl>
179 <p class="muted small">
180 connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>.
181 <a href="https://github.com/apps/synchub-to/installations/new">connect a different installation?</a>
182 </p>
183 </section>
184
185 <section class="card">
186 <h2>SSH key</h2>
187 <template v-if="data.sshKey">
188 <p class="small muted">
189 created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>.
190 </p>
191 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre>
192 </template>
193 <p v-else class="muted">
194 no key on file yet. it's created automatically on first sign-in.
195 </p>
196 <button
197 type="button"
198 :disabled="pendingAction !== null"
199 :aria-busy="pendingAction === 'rotate-key'"
200 @click="rotateKey"
201 >
202 {{ pendingAction === 'rotate-key' ? 'Rotating…' : 'Rotate SSH key' }}
203 </button>
204 </section>
205
206 <section class="card">
207 <h2>repositories ({{ data.repos.length }})</h2>
208 <p v-if="data.repos.length === 0" class="muted">
209 no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute.
210 </p>
211 <ul v-else class="repos">
212 <li v-for="repo in data.repos" :key="repo.id" class="repo">
213 <div class="repo__main">
214 <div class="repo__head">
215 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a>
216 <span class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span>
217 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span>
218 </div>
219 <dl class="repo__meta">
220 <div class="repo__meta-item">
221 <dt>tangled</dt>
222 <dd v-if="repo.tangledFullName" class="repo__did">{{ repo.tangledFullName }}</dd>
223 <dd v-else class="muted">not yet enrolled</dd>
224 </div>
225 <div class="repo__meta-item">
226 <dt>last synced</dt>
227 <dd>{{ fmtDate(repo.lastSyncedAt) }}</dd>
228 </div>
229 <div class="repo__meta-item">
230 <dt>refs</dt>
231 <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd>
232 </div>
233 </dl>
234 <p v-if="repo.lastError" class="repo__error muted small">
235 {{ repo.lastError }}
236 </p>
237 </div>
238 <div class="repo__actions">
239 <button
240 type="button"
241 :disabled="pendingAction !== null"
242 :aria-busy="pendingAction === actionKey('resync', repo.id)"
243 @click="resync(repo)"
244 >
245 {{ pendingAction === actionKey('resync', repo.id) ? 'Resyncing…' : 'Resync' }}
246 </button>
247 <button
248 v-if="repo.disabledAt"
249 type="button"
250 :disabled="pendingAction !== null"
251 :aria-busy="pendingAction === actionKey('enable', repo.id)"
252 @click="enable(repo)"
253 >
254 {{ pendingAction === actionKey('enable', repo.id) ? 'Enabling…' : 'Enable' }}
255 </button>
256 <button
257 v-else
258 type="button"
259 :disabled="pendingAction !== null"
260 :aria-busy="pendingAction === actionKey('disable', repo.id)"
261 @click="disable(repo)"
262 >
263 {{ pendingAction === actionKey('disable', repo.id) ? 'Disabling…' : 'Disable' }}
264 </button>
265 </div>
266 </li>
267 </ul>
268 </section>
269
270 <p
271 v-if="flash"
272 class="flash"
273 :class="{ 'flash--error': flashIsError }"
274 :role="flashIsError ? 'alert' : 'status'"
275 >
276 <span aria-hidden="true">{{ flashIsError ? '⚠' : '✓' }}</span> {{ flash }}
277 </p>
278 </div>
279 </div>
280</template>
281
282<style scoped>
283.page {
284 max-width: 64rem;
285 margin: 0 auto;
286 padding-inline: var(--page-gutter);
287 color: var(--color-ink);
288}
289
290.nav-term {
291 padding: var(--space-md) 0;
292 border-bottom: var(--rule-hair) solid var(--color-rule);
293}
294
295.nav-term__line {
296 display: flex;
297 align-items: center;
298 flex-wrap: wrap;
299 gap: 0.6ch;
300 font-family: var(--font-mono);
301 font-size: var(--text-sm);
302 margin: 0;
303}
304
305.nav-term__line .prompt { color: var(--color-accent); }
306
307.nav-term__mark {
308 display: inline-flex;
309 text-decoration: none;
310 margin-right: 1.2ch;
311}
312
313.nav-term__args {
314 display: inline-flex;
315 align-items: center;
316 gap: 0.9ch;
317 list-style: none;
318 margin: 0;
319 padding: 0;
320}
321
322.nav-term__args a {
323 display: inline-flex;
324 align-items: center;
325 min-height: 44px;
326 color: var(--color-muted);
327 text-decoration: underline;
328 text-underline-offset: 2px;
329 white-space: nowrap;
330}
331
332.nav-term__args a:hover { color: var(--color-ink); }
333
334.nav-term__logout {
335 margin-left: auto;
336 padding: 0.2rem 0.7rem;
337 border: var(--rule-hair) solid var(--color-rule-interactive);
338 border-radius: var(--radius-sm);
339 background: transparent;
340 color: var(--color-muted);
341 font-family: var(--font-mono);
342 font-size: var(--text-sm);
343 white-space: nowrap;
344 cursor: pointer;
345 transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out);
346}
347
348.nav-term__logout:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-ink); }
349.nav-term__logout:disabled { opacity: 0.5; cursor: progress; }
350
351.caret {
352 display: inline-block;
353 width: 1ch;
354 color: var(--color-accent);
355 animation: blink 1.05s steps(2) infinite;
356}
357
358@keyframes blink { 50% { opacity: 0; } }
359@media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
360
361.intro {
362 padding-block: var(--space-xl) var(--space-md);
363}
364
365h1 {
366 font-size: var(--text-xl);
367 margin: 0 0 var(--space-2xs);
368}
369
370.heading-target:focus { outline: none; }
371
372h2 {
373 margin: 0 0 var(--space-md);
374 font-size: var(--text-md);
375}
376
377.muted { color: var(--color-neutral); }
378.small { font-size: var(--text-sm); }
379
380.card {
381 background: var(--color-paper-2);
382 border: var(--rule-hair) solid var(--color-rule);
383 border-radius: var(--radius-md);
384 padding: var(--space-lg);
385 margin: var(--space-md) 0;
386}
387
388.error {
389 border-color: var(--color-error);
390 color: var(--color-error);
391}
392
393dl {
394 display: grid;
395 grid-template-columns: max-content minmax(0, 1fr);
396 gap: var(--space-2xs) var(--space-md);
397 margin: 0 0 var(--space-sm);
398}
399
400dt {
401 font-family: var(--font-mono);
402 font-size: var(--text-sm);
403 color: var(--color-neutral);
404}
405
406dd {
407 margin: 0;
408 overflow-wrap: anywhere;
409 min-width: 0;
410}
411
412code {
413 font-family: var(--font-mono);
414 font-size: var(--text-sm);
415 color: var(--color-ink);
416}
417
418.pubkey {
419 font-family: var(--font-mono);
420 font-size: var(--text-xs);
421 background: var(--color-paper);
422 border: var(--rule-hair) solid var(--color-rule);
423 padding: var(--space-sm);
424 border-radius: var(--radius-sm);
425 overflow-x: auto;
426 white-space: pre-wrap;
427 word-break: break-all;
428 margin: var(--space-xs) 0 var(--space-md);
429 color: var(--color-muted);
430}
431
432.repos {
433 list-style: none;
434 margin: 0;
435 padding: 0;
436}
437
438.repo {
439 display: flex;
440 align-items: start;
441 justify-content: space-between;
442 gap: var(--space-lg);
443 padding-block: var(--space-md);
444 border-top: var(--rule-hair) solid var(--color-rule);
445}
446
447.repo:first-child { border-top: 0; }
448
449.repo__main { min-width: 0; flex: 1; }
450
451.repo__head {
452 display: flex;
453 align-items: center;
454 flex-wrap: wrap;
455 gap: var(--space-xs);
456 margin-bottom: var(--space-xs);
457}
458
459.repo__name {
460 font-family: var(--font-mono);
461 font-size: var(--text-base);
462 color: var(--color-ink);
463 text-decoration: none;
464 border-bottom: var(--rule-hair) solid var(--color-rule);
465 overflow-wrap: anywhere;
466}
467
468.repo__name:hover { color: var(--color-accent); border-color: var(--color-accent); }
469
470.repo__meta {
471 display: flex;
472 flex-wrap: wrap;
473 gap: var(--space-xs) var(--space-lg);
474 margin: 0;
475}
476
477.repo__meta-item {
478 display: flex;
479 flex-direction: column;
480 gap: 1px;
481 min-width: 0;
482}
483
484.repo__meta dt {
485 font-family: var(--font-mono);
486 font-size: var(--text-xs);
487 letter-spacing: 0.06em;
488 text-transform: uppercase;
489 color: var(--color-neutral);
490}
491
492.repo__meta dd {
493 margin: 0;
494 font-size: var(--text-sm);
495 color: var(--color-muted);
496}
497
498.repo__did {
499 font-family: var(--font-mono);
500 word-break: break-all;
501}
502
503.repo__error {
504 margin: var(--space-xs) 0 0;
505 word-break: break-word;
506}
507
508.repo__actions {
509 display: flex;
510 flex-direction: column;
511 gap: var(--space-2xs);
512 flex: none;
513}
514
515.badge {
516 display: inline-block;
517 padding: 0.1rem 0.5rem;
518 border-radius: var(--radius-sm);
519 font-family: var(--font-mono);
520 font-size: var(--text-xs);
521 border: var(--rule-hair) solid var(--color-rule-interactive);
522 color: var(--color-muted);
523 margin-right: var(--space-2xs);
524}
525
526.badge::before {
527 margin-right: 0.4ch;
528}
529
530.badge-active { border-color: var(--color-ok); color: var(--color-ok); }
531.badge-active::before { content: "●"; }
532.badge-pending,
533.badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); }
534.badge-pending::before,
535.badge-enrolling::before { content: "◐"; }
536.badge-error { border-color: var(--color-error); color: var(--color-error); }
537.badge-error::before { content: "⚠"; }
538.badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); }
539.badge-disabled::before { content: "⏸"; }
540
541button {
542 font-family: var(--font-mono);
543 font-size: var(--text-sm);
544 padding: 0.3rem 0.75rem;
545 border-radius: var(--radius-sm);
546 border: var(--rule-hair) solid var(--color-rule-interactive);
547 background: var(--color-paper-3);
548 color: var(--color-ink);
549 cursor: pointer;
550 white-space: nowrap;
551 transition: border-color var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out);
552}
553
554button:hover:not(:disabled) { border-color: var(--color-accent); transform: translateY(-1px); }
555button:active:not(:disabled) { transform: translateY(0); }
556
557button:disabled {
558 opacity: 0.5;
559 cursor: progress;
560}
561
562@media (max-width: 40rem) {
563 .repo {
564 flex-direction: column;
565 gap: var(--space-sm);
566 }
567 .repo__actions {
568 flex-direction: row;
569 flex-wrap: wrap;
570 }
571}
572
573.flash {
574 position: fixed;
575 right: var(--space-md);
576 bottom: var(--space-md);
577 z-index: var(--z-toast);
578 padding: var(--space-xs) var(--space-sm);
579 background: var(--color-paper-3);
580 border: var(--rule-hair) solid var(--color-accent);
581 color: var(--color-ink);
582 border-radius: var(--radius-sm);
583 font-family: var(--font-mono);
584 font-size: var(--text-sm);
585}
586
587.flash--error { border-color: var(--color-error); }
588</style>