diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 8704dc55c38..48def46e3fe 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -25,6 +25,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", + "i18next": "26.5.11", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", "moment": "2.31.0", @@ -43,6 +44,7 @@ "react-dom": "19.2.8", "react-hook-form": "7.82.0", "react-hotkeys-hook": "5.3.3", + "react-i18next": "17.1.9", "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-reconciler": "0.33.0", @@ -412,9 +414,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7319,6 +7321,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7357,6 +7368,34 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.5.11", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz", + "integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -10597,6 +10636,33 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-i18next": { + "version": "17.1.9", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz", + "integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-intersection-observer": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-11.0.1.tgz", @@ -12105,7 +12171,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index b1b8fde44ee..f65316439bf 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -42,6 +42,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", + "i18next": "26.5.11", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", "moment": "2.31.0", @@ -60,6 +61,7 @@ "react-dom": "19.2.8", "react-hook-form": "7.82.0", "react-hotkeys-hook": "5.3.3", + "react-i18next": "17.1.9", "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-reconciler": "0.33.0", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx index 6707330a54c..f4a299a5dcc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx @@ -14,7 +14,7 @@ interface FakeRun { status: string; workflow_type: string; created_at: string; - metadata: { title?: string; state?: string } | null; + metadata: Record | null; } const RUNS: FakeRun[] = [ @@ -34,16 +34,16 @@ const RUNS: FakeRun[] = [ }, ]; -function mockFetch(runs: FakeRun[]) { +function mockFetch(runs: FakeRun[], events: FakeEvent[] = [], messages: FakeMessage[] = []) { return vi.fn((url: string) => { if (url.includes("/runs?limit")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs }) }); } if (url.includes("/events")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({ events: [] }) }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ events }) }); } if (url.includes("/messages")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages }) }); } return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); }); @@ -114,7 +114,7 @@ interface FakeMessage { created_at: string; } -const DETAIL_RUN = { +const DETAIL_RUN: FakeRun = { run_id: "run-aaaaaaaa-1111", status: "completed", workflow_type: "grill", @@ -151,33 +151,21 @@ const DETAIL_MESSAGES: FakeMessage[] = [ }, ]; -function mockDetailFetch(events: FakeEvent[], messages: FakeMessage[]) { - return vi.fn((url: string) => { - if (url.includes("/runs?limit")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs: [DETAIL_RUN] }) }); - } - if (url.includes("/events")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({ events }) }); - } - if (url.includes("/messages")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages }) }); - } - return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); - }); -} - -async function openDetailDrawer(events = DETAIL_EVENTS, messages = DETAIL_MESSAGES) { +async function openRunDrawer(run: FakeRun, events: FakeEvent[], messages: FakeMessage[]) { const user = userEvent.setup(); - const fetchSpy = mockDetailFetch(events, messages); + const fetchSpy = mockFetch([run], events, messages); vi.stubGlobal("fetch", fetchSpy); render(); - await user.click(await screen.findByText("First run")); + await user.click(await screen.findByText(run.metadata?.title ?? run.workflow_type)); const drawer = await screen.findByRole("dialog"); - await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument()); + expect(await within(drawer).findByText("Timeline")).toBeInTheDocument(); return { user, fetchSpy, drawer }; } +const openDetailDrawer = (events = DETAIL_EVENTS, messages = DETAIL_MESSAGES) => + openRunDrawer(DETAIL_RUN, events, messages); + describe("WorkflowRuns detail drawer", () => { it("shows the run's identity and metadata fields", async () => { const { drawer } = await openDetailDrawer(); @@ -244,3 +232,219 @@ describe("WorkflowRuns detail drawer", () => { await waitFor(() => expect(screen.queryAllByRole("dialog")).toHaveLength(0)); }); }); + +const FULL_RUN: FakeRun = { + run_id: "run-cccccccc-3333", + status: "running", + workflow_type: "autoship", + created_at: "2026-01-01T00:00:00.000Z", + metadata: { + title: "Full run", + state: "reviewing", + pr_url: "https://example.com/pr/2", + worktree_path: "/tmp/wt/full", + grill_session_id: "grill-1", + session_id: "sess-1", + plan_text: "step ".repeat(26), + }, +}; + +const TIMED_EVENTS: FakeEvent[] = [ + { + event_id: "ev-a", + event_type: "step.started", + step_name: "plan", + sequence_number: 1, + created_at: "2026-01-01T00:00:00.100Z", + data: null, + }, + { + event_id: "ev-b", + event_type: "step.started", + step_name: "implement", + sequence_number: 2, + created_at: "2026-01-01T00:00:00.165Z", + data: null, + }, + { + event_id: "ev-c", + event_type: "hook.waiting", + step_name: "review", + sequence_number: 3, + created_at: "2026-01-01T00:00:01.365Z", + data: null, + }, +]; + +describe("WorkflowRuns page copy", () => { + it("announces the loading state while runs are being fetched", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + render(); + + expect(await screen.findByText("Loading workflow runs…")).toBeInTheDocument(); + }); + + it("labels the page header, the table columns and the search box", async () => { + vi.stubGlobal("fetch", mockFetch(RUNS)); + render(); + await screen.findByText("First run"); + + expect(screen.getByText("Workflow Runs")).toBeInTheDocument(); + expect(screen.getByText("Durable state tracking for agents and automated workflows")).toBeInTheDocument(); + expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([ + "Run", + "Type", + "Status", + "Created", + ]); + expect(screen.getByPlaceholderText("Search runs…")).toBeInTheDocument(); + }); + + it("names every column in the Columns menu", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", mockFetch(RUNS)); + render(); + await screen.findByText("First run"); + + await user.click(screen.getByRole("button", { name: "Columns" })); + + expect((await screen.findAllByRole("menuitemcheckbox")).map((item) => item.textContent)).toEqual([ + "Run", + "Type", + "Status", + "Created", + ]); + }); + + it("labels the filters drawer and offers every run status as a filter", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", mockFetch(RUNS)); + render(); + await screen.findByText("First run"); + + await user.click(screen.getByRole("button", { name: "Filters" })); + const filters = await screen.findByRole("dialog", { name: "Filters" }); + expect(filters).toHaveAccessibleDescription("Narrow down workflow runs"); + expect(within(filters).getByText("Status")).toBeInTheDocument(); + expect(within(filters).getByText("Type")).toBeInTheDocument(); + expect(within(filters).getByPlaceholderText("Filter by type…")).toBeInTheDocument(); + + const statusSelect = within(filters).getByRole("combobox"); + expect(statusSelect).toHaveTextContent("All statuses"); + await user.click(statusSelect); + expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([ + "All statuses", + "Pending", + "Running", + "Paused", + "Completed", + "Failed", + ]); + + await user.click(screen.getByRole("option", { name: "Running" })); + expect(statusSelect).toHaveTextContent("Running"); + }); + + it("labels the detail drawer, its controls and every primary metadata field", async () => { + const { user, drawer } = await openRunDrawer(FULL_RUN, [TIMED_EVENTS[0]], []); + + expect(drawer).toHaveAccessibleName("Workflow run details"); + expect(drawer).toHaveAccessibleDescription("Metadata, timeline and messages for the selected workflow run"); + for (const label of ["status", "created", "pr", "state", "worktree", "grill session", "session"]) { + expect(within(drawer).getByText(label)).toBeInTheDocument(); + } + expect(within(drawer).getByRole("button", { name: "close" })).toBeInTheDocument(); + expect(within(drawer).getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + expect(within(drawer).getByText("1 event")).toBeInTheDocument(); + + await user.click(within(drawer).getByRole("button", { name: /Messages/ })); + expect(await within(drawer).findByText("No messages")).toBeInTheDocument(); + }); + + it("counts zero events in the timeline header", async () => { + const { drawer } = await openRunDrawer(FULL_RUN, [], []); + + expect(within(drawer).getByText("0 events")).toBeInTheDocument(); + }); + + it("collapses a long metadata value behind a more toggle that flips to less", async () => { + const { user, drawer } = await openRunDrawer(FULL_RUN, [], []); + + await user.click(within(drawer).getByRole("button", { name: "more" })); + + expect(within(drawer).getByRole("button", { name: "less" })).toBeInTheDocument(); + }); + + it("formats timeline durations in milliseconds under a second and in seconds above it", async () => { + const { drawer } = await openRunDrawer(FULL_RUN, TIMED_EVENTS, []); + + expect(within(drawer).getByText("65ms")).toBeInTheDocument(); + expect(within(drawer).getByText("1.2s")).toBeInTheDocument(); + expect(within(drawer).getAllByText("1.4s")).toHaveLength(2); + }); +}); + +describe("WorkflowRuns relative times", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows created times in the largest whole unit and passes an unparseable date through", async () => { + const now = Date.parse("2026-03-10T12:00:00Z"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + const runCreated = (id: string, createdAt: string): FakeRun => ({ + run_id: `run-${id}`, + status: "completed", + workflow_type: `type-${id}`, + created_at: createdAt, + metadata: null, + }); + const secondsAgo = (seconds: number) => new Date(now - seconds * 1000).toISOString(); + vi.stubGlobal( + "fetch", + mockFetch([ + runCreated("a", secondsAgo(59)), + runCreated("b", secondsAgo(60)), + runCreated("c", secondsAgo(3600)), + runCreated("d", secondsAgo(86400)), + runCreated("e", "not-a-date"), + ]), + ); + render(); + + expect(await screen.findByText("59s ago")).toBeInTheDocument(); + expect(screen.getByText("1m ago")).toBeInTheDocument(); + expect(screen.getByText("1h ago")).toBeInTheDocument(); + expect(screen.getByText("1d ago")).toBeInTheDocument(); + expect(screen.getByText("not-a-date")).toBeInTheDocument(); + }); +}); + +describe("WorkflowRuns detail drawer relative times", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("labels the run's age, each message's age and every field of a hovered timeline event", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.parse("2026-01-01T00:00:10Z")); + const { user, drawer } = await openDetailDrawer(); + + expect(within(drawer).getByText("10s ago")).toBeInTheDocument(); + + await user.hover(within(drawer).getByText("step.started")); + const tooltip = await screen.findByText( + (_, element) => element?.getAttribute("data-slot") === "tooltip-content", + {}, + { timeout: 3000 }, + ); + expect(tooltip).toHaveTextContent('type: step.startedstep: planseq: 1time: 9s agodata: {"attempt":1}'); + + await user.click(within(drawer).getByRole("button", { name: /Messages/ })); + expect(await within(drawer).findByText("8s ago")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index ba4c23fcc9c..f02bbd0688c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ArrowLeft, ChevronDown, RefreshCw } from "lucide-react"; import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table"; +import type { ParseKeys, TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking"; import { DataTable, @@ -70,12 +72,12 @@ const STATUS_DOT: Record = { }; const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"]; -const STATUS_LABELS: Record = { - pending: "Pending", - running: "Running", - paused: "Paused", - completed: "Completed", - failed: "Failed", +const STATUS_LABEL_KEYS: Record = { + pending: "workflows.status.pending", + running: "workflows.status.running", + paused: "workflows.status.paused", + completed: "workflows.status.completed", + failed: "workflows.status.failed", }; const EVENT_COLOR: Record = { @@ -91,22 +93,22 @@ function eventStyle(type: string) { // ── helpers ─────────────────────────────────────────────────────────────────── -function timeAgo(iso: string): string { +function timeAgo(iso: string, t: TFunction): string { const diff = Date.now() - new Date(iso).getTime(); if (isNaN(diff)) return iso; const s = Math.floor(diff / 1000); - if (s < 60) return `${s}s ago`; + if (s < 60) return t("workflows.timeAgo.seconds", { count: s }); const m = Math.floor(s / 60); - if (m < 60) return `${m}m ago`; + if (m < 60) return t("workflows.timeAgo.minutes", { count: m }); const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; - return `${Math.floor(h / 24)}d ago`; + if (h < 24) return t("workflows.timeAgo.hours", { count: h }); + return t("workflows.timeAgo.days", { count: Math.floor(h / 24) }); } -function fmtDuration(ms: number): string { +function fmtDuration(ms: number, t: TFunction): string { if (ms < 0) return ""; - if (ms < 1000) return `${ms}ms`; - return `${(ms / 1000).toFixed(1)}s`; + if (ms < 1000) return t("workflows.duration.ms", { count: ms }); + return t("workflows.duration.seconds", { value: (ms / 1000).toFixed(1) }); } function runTitle(run: WorkflowRun): string { @@ -130,6 +132,7 @@ const StatusDot: React.FC<{ status: RunStatus; className?: string }> = ({ status const TRUNCATE_AT = 120; const TruncatedValue: React.FC<{ value: string }> = ({ value }) => { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); if (value.length <= TRUNCATE_AT) { return {value}; @@ -138,7 +141,7 @@ const TruncatedValue: React.FC<{ value: string }> = ({ value }) => { {expanded ? value : value.slice(0, TRUNCATE_AT) + "…"} ); @@ -147,13 +150,14 @@ const TruncatedValue: React.FC<{ value: string }> = ({ value }) => { // ── metadata card ───────────────────────────────────────────────────────────── const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => { + const { t } = useTranslation(); const meta = run.metadata ?? {}; const primaryFields: { key: string; label: string }[] = [ - { key: "state", label: "state" }, - { key: "worktree_path", label: "worktree" }, - { key: "grill_session_id", label: "grill session" }, - { key: "session_id", label: "session" }, + { key: "state", label: t("workflows.metadata.state") }, + { key: "worktree_path", label: t("workflows.metadata.worktree") }, + { key: "grill_session_id", label: t("workflows.metadata.grillSession") }, + { key: "session_id", label: t("workflows.metadata.session") }, ]; const primaryKeys = new Set(["title", ...primaryFields.map((f) => f.key)]); @@ -175,15 +179,15 @@ const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => { {/* key fields grid */}
- + {run.status} - - {timeAgo(run.created_at)} + + {timeAgo(run.created_at, t)} {meta.pr_url && ( - + = ({ run, events }) => { + const { t } = useTranslation(); if (events.length === 0) { - return
No events recorded
; + return
{t("workflows.drawer.noEvents")}
; } const runStart = new Date(run.created_at).getTime(); const eventTimes = events.map((e) => new Date(e.created_at).getTime()); const lastTime = Math.max(...eventTimes); const totalSpan = Math.max(lastTime - runStart, 1); - const totalDur = fmtDuration(lastTime - runStart); + const totalDur = fmtDuration(lastTime - runStart, t); return ( @@ -275,7 +280,7 @@ const GanttTimeline: React.FC<{ : lastTime + Math.max(totalSpan * 0.12, 500); const widthPct = Math.max(8, ((nextTime - evTime) / totalSpan) * 100); const style = eventStyle(ev.event_type); - const dur = fmtDuration(nextTime - evTime); + const dur = fmtDuration(nextTime - evTime, t); return ( @@ -303,24 +308,24 @@ const GanttTimeline: React.FC<{
- type: + {t("workflows.tooltip.type")} {ev.event_type}
- step: + {t("workflows.tooltip.step")} {ev.step_name}
- seq: + {t("workflows.tooltip.seq")} {ev.sequence_number}
- time: - {timeAgo(ev.created_at)} + {t("workflows.tooltip.time")} + {timeAgo(ev.created_at, t)}
{ev.data && Object.keys(ev.data).length > 0 && (
- data: + {t("workflows.tooltip.data")} {JSON.stringify(ev.data)}
)} @@ -346,15 +351,18 @@ const ROLE_COLOR: Record = { tool_result: "text-warning", }; -const MessageRow: React.FC<{ msg: WorkflowRunMessage }> = ({ msg }) => ( -
- [{msg.role}] -
- {msg.content} - {timeAgo(msg.created_at)} +const MessageRow: React.FC<{ msg: WorkflowRunMessage }> = ({ msg }) => { + const { t } = useTranslation(); + return ( +
+ [{msg.role}] +
+ {msg.content} + {timeAgo(msg.created_at, t)} +
-
-); + ); +}; // ── collapsible section ─────────────────────────────────────────────────────── @@ -379,6 +387,7 @@ const DetailSection: React.FC<{ // ── main component ──────────────────────────────────────────────────────────── const WorkflowRuns: React.FC = ({ accessToken }) => { + const { t } = useTranslation(); const [runs, setRuns] = useState([]); const [loadingRuns, setLoadingRuns] = useState(false); const [selectedRun, setSelectedRun] = useState(null); @@ -455,8 +464,8 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { { id: "run", accessorFn: (row) => `${runTitle(row)} ${row.run_id}`, - header: "Run", - meta: { title: "Run", skeleton: "twoLine" }, + header: t("workflows.table.columns.run"), + meta: { title: t("workflows.table.columns.run"), skeleton: "twoLine" }, cell: ({ row }) => { const run = row.original; return ( @@ -472,8 +481,8 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { }, { accessorKey: "workflow_type", - header: "Type", - meta: { title: "Type" }, + header: t("workflows.table.columns.type"), + meta: { title: t("workflows.table.columns.type") }, filterFn: "includesString", cell: ({ row }) => ( {row.original.workflow_type} @@ -482,8 +491,8 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { { id: "status", accessorKey: "status", - header: "Status", - meta: { title: "Status" }, + header: t("workflows.table.columns.status"), + meta: { title: t("workflows.table.columns.status") }, filterFn: "equalsString", cell: ({ row }) => { const run = row.original; @@ -497,22 +506,22 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { }, { accessorKey: "created_at", - header: "Created", - meta: { title: "Created" }, - cell: ({ row }) => {timeAgo(row.original.created_at)}, + header: t("workflows.table.columns.created"), + meta: { title: t("workflows.table.columns.created") }, + cell: ({ row }) => {timeAgo(row.original.created_at, t)}, }, ], - [], + [t], ); + const statusLabels = Object.fromEntries(RUN_STATUS_OPTIONS.map((status) => [status, t(STATUS_LABEL_KEYS[status])])); + return (
{/* page header */}
-
Workflow Runs
-
- Durable state tracking for agents and automated workflows -
+
{t("workflows.header.title")}
+
{t("workflows.header.subtitle")}
= ({ accessToken }) => { columns={columns} getRowId={(run) => run.run_id} isLoading={loadingRuns} - loadingMessage="Loading workflow runs…" - noDataMessage={
No workflow runs yet
} + loadingMessage={t("workflows.table.loading")} + noDataMessage={ +
{t("workflows.table.empty")}
+ } paginationMode="client" pageSizeOptions={[50, 100]} filterMode="client" @@ -537,7 +548,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { table={table} searchValue={globalFilter} onSearchChange={setGlobalFilter} - searchPlaceholder="Search runs…" + searchPlaceholder={t("workflows.table.searchPlaceholder")} onRefresh={fetchRuns} isRefreshing={loadingRuns} onOpenFilters={() => setFiltersOpen(true)} @@ -546,35 +557,35 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { table={table} open={filtersOpen} onOpenChange={setFiltersOpen} - title="Filters" - description="Narrow down workflow runs" + title={t("workflows.filters.title")} + description={t("workflows.filters.description")} > {({ get, set }) => ( <> - + - + set("workflow_type", event.target.value)} - placeholder="Filter by type…" + placeholder={t("workflows.filters.typePlaceholder")} /> @@ -590,10 +601,8 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { showCloseButton={false} className="overflow-y-auto p-0 data-[side=right]:w-full data-[side=right]:sm:max-w-[680px]" > - Workflow run details - - Metadata, timeline and messages for the selected workflow run - + {t("workflows.drawer.srTitle")} + {t("workflows.drawer.srDescription")} {!selectedRun ? null : loadingDetail ? (
@@ -609,11 +618,11 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { onClick={() => setDrawerOpen(false)} > - close + {t("workflows.drawer.close")}
@@ -623,19 +632,17 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { {/* collapsible sections */}
- {events.length} {events.length === 1 ? "event" : "events"} - - } + title={t("workflows.drawer.timeline")} + meta={t("workflows.drawer.eventCount", { count: events.length })} defaultOpen > - + {messages.length === 0 ? ( -
No messages
+
+ {t("workflows.drawer.noMessages")} +
) : (
{messages.map((msg) => ( diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index 3a9d130839c..a8ff93f69f6 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -10,6 +10,7 @@ import ReactQueryProvider from "@/contexts/ReactQueryProvider"; import HotkeysProvider from "@/contexts/HotkeysProvider"; import { TerminologyProvider } from "@/contexts/TerminologyContext"; import { Toaster } from "@/components/ui/sonner"; +import I18nProvider from "@/i18n/I18nProvider"; const inter = Inter({ subsets: ["latin"] }); @@ -29,18 +30,20 @@ export default function RootLayout({ // cannot predict; suppressHydrationWarning confines that mismatch to this element. - - - - - - {children} - - - - - - + + + + + + + {children} + + + + + + + ); diff --git a/ui/litellm-dashboard/src/i18n/I18nProvider.tsx b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx new file mode 100644 index 00000000000..f0ecdfe2568 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/I18nProvider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { I18nextProvider } from "react-i18next"; + +import i18n from "./i18n"; + +export default function I18nProvider({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/ui/litellm-dashboard/src/i18n/i18n.test-d.ts b/ui/litellm-dashboard/src/i18n/i18n.test-d.ts new file mode 100644 index 00000000000..baa8c757311 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/i18n.test-d.ts @@ -0,0 +1,10 @@ +import type { TFunction } from "i18next"; +import { expectTypeOf, test } from "vitest"; + +test("translation keys come from the English message file", () => { + const t = {} as TFunction; + + expectTypeOf(t("workflows.drawer.eventCount", { count: 2 })).toBeString(); + // @ts-expect-error + t("workflows.header.titel"); +}); diff --git a/ui/litellm-dashboard/src/i18n/i18n.ts b/ui/litellm-dashboard/src/i18n/i18n.ts new file mode 100644 index 00000000000..0e9c364a0b3 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/i18n.ts @@ -0,0 +1,23 @@ +import i18n, { type InitOptions } from "i18next"; +import { initReactI18next } from "react-i18next"; + +import en from "./locales/en.json"; + +declare module "i18next" { + interface CustomTypeOptions { + defaultNS: "translation"; + resources: { translation: typeof en }; + } +} + +const options: InitOptions = { + lng: "en", + fallbackLng: "en", + interpolation: { escapeValue: false }, + resources: { en: { translation: en } }, + initAsync: false, +}; + +void i18n.use(initReactI18next).init(options); + +export default i18n; diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json new file mode 100644 index 00000000000..9a65e074b25 --- /dev/null +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -0,0 +1,76 @@ +{ + "workflows": { + "header": { + "title": "Workflow Runs", + "subtitle": "Durable state tracking for agents and automated workflows" + }, + "table": { + "columns": { + "run": "Run", + "type": "Type", + "status": "Status", + "created": "Created" + }, + "loading": "Loading workflow runs…", + "empty": "No workflow runs yet", + "searchPlaceholder": "Search runs…" + }, + "filters": { + "title": "Filters", + "description": "Narrow down workflow runs", + "status": "Status", + "type": "Type", + "allStatuses": "All statuses", + "typePlaceholder": "Filter by type…" + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "completed": "Completed", + "failed": "Failed" + }, + "drawer": { + "srTitle": "Workflow run details", + "srDescription": "Metadata, timeline and messages for the selected workflow run", + "close": "close", + "refresh": "Refresh", + "timeline": "Timeline", + "eventCount_one": "{{count}} event", + "eventCount_other": "{{count}} events", + "messages": "Messages", + "noMessages": "No messages", + "noEvents": "No events recorded" + }, + "metadata": { + "status": "status", + "created": "created", + "pr": "pr", + "state": "state", + "worktree": "worktree", + "grillSession": "grill session", + "session": "session" + }, + "truncate": { + "more": "more", + "less": "less" + }, + "tooltip": { + "type": "type: ", + "step": "step: ", + "seq": "seq: ", + "time": "time: ", + "data": "data: " + }, + "timeAgo": { + "seconds": "{{count}}s ago", + "minutes": "{{count}}m ago", + "hours": "{{count}}h ago", + "days": "{{count}}d ago" + }, + "duration": { + "ms": "{{count}}ms", + "seconds": "{{value}}s" + } + } +} diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index fe637e0473a..2b6b4c7484c 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -2,6 +2,8 @@ import "@testing-library/jest-dom"; import { cleanup } from "@testing-library/react"; import { afterEach, vi } from "vitest"; +import "@/i18n/i18n"; + const ensureTestLocalStorage = () => { if (typeof window === "undefined" || typeof window.Storage === "undefined") { return;