diff --git a/changelog.d/805-instance-health.changed.md b/changelog.d/805-instance-health.changed.md new file mode 100644 index 00000000..9889303b --- /dev/null +++ b/changelog.d/805-instance-health.changed.md @@ -0,0 +1 @@ +The FlyBot instance card now shows a status badge, a human-readable health reason, the last health-check time and the relevant Start/Stop/Reinitialize action, while the raw bridge error stays in a technical disclosure instead of the page message. diff --git a/docs/architecture.md b/docs/architecture.md index 6a2b8765..24717c68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1553,6 +1553,13 @@ Run/Plan/Review until the state is fixed. A danger zone offers stop scheduling, stop FlyBot and delete; deletion requires typing the repository name and removes the Fledge connection, instance, jobs and tokens without touching GitHub. +The FlyBot instance card maps transport failures to a short reason (`Bridge +unreachable`, `Bridge returned 502`, `Container is not running`, `Model endpoint +unreachable`) and keeps the raw error in a technical disclosure. The page-level +message uses the same reason, never a bare `TypeError: fetch failed`, and the +card shows the last health-check time and the Start/Stop/Reinitialize action for +the current state. + ### Pull-request delivery Each repository page has a delivery list of iterate jobs with open pull requests, diff --git a/public/app.css b/public/app.css index 9338c641..9080f9d3 100644 --- a/public/app.css +++ b/public/app.css @@ -1093,6 +1093,9 @@ a.list-row:hover strong { color: var(--accent); } border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--paper); } .workspace-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +[data-instance-health] .badge { vertical-align: baseline; } +[data-instance-health] details { margin-top: 8px; } +[data-instance-health] pre { margin: 6px 0 0; font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; } .add-choice { border: 1px solid var(--line); border-radius: var(--radius-md); padding: 14px; margin-bottom: 12px; } .add-choice h3 { margin: 0 0 6px; font-size: 15px; } diff --git a/src/views/pages.ts b/src/views/pages.ts index c6da7eb5..243019de 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -299,6 +299,24 @@ function statusLabel(t: Translate, status: string): string { return labels[status] ?? status } +// #805: the status answers "why is this not ready" on its own; `last_error` only +// explains a failure. `Instances.stop()` leaves the previous `last_error` in place +// and a starting instance has none, so reading that column for every non-ready +// status called a stopped instance healthy. Only `failed` reads the error, and an +// empty error there means a failed check, never a healthy one. +function instanceReason(t: Translate, status: string, error: string | null | undefined): string { + if (status === 'ready') return t('健康检查正常', 'Health check ok') + if (status === 'stopped') return t('实例已停止,不会接受新任务', 'The instance is stopped and takes no new jobs') + if (status === 'starting') return t('实例正在创建,还未就绪', 'The instance is being created and is not ready yet') + if (status === 'provisioning') return t('实例正在初始化,还未就绪', 'The instance is being provisioned and is not ready yet') + const text = String(error ?? '').toLowerCase() + if (/\b502\b/.test(text)) return t('Bridge 返回 502', 'Bridge returned 502') + if (/fetch failed|econnrefused|enotfound|etimedout|network/.test(text)) return t('Bridge 无法连接', 'Bridge unreachable') + if (/container|docker|not running/.test(text)) return t('容器未运行', 'Container is not running') + if (/model|gateway|provider/.test(text)) return t('模型端点不可达', 'Model endpoint unreachable') + return t('健康检查失败', 'Health check failed') +} + // A webhook delivery stores its state as an internal code. The console names the // three states in the reader's language and keeps the code only as the filter // value, so `/admin/webhooks?status=` links keep filtering exactly as before. @@ -1404,6 +1422,22 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer : runnable ? { body: t('立即运行会创建这个仓库的第一个任务;每次运行只开一个 PR。', 'Run now creates the first job for this repository; each run opens at most one pull request.'), action: { formAction: `/repos/${repo.id}/run`, label: t('立即运行', 'Run now'), csrf: csrf(viewer) } } : { body: t('先修复 FlyBot 实例或 GitHub 凭据,然后再运行。', 'Fix the FlyBot instance or the GitHub credential first, then run.'), action: { label: t('实例与凭据在本页的连接卡片里。', 'The instance and the credential are on the connection card of this page.') } } + const instanceHealthTone = config.flybot.instanceImage === '' + ? 'busy' + : instance === undefined + ? 'busy' + : instance.status === 'ready' + ? 'good' + : instance.status === 'failed' ? 'bad' : 'busy' + const instanceHealthStatus = config.flybot.instanceImage === '' + ? t('未启用', 'not enabled') + : instance === undefined ? t('未创建', 'not created') : statusLabel(t, instance.status) + const instanceHealthReason = config.flybot.instanceImage === '' + ? t('这个部署没有启用每仓库 FlyBot 实例。', 'This deployment has no per-repository FlyBot instance.') + : instance === undefined + ? t('实例尚未创建。', 'The instance has not been created yet.') + : instanceReason(t, instance.status, instance.last_error) + const instanceLastHealth = instance === undefined || instance.last_health_at === null ? t('未知', 'unknown') : humanTimeShort(instance.last_health_at) // #472 slice 3: the instance state is a page-level message, not a banner in the // team card. `failed` is an error; any other non-ready state is informational. if (config.flybot.instanceImage === '') { @@ -1414,7 +1448,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer pageMessages.push({ level: instance.status === 'failed' ? 'error' : 'warning', key: 'instance-status', - text: `${t(`FlyBot 实例状态:${statusLabel(t, instance.status)}`, `FlyBot instance status: ${statusLabel(t, instance.status)}`)}${instance.last_error === null ? '' : ` · ${instance.last_error}`}`, + text: `${t('FlyBot 实例:', 'FlyBot instance: ')}${instanceHealthStatus} · ${instanceHealthReason}`, }) } const instanceActions = config.flybot.instanceImage === '' @@ -1486,12 +1520,14 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer

${escapeHtml(t('读出开着的 issue 和 PR,按优先级排序、标出重复项,并点名下一项。只读:不开分支,也不开 PR。', 'Reads the open issues and pull requests, ranks them by priority, links duplicates and names the next item. Read-only: no branch and no pull request.'))}

${triageForm} ` - const instanceCard = `
+ const instanceCard = `
${escapeHtml(t('每仓库 FlyBot', 'PER-REPOSITORY FLYBOT'))}

${escapeHtml(advanced ? t('实例与团队', 'Instance & team') : t('FlyBot 状态', 'FlyBot status'))}

-

${instance === undefined ? escapeHtml(t('未创建', 'Not created')) : `${badge(t, instance.status)}${advanced ? ` ${escapeHtml(instance.container)}` : ''}`}${instanceReady && advanced ? ` · ${escapeHtml(t('任务先发给总控,再由负责人执行', 'jobs go to the Chief, then the Lead'))}` : ''}

+

${escapeHtml(instanceHealthStatus)} · ${escapeHtml(instanceHealthReason)}

+

${escapeHtml(t('最近检查', 'Last health check'))} ${escapeHtml(instanceLastHealth)}${advanced && instance !== undefined ? ` · ${escapeHtml(instance.container)}` : ''}${instanceReady && advanced ? ` · ${escapeHtml(t('任务先发给总控,再由负责人执行', 'jobs go to the Chief, then the Lead'))}` : ''}

+ ${instance === undefined || instance.last_error === null ? '' : `
${escapeHtml(t('技术详情', 'Technical detail'))}
${escapeHtml(instance.last_error)}
`}
${advanced ? instanceActions : ''}
diff --git a/test/pages.test.ts b/test/pages.test.ts index 53e6d3c8..040a04bd 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -2064,3 +2064,83 @@ test('both repository lists translate the reviewer badge', () => { store.close() } }) + +// #805: the status decides the sentence; `last_error` only explains a failure. +// `Instances.stop()` keeps the previous error and a starting instance has none, so +// every non-ready state must say what it is doing, in both languages, with its own +// action — and only `failed` may report a health failure. +test('every FlyBot instance state names itself, its own action and never calls a non-ready instance healthy', () => { + const config = testConfig({ flybot: { ...testConfig().flybot, instanceImage: 'image' } }) + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'instance-health-user', status: 'approved', superadmin: true }) + const viewer = { user: store.userById(user.id)!, csrfToken: 'csrf' } + const repo = store.createRepo({ id: 'repo_instance_health', userId: user.id, owner: 'o', name: 'health', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const saveInstance = (status: string, lastError: string | null): void => { + store.saveRepoInstance({ + repo_id: repo.id, user_id: user.id, container: 'fledge-health', network: 'n', data_dir: '/tmp/health', bridge_script: '/tmp/bridge', + dsh_port: 1, bridge_port: 2, model_port: 3, model_bind: '127.0.0.1', image: 'image', status, chief_session: 'c', + team_json: null, bridge_token_enc: 'x', bridge_pid: null, log_pid: null, last_error: lastError, + created_at: '2026-11-15T08:00:00.000Z', updated_at: '2026-11-15T08:00:00.000Z', last_health_at: '2026-11-15T08:00:00.000Z', + }) + } + const instanceCard = (html: string): string => { + const start = html.indexOf('data-instance-health') + assert.notEqual(start, -1, 'the Team section has the structured instance card') + const end = html.indexOf('
', start) + assert.notEqual(end, -1, 'the instance card closes') + return html.slice(start, end) + } + const summaryLine = (html: string): string => { + const card = instanceCard(html) + const end = card.indexOf('

') + assert.notEqual(end, -1, 'the instance card has a summary line') + return card.slice(0, end) + } + const cases = [ + { status: 'ready', lastError: null, zhBadge: '就绪', enBadge: 'Ready', zhReason: '健康检查正常', enReason: 'Health check ok', zhAction: '停止 FlyBot', enAction: 'Stop FlyBot', action: 'instance/stop' }, + { status: 'failed', lastError: 'TypeError: fetch failed', zhBadge: '失败', enBadge: 'Failed', zhReason: 'Bridge 无法连接', enReason: 'Bridge unreachable', zhAction: '启动 FlyBot', enAction: 'Start FlyBot', action: 'instance' }, + { status: 'failed', lastError: null, zhBadge: '失败', enBadge: 'Failed', zhReason: '健康检查失败', enReason: 'Health check failed', zhAction: '启动 FlyBot', enAction: 'Start FlyBot', action: 'instance' }, + { status: 'stopped', lastError: 'health check failed: stopped from the console', zhBadge: '已停止', enBadge: 'Stopped', zhReason: '实例已停止,不会接受新任务', enReason: 'The instance is stopped and takes no new jobs', zhAction: '启动 FlyBot', enAction: 'Start FlyBot', action: 'instance' }, + { status: 'starting', lastError: null, zhBadge: '启动中', enBadge: 'Starting', zhReason: '实例正在创建,还未就绪', enReason: 'The instance is being created and is not ready yet', zhAction: '重新初始化 FlyBot', enAction: 'Reinitialize FlyBot', action: 'instance' }, + { status: 'provisioning', lastError: 'TypeError: fetch failed', zhBadge: '初始化中', enBadge: 'Provisioning', zhReason: '实例正在初始化,还未就绪', enReason: 'The instance is being provisioned and is not ready yet', zhAction: '重新初始化 FlyBot', enAction: 'Reinitialize FlyBot', action: 'instance' }, + ] + for (const state of cases) { + saveInstance(state.status, state.lastError) + const instance = store.getRepoInstance(repo.id)! + const zh = repoPage({ config, locale: 'zh', viewer, repo, instance, jobs: [] }) + const en = repoPage({ config, locale: 'en', viewer, repo, instance, jobs: [] }) + const name = `${state.status} (${state.lastError === null ? 'no error' : 'an old error on the row'})` + assert.ok(instanceCard(zh).includes(state.zhBadge), `${name}: the zh card carries the status badge`) + assert.ok(instanceCard(en).includes(state.enBadge), `${name}: the en card carries the status badge`) + assert.ok(instanceCard(zh).includes(state.zhReason), `${name}: the zh card names the state`) + assert.ok(instanceCard(en).includes(state.enReason), `${name}: the en card names the state`) + assert.ok(instanceCard(zh).includes(state.zhAction), `${name}: the zh card offers its own action`) + assert.ok(instanceCard(en).includes(state.enAction), `${name}: the en card offers its own action`) + assert.ok(instanceCard(zh).includes(`action="/repos/${repo.id}/${state.action}"`), `${name}: the zh action posts its own form`) + assert.ok(instanceCard(en).includes(`action="/repos/${repo.id}/${state.action}"`), `${name}: the en action posts its own form`) + if (state.status === 'ready') { + assert.ok(!en.includes('FlyBot instance:'), 'a ready instance raises no page-level message') + assert.ok(!en.includes('Health check failed'), 'a ready instance never reports a failure') + continue + } + assert.ok(summaryLine(zh).includes(state.zhReason), `${name}: the zh summary line carries the reason, not the raw error`) + assert.ok(!instanceCard(zh).includes('健康检查正常'), `${name}: a non-ready instance is never called healthy in zh`) + assert.ok(!instanceCard(en).includes('Health check ok'), `${name}: a non-ready instance is never called healthy in en`) + assert.ok(!zh.includes('健康检查正常'), `${name}: the zh page never calls a non-ready instance healthy`) + assert.ok(!en.includes('Health check ok'), `${name}: the en page never calls a non-ready instance healthy`) + assert.ok(zh.includes(`FlyBot 实例:${state.zhBadge} · ${state.zhReason}`), `${name}: the zh page message repeats the status and the reason`) + assert.ok(en.includes(`FlyBot instance: ${state.enBadge} · ${state.enReason}`), `${name}: the en page message repeats the status and the reason`) + if (state.lastError !== null && state.status !== 'failed') { + assert.ok(!summaryLine(en).includes(state.lastError), `${name}: an old error is not the reason of a non-failed state`) + } + if (state.lastError !== null) { + assert.ok(instanceCard(en).includes('Technical detail'), `${name}: the raw error stays behind the disclosure`) + assert.ok(instanceCard(en).includes(state.lastError), `${name}: the disclosure keeps the original text`) + assert.ok(!en.slice(0, en.indexOf('data-instance-health')).includes(state.lastError), `${name}: the raw error is not the page-level message`) + } + } + } finally { + store.close() + } +})