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 = `${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(instance.last_error)}