Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/805-instance-health.changed.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
42 changes: 39 additions & 3 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 === '') {
Expand All @@ -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 === ''
Expand Down Expand Up @@ -1486,12 +1520,14 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
<p class="muted">${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.'))}</p>
${triageForm}
</section>`
const instanceCard = `<section class="card">
const instanceCard = `<section class="card" data-instance-health>
<div class="row between">
<div>
<div class="eyebrow">${escapeHtml(t('每仓库 FlyBot', 'PER-REPOSITORY FLYBOT'))}</div>
<h2>${escapeHtml(advanced ? t('实例与团队', 'Instance & team') : t('FlyBot 状态', 'FlyBot status'))}</h2>
<p class="muted">${instance === undefined ? escapeHtml(t('未创建', 'Not created')) : `${badge(t, instance.status)}${advanced ? ` <span class="muted">${escapeHtml(instance.container)}</span>` : ''}`}${instanceReady && advanced ? ` · ${escapeHtml(t('任务先发给总控,再由负责人执行', 'jobs go to the Chief, then the Lead'))}` : ''}</p>
<p class="muted"><span class="badge ${instanceHealthTone}">${escapeHtml(instanceHealthStatus)}</span> · ${escapeHtml(instanceHealthReason)}</p>
<p class="fine">${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'))}` : ''}</p>
${instance === undefined || instance.last_error === null ? '' : `<details class="advanced-help"><summary>${escapeHtml(t('技术详情', 'Technical detail'))}</summary><pre>${escapeHtml(instance.last_error)}</pre></details>`}
</div>
${advanced ? instanceActions : ''}
</div>
Expand Down
80 changes: 80 additions & 0 deletions test/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('</section>', 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('</p>')
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()
}
})
Loading