diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue index 06ceed7..ae2a703 100644 --- a/app/pages/dashboard.vue +++ b/app/pages/dashboard.vue @@ -178,6 +178,29 @@ async function logout() { } } +// Attention-needing repos float to the top, then still-catching-up, then the +// healthy majority; within a group keep the server's alphabetical order. A user +// with a long repo list sees the problems without scrolling. +const HEALTH_ORDER: Record = { + error: 0, + enrolling: 1, + waiting: 2, + info: 3, + paused: 4, + ok: 5, +} +const sortedRepos = computed(() => { + const list = data.value?.repos ?? [] + return list.toSorted((a, b) => + (HEALTH_ORDER[a.health.state] ?? 9) - (HEALTH_ORDER[b.health.state] ?? 9), + ) +}) + +const expandedError = ref(null) +function toggleError(id: number) { + expandedError.value = expandedError.value === id ? null : id +} + function summariseRefs(refs: Record): string { const entries = Object.entries(refs) if (entries.length === 0) return '—' @@ -330,17 +353,37 @@ function fmtDate(iso: string | null): string {

repositories ({{ data.repos.length }})

+ + +
+ + {{ data.summary.waiting }} {{ data.summary.waiting === 1 ? 'repository is' : 'repositories are' }} still catching up. This is normal right after installing. +
+
+ + All {{ data.summary.total }} {{ data.summary.total === 1 ? 'repository is' : 'repositories are' }} mirrored and up to date. +
+

no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute.

    -
  • +
  • {{ repo.githubFullName }} - disabled - {{ repo.status }} + {{ repo.health.state }}
    +

    + {{ repo.health.message }} +

    tangled
    @@ -356,8 +399,11 @@ function fmtDate(iso: string | null): string {
    {{ summariseRefs(repo.lastSyncedRefs) }}
    -

    - {{ repo.lastError }} +

    + + {{ repo.lastError }}

    @@ -802,17 +848,87 @@ code { margin-right: 0.4ch; } -.badge-active { border-color: var(--color-ok); color: var(--color-ok); } -.badge-active::before { content: "●"; } -.badge-pending, +.badge-ok { border-color: var(--color-ok); color: var(--color-ok); } +.badge-ok::before { content: "●"; } +.badge-waiting, .badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } -.badge-pending::before, +.badge-waiting::before, .badge-enrolling::before { content: "◐"; } +.badge-info { border-color: var(--color-accent-dim); color: var(--color-accent-dim); } +.badge-info::before { content: "ℹ"; } .badge-error { border-color: var(--color-error); color: var(--color-error); } .badge-error::before { content: "⚠"; } +.badge-paused, .badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); } +.badge-paused::before, .badge-disabled::before { content: "⏸"; } +.summary { + display: flex; + align-items: baseline; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-md); + border: var(--rule-hair) solid var(--color-rule-interactive); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + color: var(--color-warn); +} + +.summary--attention { + border-color: var(--color-error); + color: var(--color-error); +} + +.summary--ok { + border-color: var(--color-ok); + color: var(--color-ok); +} + +.repo--error { + border-left: 2px solid var(--color-error); + padding-left: var(--space-md); + margin-left: calc(-1 * var(--space-md) - 2px); +} + +.repo__health { + margin: 0 0 var(--space-xs); + font-size: var(--text-sm); + color: var(--color-muted); +} + +.repo__health--error { color: var(--color-error); } + +.repo__error-detail { + margin: var(--space-xs) 0 0; +} + +.repo__error-toggle { + padding: 0; + border: 0; + background: transparent; + color: var(--color-neutral); + font-family: var(--font-mono); + font-size: var(--text-xs); + text-decoration: underline; + cursor: pointer; +} + +.repo__error-toggle:hover { color: var(--color-muted); transform: none; } + +.repo__error-raw { + display: block; + margin-top: var(--space-2xs); + padding: var(--space-xs); + background: var(--color-paper); + border: var(--rule-hair) solid var(--color-rule); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + color: var(--color-muted); + word-break: break-word; + white-space: pre-wrap; +} + button { font-family: var(--font-mono); font-size: var(--text-sm); diff --git a/server/api/me/dashboard.get.ts b/server/api/me/dashboard.get.ts index f5a6314..6d64bc8 100644 --- a/server/api/me/dashboard.get.ts +++ b/server/api/me/dashboard.get.ts @@ -1,6 +1,7 @@ import { sql } from 'drizzle-orm' import { installation, repoMapping, sshKey, userIdentity } from '#server/db/schema' import { useDb } from '#server/utils/db' +import { type RepoHealth, repoHealth } from '#server/utils/repo-health' import { requireSession } from '#server/utils/server-session' export interface DashboardRepo { @@ -16,6 +17,14 @@ export interface DashboardRepo { lastSyncedRefs: Record lastSyncedAt: string | null refCount: number + health: RepoHealth +} + +export interface DashboardSummary { + total: number + ok: number + waiting: number + needsAttention: number } export interface DashboardPayload { @@ -34,6 +43,7 @@ export interface DashboardPayload { rotatedAt: string | null } | null repos: DashboardRepo[] + summary: DashboardSummary } export default defineEventHandler(async (event): Promise => { @@ -79,6 +89,7 @@ export default defineEventHandler(async (event): Promise => { // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` const refs = (row.lastSyncedRefs ?? {}) as Record const refKeys = Object.keys(refs) + const disabledAt = row.disabledAt?.toISOString() ?? null return { id: row.id, githubRepoId: row.githubRepoId, @@ -88,13 +99,27 @@ export default defineEventHandler(async (event): Promise => { knot: row.knot, status: row.status, lastError: row.lastError, - disabledAt: row.disabledAt?.toISOString() ?? null, + disabledAt, lastSyncedRefs: refs, lastSyncedAt: refKeys.length > 0 && row.updatedAt ? row.updatedAt.toISOString() : null, refCount: refKeys.length, + health: repoHealth({ + status: row.status, + lastError: row.lastError, + disabledAt, + tangledRepoDid: row.tangledRepoDid, + refCount: refKeys.length, + }), } }) + const summary: DashboardSummary = { + total: repos.length, + ok: repos.filter(r => r.health.state === 'ok').length, + waiting: repos.filter(r => r.health.state === 'waiting' || r.health.state === 'enrolling').length, + needsAttention: repos.filter(r => r.health.needsAttention).length, + } + const installationPayload: DashboardPayload['installation'] = installRow ? { id: installRow.id, @@ -119,5 +144,6 @@ export default defineEventHandler(async (event): Promise => { hasSshKey: sshKeyPayload !== null, sshKey: sshKeyPayload, repos, + summary, } }) diff --git a/server/utils/repo-health.ts b/server/utils/repo-health.ts new file mode 100644 index 0000000..3d170ed --- /dev/null +++ b/server/utils/repo-health.ts @@ -0,0 +1,105 @@ +/** + * Derive a user-facing health summary for a repo mapping. + * + * The dashboard needs to answer three questions for someone who isn't in the + * codebase: is this repo fine, is it still catching up, or is it stuck (and if + * so, what, roughly, do I do about it)? The raw `status` / `lastError` columns + * carry protocol-level detail that means nothing to a user, so we map them to a + * small, stable set of states plus a plain-English message. The raw error is + * kept alongside for the "copy this when reporting" path. + */ + +export type RepoHealthState = + /** Enrolled and at least one ref has synced. Nothing to do. */ + | 'ok' + /** Enrolled but nothing has synced yet: backfill/first push still in flight. */ + | 'waiting' + /** Not yet mirrored on tangled (enrolment hasn't completed). */ + | 'enrolling' + /** User paused sync from the dashboard. */ + | 'paused' + /** Informational, not a failure (e.g. renamed on GitHub). */ + | 'info' + /** Sync is stuck and won't recover on its own without action. */ + | 'error' + +export interface RepoHealth { + state: RepoHealthState + /** One short sentence a non-technical user can act on. */ + message: string + /** Whether this repo should be surfaced in the "needs attention" summary. */ + needsAttention: boolean +} + +export interface RepoHealthInput { + status: string + lastError: string | null + disabledAt: string | null + tangledRepoDid: string | null + refCount: number +} + +/** + * Map a known internal error string to a plain-English explanation. Returns + * `null` for anything unrecognised so the caller can fall back to a generic + * message while still exposing the raw text for reporting. + * + * Keyed on substrings of the errors we actually emit (see `sync-push`, + * `repo-mapping`, `receive-pack`, `tangled-repo`); keep in sync when new + * terminal messages are added. + */ +export function explainRepoError(raw: string): string | null { + const err = raw.toLowerCase() + + if (err.includes('sufficient access permissions') || err.includes('accesscontrol')) { + 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.' + } + if (err.includes('invalid path sequence')) { + return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.' + } + if (err.includes('repository access blocked') || err.includes('repo no longer exists')) { + return 'The mirror on tangled is no longer reachable. Try a resync; if it persists, let us know.' + } + if (err.includes('rejected our ssh key') || err.includes('auth-rejected') || err.includes('authentication methods failed')) { + return 'Tangled rejected our SSH key. Rotating the key from this dashboard usually fixes it.' + } + if (err.includes('exceeded') && (err.includes('bytes') || err.includes('size limit'))) { + return 'A push was larger than the size limit and couldn\'t be mirrored.' + } + if (err.includes('pack signature mismatch') || err.includes('handshake') || err.includes('no advertisement') || err.includes('connection lost')) { + return 'A transient connection problem with tangled interrupted the sync. It will retry automatically; resync to hurry it along.' + } + return null +} + +export function repoHealth(input: RepoHealthInput): RepoHealth { + if (input.disabledAt) { + return { state: 'paused', message: 'Sync is paused. Re-enable to resume mirroring.', needsAttention: false } + } + + // Rename notices (and any future advisory) are prefixed `info:` by the + // webhook handler so they can ride the `lastError` column without a schema + // change. They are not failures. + if (input.lastError?.startsWith('info:')) { + return { state: 'info', message: input.lastError.slice('info:'.length).trim(), needsAttention: false } + } + + if (input.status === 'error') { + const explained = input.lastError ? explainRepoError(input.lastError) : null + return { + state: 'error', + message: explained ?? 'Sync is stuck. Try a resync; if it keeps failing, report it with the details below.', + needsAttention: true, + } + } + + if (!input.tangledRepoDid) { + return { state: 'enrolling', message: 'Setting up the mirror on tangled. This can take a minute.', needsAttention: false } + } + + if (input.refCount === 0) { + return { state: 'waiting', message: 'Enrolled, waiting for the first sync. New installs backfill in the background.', needsAttention: false } + } + + return { state: 'ok', message: 'Mirrored and up to date.', needsAttention: false } +} diff --git a/test/unit/repo-health.spec.ts b/test/unit/repo-health.spec.ts new file mode 100644 index 0000000..6b5236f --- /dev/null +++ b/test/unit/repo-health.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { explainRepoError, repoHealth, type RepoHealthInput } from '../../server/utils/repo-health' + +function input(overrides: Partial = {}): RepoHealthInput { + return { + status: 'active', + lastError: null, + disabledAt: null, + tangledRepoDid: 'did:plc:repo', + refCount: 1, + ...overrides, + } +} + +describe('repoHealth', () => { + it('reports ok for an active mapping with synced refs', () => { + const h = repoHealth(input()) + expect(h.state).toBe('ok') + expect(h.needsAttention).toBe(false) + }) + + it('reports waiting for an enrolled mapping that has synced nothing yet', () => { + const h = repoHealth(input({ refCount: 0 })) + expect(h.state).toBe('waiting') + expect(h.needsAttention).toBe(false) + }) + + it('reports enrolling before the tangled repo exists', () => { + const h = repoHealth(input({ tangledRepoDid: null, refCount: 0 })) + expect(h.state).toBe('enrolling') + }) + + it('reports paused when disabled, regardless of status', () => { + const h = repoHealth(input({ disabledAt: new Date().toISOString(), status: 'error' })) + expect(h.state).toBe('paused') + expect(h.needsAttention).toBe(false) + }) + + it('treats info-prefixed messages as advisory, not errors', () => { + const h = repoHealth(input({ lastError: 'info: renamed on github from a/b to a/c; tangled mirror name unchanged' })) + expect(h.state).toBe('info') + expect(h.needsAttention).toBe(false) + expect(h.message).toBe('renamed on github from a/b to a/c; tangled mirror name unchanged') + }) + + it('flags error status as needing attention with a human-readable message', () => { + const h = repoHealth(input({ + status: 'error', + lastError: 'knot knot1.tangled.sh returned 400: {"error":"AccessControl","message":"DID does not have sufficient access permissions for this operation"}', + })) + expect(h.state).toBe('error') + expect(h.needsAttention).toBe(true) + expect(h.message).toMatch(/already exists/i) + }) + + it('falls back to a generic message for an unrecognised error', () => { + const h = repoHealth(input({ status: 'error', lastError: 'something we have never seen' })) + expect(h.state).toBe('error') + expect(h.message).toMatch(/report it/i) + }) +}) + +describe('explainRepoError', () => { + it('maps the production error strings we actually emit', () => { + expect(explainRepoError('receive-pack: unpack error (pack signature mismatch detected)')).toMatch(/transient connection/i) + expect(explainRepoError('receive-pack: no advertisement (stderr: ssh error: Timed out while waiting for handshake)')).toMatch(/transient connection/i) + expect(explainRepoError('knot rejected our ssh key; stopping sync')).toMatch(/ssh key/i) + expect(explainRepoError('Repository name contains invalid path sequence')).toMatch(/can.t be mirrored/i) + expect(explainRepoError('pack exceeded the configured size limit')).toMatch(/size limit/i) + }) + + it('returns null for anything unrecognised', () => { + expect(explainRepoError('totally novel failure')).toBeNull() + }) +})