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 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>