feat(i18n): translate runtimes namespace (entry surfaces)

Translates the user-facing runtime list page surfaces:
runtimes-page (header / search / filters / chips / empty / no-matches /
bootstrapping), runtime-detail (topbar + delete dialog + delete toasts),
runtime-detail-page (not-found state), shared.tsx (4-state HealthBadge
labels).

Hook conversion: shared `healthLabel(health)` was a pure module-level
function. Added `useHealthLabel` hook for translated call sites; kept
`healthLabel` as an EN-only fallback for non-component callers (column
factory in runtime-columns).

Deferred:
- runtime-list / runtime-columns (data table column headers + cell
  bodies) — large surface, not in the page-load critical path.
- connect-remote-dialog / update-section / usage-section — secondary
  flows, English remains acceptable until a focused pass.
- charts/* — primarily numeric tooltips and axes; minimal user-visible
  text.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-30 15:50:12 +08:00
parent 0d964c25c7
commit a0c13b6b9f
9 changed files with 265 additions and 93 deletions

View File

@@ -78,6 +78,10 @@ const TRANSLATED_FILES = [
"settings/components/workspace-tab.tsx",
"settings/components/members-tab.tsx",
"settings/components/delete-workspace-dialog.tsx",
"runtimes/components/runtimes-page.tsx",
"runtimes/components/runtime-detail-page.tsx",
"runtimes/components/runtime-detail.tsx",
"runtimes/components/shared.tsx",
];
export default [

View File

@@ -18,6 +18,7 @@ import type autopilots from "../locales/en/autopilots.json";
import type skills from "../locales/en/skills.json";
import type chat from "../locales/en/chat.json";
import type modals from "../locales/en/modals.json";
import type runtimes from "../locales/en/runtimes.json";
// Module augmentation enables i18next v26 selector API across the monorepo:
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
@@ -50,6 +51,7 @@ declare module "i18next" {
skills: typeof skills;
chat: typeof chat;
modals: typeof modals;
runtimes: typeof runtimes;
};
enableSelector: true;
}

View File

@@ -0,0 +1,85 @@
{
"page": {
"title": "Runtimes",
"tagline": "Machines and cloud workers running CLI sessions for your agents.",
"learn_more": "Learn more →",
"connect_remote": "Connect remote machine",
"search_placeholder": "Search runtimes…",
"scope_mine": "Mine",
"scope_all": "All",
"live": "Live",
"live_tooltip": "Real-time updates · offline detection up to 75s",
"filter_all": "All",
"filter_all_description": "All runtimes in this view",
"empty": {
"title": "No runtimes yet",
"hint": "Desktop auto-scans your local machine. For AWS EC2 or other remote machines, connect them using the setup wizard."
},
"no_matches": {
"title": "No matches",
"with_query": "No runtimes match \"{{query}}\"{{filterSuffix}}.",
"with_query_filter_suffix": " in this filter",
"no_query": "No runtimes match this filter.",
"try_widening": " Try widening the scope or clearing filters."
},
"bootstrapping": {
"title": "Starting local runtime…",
"hint": "This usually takes a few seconds. Your daemon is registering with the workspace."
}
},
"health": {
"online": {
"label": "Online",
"description": "Heartbeat received in the last 45s. Ready to dispatch tasks."
},
"recently_lost": {
"label": "Recently lost",
"description": "Lost contact under 5 minutes ago — often a brief network blip."
},
"offline": {
"label": "Offline",
"description": "No heartbeat for 5+ minutes. Restart the daemon or investigate the host."
},
"about_to_gc": {
"label": "About to GC",
"description": "Offline 6+ days. Auto-deleted at 7 days unless it reconnects."
}
},
"detail": {
"all_runtimes": "All runtimes",
"read_only": "Read-only",
"delete_aria": "Delete runtime",
"delete_tooltip": "Delete runtime",
"delete_button": "Delete runtime",
"last_seen": "last seen {{when}}",
"fact_owner": "Owner",
"fact_device": "Device",
"fact_runtime": "Runtime",
"technical_details": "Technical details",
"fact_daemon_cli": "Daemon CLI",
"fact_daemon_id": "Daemon ID",
"serving_title": "Serving",
"serving_count_one": "{{count}} agent",
"serving_count_other": "{{count}} agents",
"no_agents": "No agents are bound to this runtime yet.",
"diagnostics_title": "Diagnostics",
"diagnostics_cli": "CLI",
"delete_dialog": {
"title": "Delete Runtime",
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete",
"deleting": "Deleting..."
},
"toast_deleted": "Runtime deleted",
"toast_delete_failed": "Failed to delete runtime"
},
"detail_page": {
"not_found_title": "Runtime not found",
"not_found_hint": "It may have been deleted or you may not have access."
},
"running_one": "{{count}} running",
"running_other": "{{count}} running",
"queued_one": "{{count}} queued",
"queued_other": "{{count}} queued"
}

View File

@@ -15,6 +15,7 @@ import enAutopilots from "./en/autopilots.json";
import enSkills from "./en/skills.json";
import enChat from "./en/chat.json";
import enModals from "./en/modals.json";
import enRuntimes from "./en/runtimes.json";
import zhHansCommon from "./zh-Hans/common.json";
import zhHansAuth from "./zh-Hans/auth.json";
import zhHansSettings from "./zh-Hans/settings.json";
@@ -31,6 +32,7 @@ import zhHansAutopilots from "./zh-Hans/autopilots.json";
import zhHansSkills from "./zh-Hans/skills.json";
import zhHansChat from "./zh-Hans/chat.json";
import zhHansModals from "./zh-Hans/modals.json";
import zhHansRuntimes from "./zh-Hans/runtimes.json";
// Single source of truth for the resource bundle. Both apps (web layout +
// desktop App.tsx) import from here so adding a locale or namespace happens
@@ -53,6 +55,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
skills: enSkills,
chat: enChat,
modals: enModals,
runtimes: enRuntimes,
},
"zh-Hans": {
common: zhHansCommon,
@@ -71,5 +74,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
skills: zhHansSkills,
chat: zhHansChat,
modals: zhHansModals,
runtimes: zhHansRuntimes,
},
};

View File

@@ -0,0 +1,82 @@
{
"page": {
"title": "运行时",
"tagline": "为智能体跑 CLI 会话的机器和云端 worker。",
"learn_more": "了解更多 →",
"connect_remote": "连接远程机器",
"search_placeholder": "搜索运行时…",
"scope_mine": "我的",
"scope_all": "全部",
"live": "实时",
"live_tooltip": "实时更新 · 离线检测 75 秒以内",
"filter_all": "全部",
"filter_all_description": "当前视图下的所有运行时",
"empty": {
"title": "还没有运行时",
"hint": "桌面端会自动扫描本地机器。对于 AWS EC2 或其他远程机器,可使用连接向导。"
},
"no_matches": {
"title": "无匹配",
"with_query": "没有运行时匹配 \"{{query}}\"{{filterSuffix}}。",
"with_query_filter_suffix": "(在当前筛选下)",
"no_query": "当前筛选下没有匹配的运行时。",
"try_widening": "试试放宽筛选或清除条件。"
},
"bootstrapping": {
"title": "正在启动本地运行时…",
"hint": "通常需要几秒钟。守护进程正在向工作区注册。"
}
},
"health": {
"online": {
"label": "在线",
"description": "最近 45 秒内收到心跳。可以派发 task。"
},
"recently_lost": {
"label": "最近失联",
"description": "失联不到 5 分钟——通常是短暂的网络抖动。"
},
"offline": {
"label": "离线",
"description": "5 分钟以上没有心跳。请重启守护进程或排查主机。"
},
"about_to_gc": {
"label": "即将清理",
"description": "离线 6 天以上。如不重连,将在 7 天时自动删除。"
}
},
"detail": {
"all_runtimes": "全部运行时",
"read_only": "只读",
"delete_aria": "删除运行时",
"delete_tooltip": "删除运行时",
"delete_button": "删除运行时",
"last_seen": "最后活跃 {{when}}",
"fact_owner": "所有者",
"fact_device": "设备",
"fact_runtime": "运行时",
"technical_details": "技术详情",
"fact_daemon_cli": "守护进程 CLI",
"fact_daemon_id": "守护进程 ID",
"serving_title": "服务中",
"serving_count_other": "{{count}} 个智能体",
"no_agents": "还没有智能体绑定到这个运行时。",
"diagnostics_title": "诊断",
"diagnostics_cli": "CLI",
"delete_dialog": {
"title": "删除运行时",
"description": "确定要删除\"{{name}}\"吗?此操作不可撤销。",
"cancel": "取消",
"confirm": "删除",
"deleting": "删除中..."
},
"toast_deleted": "已删除运行时",
"toast_delete_failed": "删除运行时失败"
},
"detail_page": {
"not_found_title": "未找到运行时",
"not_found_hint": "它可能已被删除,或你没有访问权限。"
},
"running_other": "{{count}} 个运行中",
"queued_other": "{{count}} 个排队中"
}

View File

@@ -6,6 +6,7 @@ import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { RuntimeDetail } from "./runtime-detail";
import { useT } from "../../i18n";
/**
* Routed entry for `/{slug}/runtimes/{id}`. Reads the workspace runtime list
@@ -16,6 +17,7 @@ import { RuntimeDetail } from "./runtime-detail";
* keeps cache invariants simple (one cache, one update path).
*/
export function RuntimeDetailPage({ runtimeId }: { runtimeId: string }) {
const { t } = useT("runtimes");
const wsId = useWorkspaceId();
const { data: runtimes, isLoading } = useQuery(runtimeListOptions(wsId));
@@ -38,9 +40,9 @@ export function RuntimeDetailPage({ runtimeId }: { runtimeId: string }) {
return (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Server className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Runtime not found</p>
<p className="mt-3 text-sm">{t(($) => $.detail_page.not_found_title)}</p>
<p className="mt-1 text-xs text-muted-foreground/70">
It may have been deleted or you may not have access.
{t(($) => $.detail_page.not_found_hint)}
</p>
</div>
);

View File

@@ -45,6 +45,7 @@ import { HealthBadge } from "./shared";
import { ProviderLogo } from "./provider-logo";
import { UpdateSection } from "./update-section";
import { UsageSection } from "./usage-section";
import { useT } from "../../i18n";
function getCliVersion(metadata: Record<string, unknown>): string | null {
if (
@@ -89,6 +90,7 @@ function useNowTick(intervalMs = 30_000): number {
}
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const { t } = useT("runtimes");
const cliVersion =
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
const launchedBy =
@@ -124,11 +126,11 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const handleDelete = () => {
deleteMutation.mutate(runtime.id, {
onSuccess: () => {
toast.success("Runtime deleted");
toast.success(t(($) => $.detail.toast_deleted));
setDeleteOpen(false);
},
onError: (e) => {
toast.error(e instanceof Error ? e.message : "Failed to delete runtime");
toast.error(e instanceof Error ? e.message : t(($) => $.detail.toast_delete_failed));
},
});
};
@@ -148,7 +150,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
render={<AppLink href={paths.runtimes()} />}
>
<ArrowLeft className="h-3 w-3" />
All runtimes
{t(($) => $.detail.all_runtimes)}
</Button>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="truncate font-mono text-xs text-foreground">
@@ -158,7 +160,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
{!canDelete && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
Read-only
{t(($) => $.detail.read_only)}
</span>
)}
{canDelete && (
@@ -170,13 +172,13 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
size="icon-sm"
onClick={() => setDeleteOpen(true)}
className="text-muted-foreground hover:text-destructive"
aria-label="Delete runtime"
aria-label={t(($) => $.detail.delete_aria)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>Delete runtime</TooltipContent>
<TooltipContent>{t(($) => $.detail.delete_tooltip)}</TooltipContent>
</Tooltip>
)}
</div>
@@ -223,19 +225,19 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
<AlertDialog open={deleteOpen} onOpenChange={(v) => { if (!v) setDeleteOpen(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Runtime</AlertDialogTitle>
<AlertDialogTitle>{t(($) => $.detail.delete_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &ldquo;{runtime.name}&rdquo;? This action cannot be undone.
{t(($) => $.detail.delete_dialog.description, { name: runtime.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t(($) => $.detail.delete_dialog.cancel)}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
{deleteMutation.isPending ? t(($) => $.detail.delete_dialog.deleting) : t(($) => $.detail.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -20,6 +20,7 @@ import {
import { PageHeader } from "../../layout/page-header";
import { ConnectRemoteDialog } from "./connect-remote-dialog";
import { RuntimeList } from "./runtime-list";
import { useT } from "../../i18n";
type RuntimeFilter = "mine" | "all";
type HealthFilter = "all" | "online" | "recently_lost" | "offline" | "about_to_gc";
@@ -32,35 +33,12 @@ const HEALTH_ORDER: HealthFilter[] = [
"about_to_gc",
];
// Single source of truth for the 4-state chip visuals + tooltip copy.
// Thresholds come from `deriveRuntimeHealth`: 45s heartbeat window (server
// sweeper), 5 min "recently lost" cutoff, 6 day "about_to_gc" trigger,
// 7 day hard GC. Wording leans on what the user should *do*, not the
// internals of the sweeper — those live in the redesign doc.
const HEALTH_CHIP: Record<
Exclude<HealthFilter, "all">,
{ label: string; dot: string; description: string }
> = {
online: {
label: "Online",
dot: "bg-success",
description: "Heartbeat received in the last 45s. Ready to dispatch tasks.",
},
recently_lost: {
label: "Recently lost",
dot: "bg-warning",
description: "Lost contact under 5 minutes ago — often a brief network blip.",
},
offline: {
label: "Offline",
dot: "bg-muted-foreground/40",
description: "No heartbeat for 5+ minutes. Restart the daemon or investigate the host.",
},
about_to_gc: {
label: "About to GC",
dot: "bg-destructive",
description: "Offline 6+ days. Auto-deleted at 7 days unless it reconnects.",
},
// Dot tokens stay in code — labels/descriptions flow through useT.
const HEALTH_DOT: Record<Exclude<HealthFilter, "all">, string> = {
online: "bg-success",
recently_lost: "bg-warning",
offline: "bg-muted-foreground/40",
about_to_gc: "bg-destructive",
};
interface RuntimesPageProps {
@@ -214,31 +192,32 @@ function PageHeaderBar({
totalCount: number;
onConnectRemote: () => void;
}) {
const { t } = useT("runtimes");
return (
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">Runtimes</h1>
<h1 className="text-sm font-medium">{t(($) => $.page.title)}</h1>
{totalCount > 0 && (
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
{totalCount}
</span>
)}
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
Machines and cloud workers running CLI sessions for your agents.{" "}
{t(($) => $.page.tagline)}{" "}
<a
href="https://multica.ai/docs/runtimes"
target="_blank"
rel="noopener noreferrer"
className="underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground"
>
Learn more
{t(($) => $.page.learn_more)}
</a>
</p>
</div>
<Button type="button" size="sm" onClick={onConnectRemote}>
<Plus className="h-3 w-3" />
Connect remote machine
{t(($) => $.page.connect_remote)}
</Button>
</PageHeader>
);
@@ -268,6 +247,7 @@ function CardToolbar({
scope: RuntimeFilter;
setScope: (v: RuntimeFilter) => void;
}) {
const { t } = useT("runtimes");
return (
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<div className="relative">
@@ -275,7 +255,7 @@ function CardToolbar({
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search runtimes…"
placeholder={t(($) => $.page.search_placeholder)}
className="h-8 w-64 pl-8 text-sm"
/>
</div>
@@ -288,12 +268,12 @@ function CardToolbar({
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success/60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span>
Live
{t(($) => $.page.live)}
</div>
}
/>
<TooltipContent side="top">
Real-time updates · offline detection up to 75s
{t(($) => $.page.live_tooltip)}
</TooltipContent>
</Tooltip>
</div>
@@ -307,9 +287,7 @@ function ScopeSegment({
value: RuntimeFilter;
onChange: (v: RuntimeFilter) => void;
}) {
// Mine first — that's the default selection and the more frequent
// scope (your own runtimes), so it lives in the leading slot. Mirrors
// the Agents page convention.
const { t } = useT("runtimes");
return (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
@@ -320,7 +298,7 @@ function ScopeSegment({
: "text-muted-foreground hover:text-foreground"
}`}
>
Mine
{t(($) => $.page.scope_mine)}
</button>
<button
onClick={() => onChange("all")}
@@ -330,7 +308,7 @@ function ScopeSegment({
: "text-muted-foreground hover:text-foreground"
}`}
>
All
{t(($) => $.page.scope_all)}
</button>
</div>
);
@@ -353,21 +331,27 @@ function FilterChipsRow({
healthCounts: Record<Exclude<HealthFilter, "all">, number>;
total: number;
}) {
const { t } = useT("runtimes");
return (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
{HEALTH_ORDER.map((key) => {
const count = key === "all" ? total : healthCounts[key];
const visual = key === "all" ? null : HEALTH_CHIP[key];
const label =
key === "all"
? t(($) => $.page.filter_all)
: t(($) => $.health[key].label);
const description =
key === "all" ? "All runtimes in this view" : visual!.description;
key === "all"
? t(($) => $.page.filter_all_description)
: t(($) => $.health[key].description);
return (
<HealthChip
key={key}
active={healthFilter === key}
onClick={() => setHealthFilter(key)}
label={visual?.label ?? "All"}
label={label}
count={count}
dotClass={visual?.dot}
dotClass={key === "all" ? undefined : HEALTH_DOT[key]}
description={description}
/>
);
@@ -430,15 +414,15 @@ function HealthChip({
// ---------------------------------------------------------------------------
function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
const { t } = useT("runtimes");
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Server className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="mt-4 text-base font-semibold">No runtimes yet</h2>
<h2 className="mt-4 text-base font-semibold">{t(($) => $.page.empty.title)}</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Desktop auto-scans your local machine. For AWS EC2 or other remote
machines, connect them using the setup wizard.
{t(($) => $.page.empty.hint)}
</p>
<Button
type="button"
@@ -447,7 +431,7 @@ function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) {
className="mt-5"
>
<Plus className="h-3 w-3" />
Connect remote machine
{t(($) => $.page.connect_remote)}
</Button>
</div>
);
@@ -470,13 +454,14 @@ function NoMatchesState({
scope: RuntimeFilter;
bootstrapping?: boolean;
}) {
const { t } = useT("runtimes");
if (bootstrapping) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center">
<Server className="h-8 w-8 animate-pulse text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Starting local runtime</p>
<p className="text-sm text-muted-foreground">{t(($) => $.page.bootstrapping.title)}</p>
<p className="max-w-xs text-xs text-muted-foreground/70">
This usually takes a few seconds. Your daemon is registering with the workspace.
{t(($) => $.page.bootstrapping.hint)}
</p>
</div>
);
@@ -485,16 +470,17 @@ function NoMatchesState({
const hasSearch = search.length > 0;
const hasHealthFilter = healthFilter !== "all";
const hasScope = scope === "mine";
const filterSuffix = hasHealthFilter || hasScope ? t(($) => $.page.no_matches.with_query_filter_suffix) : "";
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center text-muted-foreground">
<Search className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm">No matches</p>
<p className="text-sm">{t(($) => $.page.no_matches.title)}</p>
<p className="max-w-xs text-xs">
{hasSearch
? `No runtimes match "${search}"${hasHealthFilter || hasScope ? " in this filter" : ""}.`
: "No runtimes match this filter."}{" "}
Try widening the scope or clearing filters.
? t(($) => $.page.no_matches.with_query, { query: search, filterSuffix })
: t(($) => $.page.no_matches.no_query)}
{t(($) => $.page.no_matches.try_widening)}
</p>
</div>
);

View File

@@ -2,6 +2,7 @@ import { Cloud, Monitor, Wifi, WifiHigh, WifiOff } from "lucide-react";
import { Badge } from "@multica/ui/components/ui/badge";
import type { RuntimeHealth } from "@multica/core/runtimes";
import { ProviderLogo } from "./provider-logo";
import { useT } from "../../i18n";
export function RuntimeModeIcon({ mode }: { mode: string }) {
return mode === "cloud" ? (
@@ -26,30 +27,13 @@ export function ProviderChip({ provider }: { provider: string }) {
// The mapping intentionally reuses our existing tokens (success/warning/
// muted-foreground/destructive) instead of introducing runtime-specific
// colours — keeps the palette small and consistent with Skills.
const HEALTH_VISUAL: Record<
RuntimeHealth,
{ dot: string; label: string; tone: string }
> = {
online: {
dot: "bg-success",
label: "Online",
tone: "bg-success/10 text-success",
},
recently_lost: {
dot: "bg-warning",
label: "Recently lost",
tone: "bg-warning/10 text-warning",
},
offline: {
dot: "bg-muted-foreground/40",
label: "Offline",
tone: "bg-muted text-muted-foreground",
},
about_to_gc: {
dot: "bg-destructive",
label: "About to GC",
tone: "bg-destructive/10 text-destructive",
},
// Maps each derived 4-state runtime health to a semantic colour class.
// Labels flow through useT — see useHealthLabel below.
const HEALTH_VISUAL: Record<RuntimeHealth, { dot: string; tone: string }> = {
online: { dot: "bg-success", tone: "bg-success/10 text-success" },
recently_lost: { dot: "bg-warning", tone: "bg-warning/10 text-warning" },
offline: { dot: "bg-muted-foreground/40", tone: "bg-muted text-muted-foreground" },
about_to_gc: { dot: "bg-destructive", tone: "bg-destructive/10 text-destructive" },
};
export function HealthDot({
@@ -106,9 +90,29 @@ export function HealthIcon({
return <Icon className={`${className} ${tone}`} />;
}
// English-only fallback. Pure function form for non-component callers
// (e.g. column factory builders). Translated call sites should use the
// `useHealthLabel` hook below instead.
const HEALTH_LABEL_EN: Record<RuntimeHealth, string> = {
online: "Online",
recently_lost: "Recently lost",
offline: "Offline",
about_to_gc: "About to GC",
};
export function healthLabel(health: RuntimeHealth | "loading"): string {
if (health === "loading") return "—";
return HEALTH_VISUAL[health].label;
return HEALTH_LABEL_EN[health];
}
// Hook form: usable inside React components (preferred for new call sites
// that aren't running in non-component contexts).
export function useHealthLabel(): (health: RuntimeHealth | "loading") => string {
const { t } = useT("runtimes");
return (health) => {
if (health === "loading") return "—";
return t(($) => $.health[health].label);
};
}
export function HealthBadge({
@@ -116,6 +120,7 @@ export function HealthBadge({
}: {
health: RuntimeHealth | "loading";
}) {
const labelOf = useHealthLabel();
if (health === "loading") {
return (
<Badge variant="secondary" className="bg-muted text-muted-foreground">
@@ -127,7 +132,7 @@ export function HealthBadge({
return (
<Badge variant="secondary" className={v.tone}>
<span className={`h-1.5 w-1.5 rounded-full ${v.dot}`} />
{v.label}
{labelOf(health)}
</Badge>
);
}