From 87b6b726cb30a5a6e1d1d2e4ff2356ad72635ad3 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 10 Jul 2026 13:01:00 +0200 Subject: [PATCH] fix: reflect pending and failing pushes in dashboard repo health --- app/pages/dashboard.vue | 19 +++++++++++++------ server/api/me/dashboard.get.ts | 32 ++++++++++++++++++++++++++++++-- server/utils/repo-health.ts | 30 ++++++++++++++++++++---------- test/unit/repo-health.spec.ts | 19 +++++++++++++++++++ 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue index e327f1c..086860a 100644 --- a/app/pages/dashboard.vue +++ b/app/pages/dashboard.vue @@ -183,10 +183,11 @@ async function logout() { // with a long repo list sees the problems without scrolling. const HEALTH_ORDER: Record = { error: 0, - enrolling: 1, - info: 2, - paused: 3, - ok: 4, + behind: 1, + enrolling: 2, + info: 3, + paused: 4, + ok: 5, } const sortedRepos = computed(() => { const list = data.value?.repos ?? [] @@ -361,9 +362,13 @@ function fmtDate(iso: string | null): string { -
+
- {{ data.summary.enrolling }} {{ data.summary.enrolling === 1 ? 'repository is' : 'repositories are' }} still setting up on tangled. This is normal right after installing. + + + + This clears on its own. +
@@ -849,7 +854,9 @@ code { .badge-ok { border-color: var(--color-ok); color: var(--color-ok); } .badge-ok::before { content: "●"; } +.badge-behind, .badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } +.badge-behind::before, .badge-enrolling::before { content: "◐"; } .badge-info { border-color: var(--color-accent-dim); color: var(--color-accent-dim); } .badge-info::before { content: "ℹ"; } diff --git a/server/api/me/dashboard.get.ts b/server/api/me/dashboard.get.ts index 0ec5615..abaab6d 100644 --- a/server/api/me/dashboard.get.ts +++ b/server/api/me/dashboard.get.ts @@ -1,5 +1,5 @@ import { sql } from 'drizzle-orm' -import { installation, repoMapping, sshKey, userIdentity } from '#server/db/schema' +import { installation, job, 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' @@ -23,6 +23,7 @@ export interface DashboardRepo { export interface DashboardSummary { total: number ok: number + behind: number enrolling: number needsAttention: number } @@ -50,7 +51,7 @@ export default defineEventHandler(async (event): Promise => { const session = await requireSession(event) const db = useDb() - const [identityRow, installRows, repoRows, sshKeyRows] = await Promise.all([ + const [identityRow, installRows, repoRows, sshKeyRows, jobRows] = await Promise.all([ db.select({ did: userIdentity.did, handle: userIdentity.handle }) .from(userIdentity) .where(sql`${userIdentity.did} = ${session.did}`) @@ -75,11 +76,35 @@ export default defineEventHandler(async (event): Promise => { .from(sshKey) .where(sql`${sshKey.installationId} = ${session.installationId} AND ${sshKey.did} = ${session.did}`) .limit(1), + // Outstanding sync work per repo. A queued/running ref job means the mirror + // is behind (a push hasn't landed yet); a failed one means it's stuck. The + // `repo_mapping` row alone can't tell us this: `lastError` is only set on + // terminal rejections, and a repo pending its first relayed push looks + // identical to a fully-synced one. Keyed off the job payload's repo id. + db.select({ + // bigint and count(*) both arrive as strings over the wire; coerce below. + githubRepoId: sql`(${job.payload}->>'githubRepoId')::bigint`, + pending: sql`count(*) filter (where ${job.status} in ('queued', 'running'))`, + failing: sql`count(*) filter (where ${job.status} = 'failed')`, + }) + .from(job) + .where(sql` + (${job.payload}->>'installationId')::bigint = ${session.installationId} + AND ${job.kind} in ('github.push', 'github.create', 'github.delete') + AND ${job.status} in ('queued', 'running', 'failed') + `) + .groupBy(sql`(${job.payload}->>'githubRepoId')::bigint`), ]) const installRow = installRows[0] ?? null const sshKeyRow = sshKeyRows[0] ?? null + const jobStateByRepo = new Map() + for (const j of jobRows) { + if (j.githubRepoId == null) continue + jobStateByRepo.set(Number(j.githubRepoId), { pending: Number(j.pending), failing: Number(j.failing) }) + } + const repos: DashboardRepo[] = repoRows.map(row => { // Schema audit prefers no new column for "last push time": `updatedAt` // moves on any mapping mutation (status, errors, disable toggle), so it's @@ -108,6 +133,8 @@ export default defineEventHandler(async (event): Promise => { lastError: row.lastError, disabledAt, tangledRepoDid: row.tangledRepoDid, + pendingJobs: jobStateByRepo.get(row.githubRepoId)?.pending ?? 0, + failingJobs: jobStateByRepo.get(row.githubRepoId)?.failing ?? 0, }), } }) @@ -115,6 +142,7 @@ export default defineEventHandler(async (event): Promise => { const summary: DashboardSummary = { total: repos.length, ok: repos.filter(r => r.health.state === 'ok').length, + behind: repos.filter(r => r.health.state === 'behind').length, enrolling: repos.filter(r => r.health.state === 'enrolling').length, needsAttention: repos.filter(r => r.health.needsAttention).length, } diff --git a/server/utils/repo-health.ts b/server/utils/repo-health.ts index 232b7bc..8c431cc 100644 --- a/server/utils/repo-health.ts +++ b/server/utils/repo-health.ts @@ -10,8 +10,10 @@ */ export type RepoHealthState = - /** Mirrored on tangled (the knot holds the repo). Nothing to do. */ + /** Mirrored on tangled and no outstanding sync work. Nothing to do. */ | 'ok' + /** Mirrored, but a push/ref update is still queued (catching up). */ + | 'behind' /** Enrolment hasn't produced a tangled mirror yet. */ | 'enrolling' /** User paused sync from the dashboard. */ @@ -34,6 +36,10 @@ export interface RepoHealthInput { lastError: string | null disabledAt: string | null tangledRepoDid: string | null + /** Count of queued/running ref-sync jobs for this repo. */ + pendingJobs: number + /** Count of failed ref-sync jobs for this repo. */ + failingJobs: number } /** @@ -85,24 +91,28 @@ export function repoHealth(input: RepoHealthInput): RepoHealth { return { state: 'info', message: input.lastError.slice('info:'.length).trim(), needsAttention: false } } - if (input.status === 'error') { + // A stuck mapping, or a failed ref-sync job for an otherwise-active mapping: + // either way sync won't recover on its own. + if (input.status === 'error' || input.failingJobs > 0) { 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.', + message: explained ?? 'A recent sync failed and needs attention. Try a resync; if it keeps failing, report it with the details below.', needsAttention: true, } } - // The knot clones the whole repo from `source` at enrolment, so a mapping - // with a `tangledRepoDid` and `active` status is mirrored regardless of - // whether we've relayed a push since. `refCount` counts only pushes we've - // observed after enrolment, so it is NOT a completeness signal: a repo that - // simply hasn't been pushed to since enrolment still has `refCount === 0` - // while being fully up to date on the knot. if (!input.tangledRepoDid || input.status !== 'active') { return { state: 'enrolling', message: 'Setting up the mirror on tangled. This can take a minute.', needsAttention: false } } - return { state: 'ok', message: 'Mirrored and syncing.', needsAttention: false } + // Mirrored, but a push hasn't landed on the knot yet. This is the honest + // "not up to date *right now*" signal: the knot cloned the whole repo at + // enrolment, so the mapping is otherwise complete, but an outstanding job + // means a newer commit is still in flight. + if (input.pendingJobs > 0) { + return { state: 'behind', message: 'Catching up: a recent push is still syncing to tangled.', 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 index 8e16230..7c36b66 100644 --- a/test/unit/repo-health.spec.ts +++ b/test/unit/repo-health.spec.ts @@ -7,6 +7,8 @@ function input(overrides: Partial = {}): RepoHealthInput { lastError: null, disabledAt: null, tangledRepoDid: 'did:plc:repo', + pendingJobs: 0, + failingJobs: 0, ...overrides, } } @@ -27,6 +29,23 @@ describe('repoHealth', () => { expect(h.state).toBe('ok') }) + it('reports behind when a push is still queued for a mirrored repo', () => { + const h = repoHealth(input({ pendingJobs: 1 })) + expect(h.state).toBe('behind') + expect(h.needsAttention).toBe(false) + }) + + it('flags a failing sync job as an error even when the mapping status is active', () => { + const h = repoHealth(input({ failingJobs: 2 })) + expect(h.state).toBe('error') + expect(h.needsAttention).toBe(true) + }) + + it('prefers the error state over behind when both failing and pending jobs exist', () => { + const h = repoHealth(input({ pendingJobs: 3, failingJobs: 1 })) + expect(h.state).toBe('error') + }) + it('reports enrolling before the tangled repo exists', () => { const h = repoHealth(input({ tangledRepoDid: null })) expect(h.state).toBe('enrolling') -- 2.51.2