Overview: report liveness and a Source whose poll loop stopped

This commit is contained in:
2026-11-02 01:22:34 +08:00
parent a662188a57
commit b348d69beb
10 changed files with 153 additions and 13 deletions
+2 -1
View File
@@ -42,7 +42,8 @@ const openMaster = async (container: HTMLElement) => {
}
const serve = () => vi.stubGlobal('fetch', vi.fn(async (url: string) => {
const path = new URL(url, 'http://127.0.0.1').pathname.replace('/orchestrator/api/', '')
if (path === 'overview') return Response.json({ problems: [], person: [], executor: { running: 0, queued: 0, maxWorkers: 3, idle: 0 }, flow: { triggers: 0, filtered: 0, judged: 0, accepted: 0, evaluating: 0 }, policyVersion: 'v' })
if (path === 'overview') return Response.json({ problems: [], liveness: { startedAt: null, uptimeMs: null, lastPollAt: null, pollAgeMs: null, polling: false }, person: [],
executor: { running: 0, queued: 0, maxWorkers: 3, idle: 0 }, flow: { triggers: 0, filtered: 0, judged: 0, accepted: 0, evaluating: 0 }, policyVersion: 'v' })
if (path === 'state') return Response.json({ masterId: 'master', settings: SettingsSchema.parse({}), revisions: { policy: 0, eval: 0, executor: 0 }, sources: [], policy_version: 'v', health: {}, outgoing: [], load: { running: 0, queued: 0 },
setup: { machine: { stateDir: '/srv/state', worktreeRoot: '/srv/state/worktrees', blockedCommands: [], promptMaxChars: 100000, admissionContextMaxPages: 10, admissionContextTimeoutMs: 15000 }, problem: null, feishuProblem: null, accounts: null } })
return Response.json({ error: 'unavailable' }, { status: 503 })
+9 -2
View File
@@ -20,7 +20,7 @@ import { githubChannel } from '../../src/sources/github-trigger.ts'
import { feishuKind } from '../../src/sources/feishu.ts'
import { githubKind } from '../../src/sources/github.ts'
import type { Health } from '../../src/sources/host.ts'
import type { SourceKind } from '../../src/sources/kind.ts'
import type { Localized, SourceKind } from '../../src/sources/kind.ts'
import { testDeployment } from './deployment.ts'
export const MEMBER = 'ou_alice'
@@ -110,6 +110,8 @@ export interface PanelServer {
opened: string[]
dropped: DroppedReply[]
failing: Extract<Problem, { kind: 'outgoing' }>['outgoing'][]
/** Sources whose poll loop the fixture reports as stopped, with how long ago their last round was. */
stalled: { id: string; title: Localized; ageMs: number; intervalMs: number }[]
}
export let server: PanelServer
@@ -195,8 +197,13 @@ const overviewView = (): ApiResponse<'GET overview'> => {
...Object.entries(server.health).filter(([name, item]) => item.state === 'error' && !KINDS.some(kind => kind.health.includes(name))
&& !name.startsWith('sync:') && name !== 'evals')
.map(([name, item]) => ({ kind: 'runtime-error' as const, name, detail: item.detail })),
...server.stalled.map(item => ({ kind: 'stalled' as const, source: { id: item.id, title: item.title }, ageMs: item.ageMs,
intervalMs: item.intervalMs })),
...Object.values(server.settings.policy.sources).some(part => part.allowed.length) ? [] : [{ kind: 'no-allowed' as const }],
...server.failing.map(outgoing => ({ kind: 'outgoing' as const, outgoing }))],
// The fixture panel renders the readout, so a poll round a few seconds old stands for a running Source.
liveness: { startedAt: '2026-09-22T00:00:00Z', uptimeMs: 3_600_000, lastPollAt: new Date(Date.now() - 4_000).toISOString(),
pollAgeMs: 4_000, polling: true },
person: server.person, executor: { running: 2, queued: 0, maxWorkers: server.settings.executor.maxWorkers, idle: 1 },
flow: { received: 12, filtered: 2, triggers: 10, judged: 5, accepted: 1, evaluating: 0,
dropped: server.dropped.filter(item => item.dropped.at >= dayAgo()).length },
@@ -249,7 +256,7 @@ function initial(): PanelServer {
en: 'Feishu message' } }
const defaults = { revision: 0, previousRevision: null, savedAt: '', source: 'defaults' }
return {
health: {}, down: false, pending: 0, opened: [], requests: [], misuse: [], queued: new Set(), dropped: [], failing: [],
health: {}, down: false, pending: 0, opened: [], requests: [], misuse: [], queued: new Set(), dropped: [], failing: [], stalled: [],
setup: { machine: MACHINE, problem: null, feishuProblem: null,
accounts: { repository: 'acme/widgets', agent: 'agent-bot', feishu: { appId: 'cli_bot', brand: 'feishu', botId: 'ou_bot',
name: 'Widgets Bot' } } },
+22 -5
View File
@@ -2,6 +2,7 @@
// The overview.
import { expect, it } from 'vitest'
import { en, zh } from '../src/client/locales.ts'
import { duration } from '../src/client/text.ts'
import { usePanel, mount, remount, all, find, tab, click, text } from './fixtures/panel.tsx'
import { server, droppedReply, threadSummary } from './fixtures/panel-server.ts'
@@ -10,7 +11,8 @@ it('leads the overview with the pending Tickets, then problems, the last day and
await mount()
expect(tab(zh['tab.overview']).getAttribute('aria-selected')).toBe('true')
expect(all('[role="tab"]').map(tab => tab.textContent)).toEqual(['概览', '来源', '触发', '评估', '执行', '设置'])
expect(all('.dt-section-title').map(title => title.textContent)).toEqual(['待处理工单2', '系统问题2', '过去 24 小时详情 ›', '执行器详情 ›'])
expect(all('.dt-section-title').map(title => title.textContent)).toEqual(['待处理工单2', '系统问题2', '存活', '过去 24 小时详情 ›',
'执行器详情 ›'])
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual(['待回答', '待处理失败'])
expect(all('.dt-cards .dt-att-what').map(item => item.textContent)).toEqual(['飞书 来源异常', '评估未完成:这个文案好奇怪'])
// Actions sit below the title and Threads, apart from the stage, each with its icon.
@@ -18,7 +20,13 @@ it('leads the overview with the pending Tickets, then problems, the last day and
&& [...row.querySelectorAll('.dt-ticket-actions button, .dt-ticket-actions a')].every(action => action.querySelector('svg')))).toBe(true)
expect(text()).toContain('来源会在每次轮询时自动重试')
expect(text()).toContain('需要确认按钮颜色')
expect(all('.dt-stat').map(stat => stat.textContent)).toEqual(['到达12', '被过滤2', '已评估5', '接单或续做1', '执行中2 / 3', '排队0', '等待新触发1'])
// Durations are formatted by the interface language, so the readout is asserted by label, and only the polling word verbatim.
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([zh.livenessUptime, zh.livenessLastPoll, zh.livenessPolling,
zh.flowReceivedLabel, zh.flowFilteredLabel, zh.flowJudgedLabel, zh.flowAcceptedLabel, zh.executingLabel, zh.queuedLabel,
zh.idleLabel])
expect(all('.dt-stat-value').map(value => value.textContent).slice(3)).toEqual(['12', '2', '5', '1', '2 / 3', '0', '1'])
expect(all('.dt-stat-value').map(value => value.textContent).slice(0, 3)).toEqual([expect.stringContaining('小时'), expect
.stringContaining('秒'), zh.livenessPolling])
expect(all('.dt-chip').map(chip => chip.textContent)[0]).toBe('GitHub#42CI 失败1')
expect(document.querySelector('.dt-chip-problem')?.textContent).toBe('CI 失败1')
expect(document.querySelector<HTMLAnchorElement>('a.dt-chip-link')?.href).toBe('https://github.com/acme/widgets/pull/42')
@@ -126,10 +134,10 @@ it('words a refused retry in the interface language by its code', async () => {
it('shows the overview in English', async () => {
await mount(en)
expect(all('.dt-section-title').map(title => title.textContent)).toEqual([`${en.pendingTickets}2`, `${en.problems}2`,
`${en.lastDay}${en.details}`, `${en['term.executor']}${en.details}`])
en.livenessTitle, `${en.lastDay}${en.details}`, `${en['term.executor']}${en.details}`])
expect(all('.dt-cards .dt-ticket-stage').map(item => item.textContent)).toEqual([en['stage.answer'], en['stage.failed']])
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([en.flowReceivedLabel, en.flowFilteredLabel, en.flowJudgedLabel,
en.flowAcceptedLabel, en.executingLabel, en.queuedLabel, en.idleLabel])
expect(all('.dt-stat-label').map(label => label.textContent)).toEqual([en.livenessUptime, en.livenessLastPoll, en.livenessPolling,
en.flowReceivedLabel, en.flowFilteredLabel, en.flowJudgedLabel, en.flowAcceptedLabel, en.executingLabel, en.queuedLabel, en.idleLabel])
})
it('lists the unavailable Evals one by one, without the health entry that counts them as a failure of dsh-superagent', async () => {
@@ -157,3 +165,12 @@ it('says when no Source is on, an Eval is past its deadline or a message keeps f
await click('评估超时未决定:#9 迟迟未决')
expect(server.requests.some(request => request.path.startsWith('threads/github:9'))).toBe(true)
})
it('says when a Source stopped polling, with how long its last round was and the way to it', async () => {
server.stalled = [{ id: 'github', title: { zh: 'GitHub', en: 'GitHub' }, ageMs: 26 * 60_000, intervalMs: 60_000 }]
await mount()
const line = all('.dt-cards .dt-att-what').map(item => item.textContent).find(title => title?.includes('GitHub 最后一次轮询在'))
// The same duration helper the panel uses, so the assertion does not depend on the locale's spacing rules.
expect(line).toBe(zh.problemStalled.replace('{source}', 'GitHub').replace('{age}', duration(26 * 60_000, 'zh')))
expect(text()).toContain(zh.problemStalledHint)
})
+37 -1
View File
@@ -11,7 +11,7 @@ import { snapshot, threadSummary, ticketSummary, triggerView } from '../src/app/
import { basisFacts, ticketDetail } from '../src/app/views/tickets.ts'
import { threadDetail } from '../src/app/views/threads.ts'
import { droppedReplies, sourcesStatus } from '../src/app/views/sources.ts'
import { overview } from '../src/app/views/overview.ts'
import { overview, pollAgeMs } from '../src/app/views/overview.ts'
import { markdown, plain } from '../src/app/views/text.ts'
import { policyHistory } from '../src/app/settings-history.ts'
import { KV } from '../src/kv.ts'
@@ -319,3 +319,39 @@ it('reads each ticket a page\'s follow-ups reached once, and nothing for a messa
expect(followupOwners(store.triggers.get('f3')!, records)).toEqual([])
expect([records.tickets.get, records.threads.get].map(read => read.mock.calls.length)).toEqual([0, 0])
})
it('reads the age of a poll round from the newest report a Source carries, and nothing before it reports one', () => {
const now = Date.now()
expect(pollAgeMs([{}, {}])).toBeUndefined()
expect(pollAgeMs([{ at: new Date(now - 300_000).toISOString() }, { at: new Date(now - 60_000).toISOString() }]))
.toBeGreaterThanOrEqual(59_000)
expect(pollAgeMs([{ at: 'not a time' }])).toBeUndefined()
})
it('lists a Source whose poll loop stopped past a few of its own intervals, and never one still starting or off', () => {
const host = views()
const long = new Date(Date.now() - 20 * 60_000).toISOString()
// github polls every 60 seconds by default, so 20 minutes is well past the floor: the loop stopped.
expect(problems(host, { github: { state: 'ready', detail: '', at: long } }).filter(item => item.kind === 'stalled'))
.toEqual([{ kind: 'stalled', source: { id: 'github', title: githubKind.title }, ageMs: expect.any(Number), intervalMs: 60_000 }])
// A Source that never reported a round is starting, not stalled; the grid an operator reads says so separately.
expect(problems(host, { github: { state: 'starting', detail: '' } }).filter(item => item.kind === 'stalled')).toEqual([])
// A fresh round, or one off Source that is switched off, is not stalled either.
const fresh = new Date(Date.now() - 5_000).toISOString()
expect(problems(host, { github: { state: 'ready', detail: '', at: fresh } }).filter(item => item.kind === 'stalled')).toEqual([])
store.saveSettings({ ...store.settings(), sources: { ...store.settings().sources, github: { enabled: false, config: {} } } })
expect(problems(host, { github: { state: 'ready', detail: '', at: long } }).filter(item => item.kind === 'stalled')).toEqual([])
})
it('reports whether the service is alive and polling apart from whether any Trigger arrived', () => {
const host = views()
const startedAt = new Date(Date.now() - 7_200_000).toISOString()
const polled = new Date(Date.now() - 30_000).toISOString()
const view = overview(host, { github: { state: 'ready', detail: '', at: polled } }, 'v1', load, undefined, startedAt)
expect(view.liveness).toMatchObject({ startedAt, lastPollAt: polled, polling: true })
expect(view.liveness.uptimeMs).toBeGreaterThanOrEqual(7_190_000)
// The process can answer for hours with nothing running: no Source enables no polling, whatever the uptime says.
store.saveSettings({ ...store.settings(), sources: { github: { enabled: false, config: {} }, feishu: { enabled: false, config: {} } } })
expect(overview(host, {}, 'v1', load, undefined, startedAt).liveness).toMatchObject({ polling: false, lastPollAt: null, pollAgeMs: null })
expect(overview(host, {}, 'v1', load).liveness).toMatchObject({ startedAt: null, uptimeMs: null, polling: false })
})