mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
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:
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
85
packages/views/locales/en/runtimes.json
Normal file
85
packages/views/locales/en/runtimes.json
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
82
packages/views/locales/zh-Hans/runtimes.json
Normal file
82
packages/views/locales/zh-Hans/runtimes.json
Normal 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}} 个排队中"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 “{runtime.name}”? 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user