mirror your GitHub repos to tangled.org automatically
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>