mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
92

Configure Feed

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

feat: surface per-repo sync health on the dashboard

Daniel Roe (Jul 10, 2026, 12:20 PM +0200) 12576164 cd8c9d43

+332 -10
+125 -9
app/pages/dashboard.vue
··· 178 178 } 179 179 } 180 180 181 + // Attention-needing repos float to the top, then still-catching-up, then the 182 + // healthy majority; within a group keep the server's alphabetical order. A user 183 + // with a long repo list sees the problems without scrolling. 184 + const HEALTH_ORDER: Record<string, number> = { 185 + error: 0, 186 + enrolling: 1, 187 + waiting: 2, 188 + info: 3, 189 + paused: 4, 190 + ok: 5, 191 + } 192 + const sortedRepos = computed<DashboardRepo[]>(() => { 193 + const list = data.value?.repos ?? [] 194 + return list.toSorted((a, b) => 195 + (HEALTH_ORDER[a.health.state] ?? 9) - (HEALTH_ORDER[b.health.state] ?? 9), 196 + ) 197 + }) 198 + 199 + const expandedError = ref<number | null>(null) 200 + function toggleError(id: number) { 201 + expandedError.value = expandedError.value === id ? null : id 202 + } 203 + 181 204 function summariseRefs(refs: Record<string, string>): string { 182 205 const entries = Object.entries(refs) 183 206 if (entries.length === 0) return '—' ··· 330 353 331 354 <section class="card"> 332 355 <h2>repositories ({{ data.repos.length }})</h2> 356 + 357 + <div v-if="data.summary.needsAttention > 0" class="summary summary--attention" role="alert"> 358 + <span aria-hidden="true">⚠</span> 359 + <span> 360 + {{ data.summary.needsAttention }} 361 + {{ data.summary.needsAttention === 1 ? 'repository needs' : 'repositories need' }} attention. 362 + <template v-if="data.summary.waiting > 0">{{ data.summary.waiting }} still catching up.</template> 363 + </span> 364 + </div> 365 + <div v-else-if="data.summary.waiting > 0" class="summary" role="status"> 366 + <span aria-hidden="true">◐</span> 367 + <span>{{ data.summary.waiting }} {{ data.summary.waiting === 1 ? 'repository is' : 'repositories are' }} still catching up. This is normal right after installing.</span> 368 + </div> 369 + <div v-else-if="data.summary.total > 0" class="summary summary--ok" role="status"> 370 + <span aria-hidden="true">●</span> 371 + <span>All {{ data.summary.total }} {{ data.summary.total === 1 ? 'repository is' : 'repositories are' }} mirrored and up to date.</span> 372 + </div> 373 + 333 374 <p v-if="data.repos.length === 0" class="muted"> 334 375 no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute. 335 376 </p> 336 377 <ul v-else class="repos"> 337 - <li v-for="repo in data.repos" :key="repo.id" class="repo"> 378 + <li v-for="repo in sortedRepos" :key="repo.id" class="repo" :class="`repo--${repo.health.state}`"> 338 379 <div class="repo__main"> 339 380 <div class="repo__head"> 340 381 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a> 341 - <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 342 - <span v-else class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 382 + <span class="badge" :class="`badge-${repo.health.state}`">{{ repo.health.state }}</span> 343 383 </div> 384 + <p class="repo__health" :class="{ 'repo__health--error': repo.health.state === 'error' }"> 385 + {{ repo.health.message }} 386 + </p> 344 387 <dl class="repo__meta"> 345 388 <div class="repo__meta-item"> 346 389 <dt>tangled</dt> ··· 356 399 <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd> 357 400 </div> 358 401 </dl> 359 - <p v-if="repo.lastError" class="repo__error muted small"> 360 - {{ repo.lastError }} 402 + <p v-if="repo.lastError && !repo.lastError.startsWith('info:')" class="repo__error-detail small"> 403 + <button type="button" class="repo__error-toggle" :aria-expanded="expandedError === repo.id" @click="toggleError(repo.id)"> 404 + {{ expandedError === repo.id ? 'hide' : 'show' }} technical details 405 + </button> 406 + <code v-if="expandedError === repo.id" class="repo__error-raw">{{ repo.lastError }}</code> 361 407 </p> 362 408 </div> 363 409 <div class="repo__actions"> ··· 802 848 margin-right: 0.4ch; 803 849 } 804 850 805 - .badge-active { border-color: var(--color-ok); color: var(--color-ok); } 806 - .badge-active::before { content: "●"; } 807 - .badge-pending, 851 + .badge-ok { border-color: var(--color-ok); color: var(--color-ok); } 852 + .badge-ok::before { content: "●"; } 853 + .badge-waiting, 808 854 .badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } 809 - .badge-pending::before, 855 + .badge-waiting::before, 810 856 .badge-enrolling::before { content: "◐"; } 857 + .badge-info { border-color: var(--color-accent-dim); color: var(--color-accent-dim); } 858 + .badge-info::before { content: "ℹ"; } 811 859 .badge-error { border-color: var(--color-error); color: var(--color-error); } 812 860 .badge-error::before { content: "⚠"; } 861 + .badge-paused, 813 862 .badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); } 863 + .badge-paused::before, 814 864 .badge-disabled::before { content: "⏸"; } 865 + 866 + .summary { 867 + display: flex; 868 + align-items: baseline; 869 + gap: var(--space-xs); 870 + padding: var(--space-sm) var(--space-md); 871 + margin-bottom: var(--space-md); 872 + border: var(--rule-hair) solid var(--color-rule-interactive); 873 + border-radius: var(--radius-sm); 874 + font-size: var(--text-sm); 875 + color: var(--color-warn); 876 + } 877 + 878 + .summary--attention { 879 + border-color: var(--color-error); 880 + color: var(--color-error); 881 + } 882 + 883 + .summary--ok { 884 + border-color: var(--color-ok); 885 + color: var(--color-ok); 886 + } 887 + 888 + .repo--error { 889 + border-left: 2px solid var(--color-error); 890 + padding-left: var(--space-md); 891 + margin-left: calc(-1 * var(--space-md) - 2px); 892 + } 893 + 894 + .repo__health { 895 + margin: 0 0 var(--space-xs); 896 + font-size: var(--text-sm); 897 + color: var(--color-muted); 898 + } 899 + 900 + .repo__health--error { color: var(--color-error); } 901 + 902 + .repo__error-detail { 903 + margin: var(--space-xs) 0 0; 904 + } 905 + 906 + .repo__error-toggle { 907 + padding: 0; 908 + border: 0; 909 + background: transparent; 910 + color: var(--color-neutral); 911 + font-family: var(--font-mono); 912 + font-size: var(--text-xs); 913 + text-decoration: underline; 914 + cursor: pointer; 915 + } 916 + 917 + .repo__error-toggle:hover { color: var(--color-muted); transform: none; } 918 + 919 + .repo__error-raw { 920 + display: block; 921 + margin-top: var(--space-2xs); 922 + padding: var(--space-xs); 923 + background: var(--color-paper); 924 + border: var(--rule-hair) solid var(--color-rule); 925 + border-radius: var(--radius-sm); 926 + font-size: var(--text-xs); 927 + color: var(--color-muted); 928 + word-break: break-word; 929 + white-space: pre-wrap; 930 + } 815 931 816 932 button { 817 933 font-family: var(--font-mono);
+27 -1
server/api/me/dashboard.get.ts
··· 1 1 import { sql } from 'drizzle-orm' 2 2 import { installation, repoMapping, sshKey, userIdentity } from '#server/db/schema' 3 3 import { useDb } from '#server/utils/db' 4 + import { type RepoHealth, repoHealth } from '#server/utils/repo-health' 4 5 import { requireSession } from '#server/utils/server-session' 5 6 6 7 export interface DashboardRepo { ··· 16 17 lastSyncedRefs: Record<string, string> 17 18 lastSyncedAt: string | null 18 19 refCount: number 20 + health: RepoHealth 21 + } 22 + 23 + export interface DashboardSummary { 24 + total: number 25 + ok: number 26 + waiting: number 27 + needsAttention: number 19 28 } 20 29 21 30 export interface DashboardPayload { ··· 34 43 rotatedAt: string | null 35 44 } | null 36 45 repos: DashboardRepo[] 46 + summary: DashboardSummary 37 47 } 38 48 39 49 export default defineEventHandler(async (event): Promise<DashboardPayload> => { ··· 79 89 // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` 80 90 const refs = (row.lastSyncedRefs ?? {}) as Record<string, string> 81 91 const refKeys = Object.keys(refs) 92 + const disabledAt = row.disabledAt?.toISOString() ?? null 82 93 return { 83 94 id: row.id, 84 95 githubRepoId: row.githubRepoId, ··· 88 99 knot: row.knot, 89 100 status: row.status, 90 101 lastError: row.lastError, 91 - disabledAt: row.disabledAt?.toISOString() ?? null, 102 + disabledAt, 92 103 lastSyncedRefs: refs, 93 104 lastSyncedAt: refKeys.length > 0 && row.updatedAt ? row.updatedAt.toISOString() : null, 94 105 refCount: refKeys.length, 106 + health: repoHealth({ 107 + status: row.status, 108 + lastError: row.lastError, 109 + disabledAt, 110 + tangledRepoDid: row.tangledRepoDid, 111 + refCount: refKeys.length, 112 + }), 95 113 } 96 114 }) 115 + 116 + const summary: DashboardSummary = { 117 + total: repos.length, 118 + ok: repos.filter(r => r.health.state === 'ok').length, 119 + waiting: repos.filter(r => r.health.state === 'waiting' || r.health.state === 'enrolling').length, 120 + needsAttention: repos.filter(r => r.health.needsAttention).length, 121 + } 97 122 98 123 const installationPayload: DashboardPayload['installation'] = installRow 99 124 ? { ··· 119 144 hasSshKey: sshKeyPayload !== null, 120 145 sshKey: sshKeyPayload, 121 146 repos, 147 + summary, 122 148 } 123 149 })
+105
server/utils/repo-health.ts
··· 1 + /** 2 + * Derive a user-facing health summary for a repo mapping. 3 + * 4 + * The dashboard needs to answer three questions for someone who isn't in the 5 + * codebase: is this repo fine, is it still catching up, or is it stuck (and if 6 + * so, what, roughly, do I do about it)? The raw `status` / `lastError` columns 7 + * carry protocol-level detail that means nothing to a user, so we map them to a 8 + * small, stable set of states plus a plain-English message. The raw error is 9 + * kept alongside for the "copy this when reporting" path. 10 + */ 11 + 12 + export type RepoHealthState = 13 + /** Enrolled and at least one ref has synced. Nothing to do. */ 14 + | 'ok' 15 + /** Enrolled but nothing has synced yet: backfill/first push still in flight. */ 16 + | 'waiting' 17 + /** Not yet mirrored on tangled (enrolment hasn't completed). */ 18 + | 'enrolling' 19 + /** User paused sync from the dashboard. */ 20 + | 'paused' 21 + /** Informational, not a failure (e.g. renamed on GitHub). */ 22 + | 'info' 23 + /** Sync is stuck and won't recover on its own without action. */ 24 + | 'error' 25 + 26 + export interface RepoHealth { 27 + state: RepoHealthState 28 + /** One short sentence a non-technical user can act on. */ 29 + message: string 30 + /** Whether this repo should be surfaced in the "needs attention" summary. */ 31 + needsAttention: boolean 32 + } 33 + 34 + export interface RepoHealthInput { 35 + status: string 36 + lastError: string | null 37 + disabledAt: string | null 38 + tangledRepoDid: string | null 39 + refCount: number 40 + } 41 + 42 + /** 43 + * Map a known internal error string to a plain-English explanation. Returns 44 + * `null` for anything unrecognised so the caller can fall back to a generic 45 + * message while still exposing the raw text for reporting. 46 + * 47 + * Keyed on substrings of the errors we actually emit (see `sync-push`, 48 + * `repo-mapping`, `receive-pack`, `tangled-repo`); keep in sync when new 49 + * terminal messages are added. 50 + */ 51 + export function explainRepoError(raw: string): string | null { 52 + const err = raw.toLowerCase() 53 + 54 + if (err.includes('sufficient access permissions') || err.includes('accesscontrol')) { 55 + return 'Tangled rejected the mirror, usually because a repo with this name already exists on your account. Rename or remove the existing one, then resync.' 56 + } 57 + if (err.includes('invalid path sequence')) { 58 + return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.' 59 + } 60 + if (err.includes('repository access blocked') || err.includes('repo no longer exists')) { 61 + return 'The mirror on tangled is no longer reachable. Try a resync; if it persists, let us know.' 62 + } 63 + if (err.includes('rejected our ssh key') || err.includes('auth-rejected') || err.includes('authentication methods failed')) { 64 + return 'Tangled rejected our SSH key. Rotating the key from this dashboard usually fixes it.' 65 + } 66 + if (err.includes('exceeded') && (err.includes('bytes') || err.includes('size limit'))) { 67 + return 'A push was larger than the size limit and couldn\'t be mirrored.' 68 + } 69 + if (err.includes('pack signature mismatch') || err.includes('handshake') || err.includes('no advertisement') || err.includes('connection lost')) { 70 + return 'A transient connection problem with tangled interrupted the sync. It will retry automatically; resync to hurry it along.' 71 + } 72 + return null 73 + } 74 + 75 + export function repoHealth(input: RepoHealthInput): RepoHealth { 76 + if (input.disabledAt) { 77 + return { state: 'paused', message: 'Sync is paused. Re-enable to resume mirroring.', needsAttention: false } 78 + } 79 + 80 + // Rename notices (and any future advisory) are prefixed `info:` by the 81 + // webhook handler so they can ride the `lastError` column without a schema 82 + // change. They are not failures. 83 + if (input.lastError?.startsWith('info:')) { 84 + return { state: 'info', message: input.lastError.slice('info:'.length).trim(), needsAttention: false } 85 + } 86 + 87 + if (input.status === 'error') { 88 + const explained = input.lastError ? explainRepoError(input.lastError) : null 89 + return { 90 + state: 'error', 91 + message: explained ?? 'Sync is stuck. Try a resync; if it keeps failing, report it with the details below.', 92 + needsAttention: true, 93 + } 94 + } 95 + 96 + if (!input.tangledRepoDid) { 97 + return { state: 'enrolling', message: 'Setting up the mirror on tangled. This can take a minute.', needsAttention: false } 98 + } 99 + 100 + if (input.refCount === 0) { 101 + return { state: 'waiting', message: 'Enrolled, waiting for the first sync. New installs backfill in the background.', needsAttention: false } 102 + } 103 + 104 + return { state: 'ok', message: 'Mirrored and up to date.', needsAttention: false } 105 + }
+75
test/unit/repo-health.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { explainRepoError, repoHealth, type RepoHealthInput } from '../../server/utils/repo-health' 3 + 4 + function input(overrides: Partial<RepoHealthInput> = {}): RepoHealthInput { 5 + return { 6 + status: 'active', 7 + lastError: null, 8 + disabledAt: null, 9 + tangledRepoDid: 'did:plc:repo', 10 + refCount: 1, 11 + ...overrides, 12 + } 13 + } 14 + 15 + describe('repoHealth', () => { 16 + it('reports ok for an active mapping with synced refs', () => { 17 + const h = repoHealth(input()) 18 + expect(h.state).toBe('ok') 19 + expect(h.needsAttention).toBe(false) 20 + }) 21 + 22 + it('reports waiting for an enrolled mapping that has synced nothing yet', () => { 23 + const h = repoHealth(input({ refCount: 0 })) 24 + expect(h.state).toBe('waiting') 25 + expect(h.needsAttention).toBe(false) 26 + }) 27 + 28 + it('reports enrolling before the tangled repo exists', () => { 29 + const h = repoHealth(input({ tangledRepoDid: null, refCount: 0 })) 30 + expect(h.state).toBe('enrolling') 31 + }) 32 + 33 + it('reports paused when disabled, regardless of status', () => { 34 + const h = repoHealth(input({ disabledAt: new Date().toISOString(), status: 'error' })) 35 + expect(h.state).toBe('paused') 36 + expect(h.needsAttention).toBe(false) 37 + }) 38 + 39 + it('treats info-prefixed messages as advisory, not errors', () => { 40 + const h = repoHealth(input({ lastError: 'info: renamed on github from a/b to a/c; tangled mirror name unchanged' })) 41 + expect(h.state).toBe('info') 42 + expect(h.needsAttention).toBe(false) 43 + expect(h.message).toBe('renamed on github from a/b to a/c; tangled mirror name unchanged') 44 + }) 45 + 46 + it('flags error status as needing attention with a human-readable message', () => { 47 + const h = repoHealth(input({ 48 + status: 'error', 49 + lastError: 'knot knot1.tangled.sh returned 400: {"error":"AccessControl","message":"DID does not have sufficient access permissions for this operation"}', 50 + })) 51 + expect(h.state).toBe('error') 52 + expect(h.needsAttention).toBe(true) 53 + expect(h.message).toMatch(/already exists/i) 54 + }) 55 + 56 + it('falls back to a generic message for an unrecognised error', () => { 57 + const h = repoHealth(input({ status: 'error', lastError: 'something we have never seen' })) 58 + expect(h.state).toBe('error') 59 + expect(h.message).toMatch(/report it/i) 60 + }) 61 + }) 62 + 63 + describe('explainRepoError', () => { 64 + it('maps the production error strings we actually emit', () => { 65 + expect(explainRepoError('receive-pack: unpack error (pack signature mismatch detected)')).toMatch(/transient connection/i) 66 + expect(explainRepoError('receive-pack: no advertisement (stderr: ssh error: Timed out while waiting for handshake)')).toMatch(/transient connection/i) 67 + expect(explainRepoError('knot rejected our ssh key; stopping sync')).toMatch(/ssh key/i) 68 + expect(explainRepoError('Repository name contains invalid path sequence')).toMatch(/can.t be mirrored/i) 69 + expect(explainRepoError('pack exceeded the configured size limit')).toMatch(/size limit/i) 70 + }) 71 + 72 + it('returns null for anything unrecognised', () => { 73 + expect(explainRepoError('totally novel failure')).toBeNull() 74 + }) 75 + })