diff --git a/apps/web/app/settings/system/about/page.tsx b/apps/web/app/settings/system/about/page.tsx index 3f4a6c2fec..92b53d807a 100644 --- a/apps/web/app/settings/system/about/page.tsx +++ b/apps/web/app/settings/system/about/page.tsx @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { StateProvider } from "@/components/state-provider"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { AboutCard } from "@/components/settings/system/about-card"; @@ -16,7 +17,7 @@ export default async function SystemAboutPage() { return ( - + diff --git a/apps/web/app/settings/system/backups/page.tsx b/apps/web/app/settings/system/backups/page.tsx index e3611b973a..32b09e9675 100644 --- a/apps/web/app/settings/system/backups/page.tsx +++ b/apps/web/app/settings/system/backups/page.tsx @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { StateProvider } from "@/components/state-provider"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { BackupsTable } from "@/components/settings/system/backups-table"; @@ -21,8 +22,11 @@ export default async function SystemBackupsPage() { return ( /backups/", + })} > diff --git a/apps/web/app/settings/system/database/page.tsx b/apps/web/app/settings/system/database/page.tsx index 1dc0046218..f43a371b0c 100644 --- a/apps/web/app/settings/system/database/page.tsx +++ b/apps/web/app/settings/system/database/page.tsx @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { StateProvider } from "@/components/state-provider"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card"; @@ -15,8 +16,8 @@ export default async function SystemDatabasePage() { return ( diff --git a/apps/web/app/settings/system/feature-toggles/page.tsx b/apps/web/app/settings/system/feature-toggles/page.tsx index 6377e4254f..4120c515ce 100644 --- a/apps/web/app/settings/system/feature-toggles/page.tsx +++ b/apps/web/app/settings/system/feature-toggles/page.tsx @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { FeatureTogglesSettings } from "@/components/settings/system/feature-toggles-settings"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { fetchRuntimeFlags } from "@/lib/api/domains/runtime-flags-api"; @@ -11,8 +12,8 @@ export default async function FeatureTogglesPage() { return ( diff --git a/apps/web/app/settings/system/status/page.tsx b/apps/web/app/settings/system/status/page.tsx index 551ffd0c31..a2203aabdb 100644 --- a/apps/web/app/settings/system/status/page.tsx +++ b/apps/web/app/settings/system/status/page.tsx @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { StateProvider } from "@/components/state-provider"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { HealthIssuesCard } from "@/components/settings/system/health-issues-card"; @@ -32,7 +33,7 @@ export default async function SystemStatusPage() { return ( - +
diff --git a/apps/web/app/settings/system/updates/page.tsx b/apps/web/app/settings/system/updates/page.tsx index 3c7fbd8072..77f2856842 100644 --- a/apps/web/app/settings/system/updates/page.tsx +++ b/apps/web/app/settings/system/updates/page.tsx @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { Suspense } from "react"; import { StateProvider } from "@/components/state-provider"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; @@ -17,8 +18,8 @@ export default async function SystemUpdatesPage() { return ( {/* diff --git a/apps/web/components/app-sidebar/sections/settings/system-group.tsx b/apps/web/components/app-sidebar/sections/settings/system-group.tsx index 01ecc8042f..2d22e8bd92 100644 --- a/apps/web/components/app-sidebar/sections/settings/system-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/system-group.tsx @@ -14,6 +14,7 @@ import { IconUsers, } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; @@ -21,20 +22,27 @@ import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const ROOT_HREF = "/settings/system"; const DEFAULT_HREF = `${ROOT_HREF}/status`; -const BASE_ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [ - { href: `${ROOT_HREF}/status`, label: "Status", icon: IconActivity }, - { href: `${ROOT_HREF}/feature-toggles`, label: "Feature Toggles", icon: IconFlask }, - { href: `${ROOT_HREF}/database`, label: "Database", icon: IconDatabase }, - { href: `${ROOT_HREF}/backups`, label: "Backups", icon: IconArchive }, - { href: `${ROOT_HREF}/storage`, label: "Storage", icon: IconTrash }, - { href: `${ROOT_HREF}/logs`, label: "Logs", icon: IconFileText }, - { href: `${ROOT_HREF}/updates`, label: "Updates", icon: IconRefresh }, - { href: `${ROOT_HREF}/about`, label: "About", icon: IconInfoCircle }, - { href: `${ROOT_HREF}/licenses`, label: "Licenses", icon: IconScale }, +/** + * `href` is the route and stays a value; only `labelKey` is copy, resolved at + * render so the nav follows a locale switch. Storing resolved labels here + * would freeze them at the boot locale — and because these are SCREAMING_CASE + * identifiers, `mode: "jsx-only"` skips them entirely, so lint reported none + * of the ten. + */ +const BASE_ITEMS: Array<{ href: string; labelKey: string; icon: TablerIcon }> = [ + { href: `${ROOT_HREF}/status`, labelKey: "common:status", icon: IconActivity }, + { href: `${ROOT_HREF}/feature-toggles`, labelKey: "system:navFeatureToggles", icon: IconFlask }, + { href: `${ROOT_HREF}/database`, labelKey: "system:navDatabase", icon: IconDatabase }, + { href: `${ROOT_HREF}/backups`, labelKey: "system:navBackups", icon: IconArchive }, + { href: `${ROOT_HREF}/storage`, labelKey: "system:navStorage", icon: IconTrash }, + { href: `${ROOT_HREF}/logs`, labelKey: "system:navLogs", icon: IconFileText }, + { href: `${ROOT_HREF}/updates`, labelKey: "system:navUpdates", icon: IconRefresh }, + { href: `${ROOT_HREF}/about`, labelKey: "system:navAbout", icon: IconInfoCircle }, + { href: `${ROOT_HREF}/licenses`, labelKey: "system:navLicenses", icon: IconScale }, ]; -const AUTH_ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [ - { href: `${ROOT_HREF}/users`, label: "Users", icon: IconUsers }, +const AUTH_ITEMS: Array<{ href: string; labelKey: string; icon: TablerIcon }> = [ + { href: `${ROOT_HREF}/users`, labelKey: "system:navUsers", icon: IconUsers }, ]; type SystemGroupProps = { @@ -50,24 +58,25 @@ function useIsAdmin(): boolean { } export function SystemGroup({ pathname, expanded, onToggle }: SystemGroupProps) { + const { t } = useTranslation(); const authEnabled = useFeature("auth"); const isAdmin = useIsAdmin(); const items = authEnabled && isAdmin ? [...BASE_ITEMS, ...AUTH_ITEMS] : BASE_ITEMS; return ( - {items.map(({ href, label, icon }) => ( + {items.map(({ href, labelKey, icon }) => (
+ {/* Version, date and the release notes body are all release data. */} v{entry.version} {entry.date && ( @@ -49,7 +52,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) { rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer" > - View on GitHub + {t("system:changelogViewOnGitHub")}
@@ -82,6 +85,7 @@ function buildPageNumbers(currentPage: number, totalPages: number): (number | "e } export function ChangelogList() { + const { t } = useTranslation(); const changelog = getChangelog(); const router = useRouter(); const pathname = usePathname(); @@ -111,7 +115,7 @@ export function ChangelogList() { }, [changelog, currentPage]); if (changelog.length === 0) { - return

No changelog entries available.

; + return

{t("system:changelogEmpty")}

; } const pageNumbers = buildPageNumbers(currentPage, totalPages); diff --git a/apps/web/components/settings/system/about-card.tsx b/apps/web/components/settings/system/about-card.tsx index fcbf4c33da..1380e85652 100644 --- a/apps/web/components/settings/system/about-card.tsx +++ b/apps/web/components/settings/system/about-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; @@ -10,6 +11,7 @@ function Row({ label, value, testid }: { label: string; value: string; testid: s return (
{label} + {/* Only the label is copy; every value here is a build identifier. */} {value || "-"} @@ -18,13 +20,14 @@ function Row({ label, value, testid }: { label: string; value: string; testid: s } export function AboutCard() { + const { t } = useTranslation(); const { info, isLoading } = useSystemInfo(); if (!info && isLoading) { return ( - Loading... + {t("system:aboutLoading")} ); @@ -34,25 +37,33 @@ export function AboutCard() { - About kandev + {t("system:aboutTitle")}
- - + + - - + +
@@ -72,7 +84,7 @@ export function AboutCard() { rel="noreferrer" data-testid="system-about-docs-link" > - Documentation + {t("system:aboutDocumentation")}
diff --git a/apps/web/components/settings/system/action-button-content.tsx b/apps/web/components/settings/system/action-button-content.tsx index f218159e26..fc0e8dce10 100644 --- a/apps/web/components/settings/system/action-button-content.tsx +++ b/apps/web/components/settings/system/action-button-content.tsx @@ -3,6 +3,7 @@ import { Spinner } from "@kandev/ui/spinner"; import { IconAlertTriangle, IconCheck } from "@tabler/icons-react"; import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import type { ActionFeedbackState } from "@/hooks/use-action-feedback"; type Props = { @@ -11,11 +12,11 @@ type Props = { idleIcon: ReactNode; /** Label to show in the idle state (e.g. "Run vacuum"). */ idleLabel: string; - /** Label to show while pending. Defaults to "Running...". */ + /** Label to show while pending. Defaults to a translated "Running...". */ pendingLabel?: string; - /** Label to show on success. Defaults to "Done". */ + /** Label to show on success. Defaults to a translated "Done". */ successLabel?: string; - /** Label to show on error. Defaults to "Failed". */ + /** Label to show on error. Defaults to a translated "Failed". */ errorLabel?: string; }; @@ -29,15 +30,19 @@ export function ActionButtonContent({ state, idleIcon, idleLabel, - pendingLabel = "Running...", - successLabel = "Done", - errorLabel = "Failed", + pendingLabel, + successLabel, + errorLabel, }: Props) { + // The three fallbacks resolve here rather than as destructuring defaults: + // a default parameter is evaluated before the component body runs, so `t` + // is not in scope there. + const { t } = useTranslation(); if (state === "pending") { return ( <> - {pendingLabel} + {pendingLabel ?? t("system:actionRunning")} ); } @@ -45,7 +50,7 @@ export function ActionButtonContent({ return ( <> - {successLabel} + {successLabel ?? t("system:actionDone")} ); } @@ -53,7 +58,7 @@ export function ActionButtonContent({ return ( <> - {errorLabel} + {errorLabel ?? t("system:actionFailed")} ); } diff --git a/apps/web/components/settings/system/backups-table.tsx b/apps/web/components/settings/system/backups-table.tsx index 0a304c66d6..faae40295c 100644 --- a/apps/web/components/settings/system/backups-table.tsx +++ b/apps/web/components/settings/system/backups-table.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; @@ -9,21 +11,34 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { IconDownload, IconTrash, IconArchive, IconRotateClockwise } from "@tabler/icons-react"; import { useBackups } from "@/hooks/domains/system/use-backups"; import { buildBackupDownloadUrl, createBackup, deleteBackup } from "@/lib/api/domains/system-api"; +import { formatDateTime } from "@/lib/i18n/formats"; import { formatBytes } from "@/lib/utils/format-bytes"; import { JobProgressIndicator } from "./job-progress-indicator"; import { RestoreDialog } from "./restore-dialog"; -import type { SnapshotInfo } from "@/lib/types/system"; +import type { SnapshotInfo, SnapshotKind } from "@/lib/types/system"; const BACKUP_CREATE_TIMEOUT_MS = 15_000; const BACKUP_CREATE_POLL_MS = 250; +/** + * How many snapshots the backend keeps. Only referenced by the help text, but + * it travels as a `count` so the sentence inflects in every locale rather than + * hardcoding the English plural. + */ +const BACKUP_RETENTION_COUNT = 2; function formatTimestamp(iso: string): string { if (!iso) return "-"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString(); + return formatDateTime(d); } +/** `kind` is the wire enum; only its badge label is copy. */ +const KIND_LABEL_KEYS: Record = { + auto: "system:backupsKindAuto", + manual: "system:backupsKindManual", +}; + function BackupRow({ row, onRestore, @@ -33,14 +48,16 @@ function BackupRow({ onRestore: (name: string) => void; onDelete: (name: string) => void; }) { + const { t } = useTranslation(); return ( + {/* The snapshot filename is a value. */} {row.name} - {row.kind} + {KIND_LABEL_KEYS[row.kind] ? t(KIND_LABEL_KEYS[row.kind]) : row.kind} {formatBytes(row.size_bytes)} @@ -54,7 +71,13 @@ function BackupRow({ className="cursor-pointer" data-testid="system-backups-download" > - + {/* These three are icon-only, so the aria-label is their only + accessible name. It names the snapshot it acts on. */} + @@ -63,6 +86,7 @@ function BackupRow({ variant="ghost" className="cursor-pointer" onClick={() => onRestore(row.name)} + aria-label={t("system:backupsRestoreLabel", { name: row.name })} data-testid="system-backups-restore" > @@ -72,6 +96,7 @@ function BackupRow({ variant="ghost" className="cursor-pointer text-destructive" onClick={() => onDelete(row.name)} + aria-label={t("system:backupsDeleteLabel", { name: row.name })} data-testid="system-backups-delete" > @@ -91,15 +116,16 @@ function BackupsList({ onRestore: (name: string) => void; onDelete: (name: string) => void; }) { + const { t } = useTranslation(); return ( - Name - Kind - Size - Created - Actions + {t("system:backupsColumnName")} + {t("system:backupsColumnKind")} + {t("system:backupsColumnSize")} + {t("system:backupsColumnCreated")} + {t("system:backupsColumnActions")} @@ -118,6 +144,7 @@ function sleep(ms: number): Promise { async function waitForCreatedBackup( reload: () => Promise, previousNames: Set, + t: TFunction, ): Promise { const deadline = Date.now() + BACKUP_CREATE_TIMEOUT_MS; while (Date.now() < deadline) { @@ -127,10 +154,13 @@ async function waitForCreatedBackup( } await sleep(BACKUP_CREATE_POLL_MS); } - throw new Error("Create snapshot did not finish within 15s"); + // This message is caught and rendered into the card's error line, so it is + // display copy rather than a control-flow-only throw. + throw new Error(t("system:backupsCreateTimeout", { seconds: BACKUP_CREATE_TIMEOUT_MS / 1_000 })); } export function BackupsTable() { + const { t } = useTranslation(); const { backups, loaded, isLoading, reload } = useBackups(); const [creating, setCreating] = useState(false); const [restoreName, setRestoreName] = useState(null); @@ -142,9 +172,9 @@ export function BackupsTable() { const previousNames = new Set(backups.map((backup) => backup.name)); try { await createBackup(); - await waitForCreatedBackup(reload, previousNames); + await waitForCreatedBackup(reload, previousNames, t); } catch (err) { - setError(err instanceof Error ? err.message : "Create snapshot failed"); + setError(err instanceof Error ? err.message : t("system:backupsCreateFailed")); } finally { setCreating(false); } @@ -156,7 +186,7 @@ export function BackupsTable() { await deleteBackup(name); await reload(); } catch (err) { - setError(err instanceof Error ? err.message : "Delete failed"); + setError(err instanceof Error ? err.message : t("system:backupsDeleteFailed")); } }; @@ -166,7 +196,7 @@ export function BackupsTable() { - Backups + {t("system:backupsTitle")}

- A backup is a full copy of Kandev's database (tasks, sessions, settings). Kandev - automatically creates one before every version upgrade so you can roll back if something - goes wrong. You can also create one manually before risky operations like factory reset. - Only the latest 2 backups are kept on disk. + {t("system:backupsHelp")}{" "} + {t("system:backupsRetention", { count: BACKUP_RETENTION_COUNT })}

{error && (

@@ -192,13 +220,12 @@ export function BackupsTable() { )} {!loaded && isLoading && (

- Loading backups... + {t("system:backupsLoading")}
)} {loaded && items.length === 0 && (

- No backups yet. Snapshots created automatically on version upgrade or manually via the - button above will appear here. + {t("system:backupsEmpty")}

)} {items.length > 0 && ( diff --git a/apps/web/components/settings/system/create-user-dialog.tsx b/apps/web/components/settings/system/create-user-dialog.tsx index 12a0925b02..334e494523 100644 --- a/apps/web/components/settings/system/create-user-dialog.tsx +++ b/apps/web/components/settings/system/create-user-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -44,11 +45,12 @@ function CreateUserFields({ setRole, error, }: FormFields) { + const { t } = useTranslation(); return (
@@ -106,6 +109,7 @@ function CreateUserFields({ } export function CreateUserDialog({ open, onOpenChange, onCreated }: Props) { + const { t } = useTranslation(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [displayName, setDisplayName] = useState(""); @@ -130,7 +134,7 @@ export function CreateUserDialog({ open, onOpenChange, onCreated }: Props) { onOpenChange(false); reset(); } catch (err) { - setError(err instanceof ApiError ? err.message : "Could not create user."); + setError(err instanceof ApiError ? err.message : t("system:createUserFailed")); } finally { setSubmitting(false); } @@ -146,11 +150,8 @@ export function CreateUserDialog({ open, onOpenChange, onCreated }: Props) { > - Add a user - - Creates an account directly with a password you set. Prefer an invite link when the user - should choose their own password. - + {t("system:createUserTitle")} + {t("system:createUserDescription")} diff --git a/apps/web/components/settings/system/database-stats-card.tsx b/apps/web/components/settings/system/database-stats-card.tsx index d63828833e..36d3a4209c 100644 --- a/apps/web/components/settings/system/database-stats-card.tsx +++ b/apps/web/components/settings/system/database-stats-card.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; @@ -15,25 +17,24 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useDatabaseStats } from "@/hooks/domains/system/use-database-stats"; import { optimizeDatabase, vacuumDatabase } from "@/lib/api/domains/system-api"; import type { DatabaseStats } from "@/lib/types/system"; +import { formatDateTime } from "@/lib/i18n/formats"; import { formatBytes } from "@/lib/utils/format-bytes"; import { useActionFeedback, type ActionFeedbackState } from "@/hooks/use-action-feedback"; import { ActionButtonContent } from "./action-button-content"; import { JobProgressIndicator } from "./job-progress-indicator"; import { FactoryResetDialog } from "./factory-reset-dialog"; -function formatTimestamp(iso: string | null | undefined): string { - if (!iso) return "Never"; +function formatTimestamp(iso: string | null | undefined, t: TFunction): string { + if (!iso) return t("system:databaseNever"); const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString(); + return formatDateTime(d); } -const WAL_HELP = - "Write-Ahead Log. SQLite writes new changes to this companion file first and merges them into the main database over time. A non-zero WAL size is normal - it temporarily grows under load and shrinks back during checkpoints. Running vacuum will reset it."; - type Row = { label: string; value: string; testid: string; info?: string }; function StatRow({ label, value, testid, info }: Row) { + const { t } = useTranslation(); return (
@@ -43,7 +44,7 @@ function StatRow({ label, value, testid, info }: Row) { - {VACUUM_HELP} + {vacuumHelp} } /> @@ -217,18 +228,18 @@ function SQLiteMaintenanceButtons({ } - idleLabel="Run optimize" + idleLabel={t("system:databaseRunOptimize")} /> - {OPTIMIZE_HELP} + {optimizeHelp} } /> @@ -239,10 +250,10 @@ function SQLiteMaintenanceButtons({ className="cursor-pointer" data-testid="system-factory-reset-button" > - Factory reset + {t("system:databaseFactoryReset")} - {FACTORY_RESET_HELP} + {factoryResetHelp} } /> @@ -266,6 +277,7 @@ function MaintenanceButtons({ } export function DatabaseStatsCard() { + const { t } = useTranslation(); const { database, isLoading, error, reload } = useDatabaseStats(); const vacuum = useActionFeedback(); const optimize = useActionFeedback(); @@ -287,7 +299,7 @@ export function DatabaseStatsCard() { - Database + {t("system:databaseTitle")} @@ -298,7 +310,7 @@ export function DatabaseStatsCard() { )} {!database && isLoading && (
- Loading database stats... + {t("system:databaseLoading")}
)} {database && } diff --git a/apps/web/components/settings/system/disk-usage-card.tsx b/apps/web/components/settings/system/disk-usage-card.tsx index e34c38f012..ff986b4ccc 100644 --- a/apps/web/components/settings/system/disk-usage-card.tsx +++ b/apps/web/components/settings/system/disk-usage-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; @@ -10,43 +11,52 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useDiskUsage } from "@/hooks/domains/system/use-disk-usage"; import { openDataFolder } from "@/lib/api/domains/system-api"; import { useActionFeedback } from "@/hooks/use-action-feedback"; +import { formatDateTime } from "@/lib/i18n/formats"; import type { DiskBreakdown } from "@/lib/types/system"; import { formatBytes } from "@/lib/utils/format-bytes"; import { ActionButtonContent } from "./action-button-content"; import { JobProgressIndicator } from "./job-progress-indicator"; -const REFRESH_HELP = - "Walks every directory inside the data folder and adds up the file sizes. Results are cached for a couple of hours; click to compute fresh numbers right now. Can take a few seconds on large workspaces."; - type Row = { key: keyof Omit; - label: string; + labelKey: string; }; +/** + * The breakdown `key` is the wire field name and stays a value; only + * `labelKey` is copy, and it resolves at render so the table follows a locale + * switch. Assigning the resolved labels here instead would freeze them at the + * boot locale — and `mode: "jsx-only"` skips SCREAMING_CASE identifiers, so + * lint would never have reported it. + */ const ROWS: Row[] = [ - { key: "data_dir", label: "Data directory" }, - { key: "worktrees", label: "Worktrees" }, - { key: "repos", label: "Repositories" }, - { key: "sessions", label: "Sessions" }, - { key: "tasks", label: "Tasks" }, - { key: "quick_chat", label: "Quick chat" }, - { key: "backups", label: "Backups" }, + { key: "data_dir", labelKey: "system:diskRowDataDir" }, + { key: "worktrees", labelKey: "system:diskRowWorktrees" }, + { key: "repos", labelKey: "system:diskRowRepos" }, + { key: "sessions", labelKey: "system:diskRowSessions" }, + { key: "tasks", labelKey: "system:diskRowTasks" }, + { key: "quick_chat", labelKey: "system:diskRowQuickChat" }, + { key: "backups", labelKey: "system:diskRowBackups" }, ]; function formatComputedAt(iso: string): string { const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) return iso; - return parsed.toLocaleString(); + return formatDateTime(parsed); } function HomeDirRow({ homeDir }: { homeDir: string }) { + const { t } = useTranslation(); return (
-

Data directory

+

+ {t("system:diskRowDataDir")} +

+ {/* The resolved data-folder path is a value. */}

{homeDir}

); } function WarningsBlock({ warnings }: { warnings: string[] }) { + const { t } = useTranslation(); if (warnings.length === 0) return null; return (
-
Some directories could not be measured:
+
{t("system:diskUnmeasuredDirs")}
+ {/* Each warning is a backend-rendered diagnostic and stays as sent. */}
    {warnings.map((w, i) => (
  • {w}
  • @@ -86,23 +98,24 @@ function WarningsBlock({ warnings }: { warnings: string[] }) { } function BreakdownTable({ data }: { data: DiskBreakdown }) { + const { t } = useTranslation(); return (
- Path - Size + {t("system:diskColumnPath")} + {t("system:diskColumnSize")} {ROWS.map((row) => ( - {row.label} + {t(row.labelKey)} {formatBytes(data[row.key])} ))} - Total + {t("system:diskTotal")} {formatBytes(data.total)} @@ -113,6 +126,7 @@ function BreakdownTable({ data }: { data: DiskBreakdown }) { } export function DiskUsageCard() { + const { t } = useTranslation(); const { diskUsage, isLoading, error, refresh } = useDiskUsage(); const refreshFeedback = useActionFeedback(); const data = diskUsage?.data ?? null; @@ -129,10 +143,10 @@ export function DiskUsageCard() { - Disk Usage + {t("system:diskUsageTitle")} {computing && data && ( - Refreshing... + {t("system:diskRefreshing")} )} @@ -152,13 +166,13 @@ export function DiskUsageCard() { } - idleLabel="Refresh" - pendingLabel="Refreshing..." - successLabel="Refreshed" + idleLabel={t("system:diskRefresh")} + pendingLabel={t("system:diskRefreshing")} + successLabel={t("system:diskRefreshed")} /> - {REFRESH_HELP} + {t("system:diskRefreshHelp")} @@ -175,7 +189,7 @@ export function DiskUsageCard() { data-testid="system-disk-usage-spinner" > - Calculating... + {t("system:diskCalculating")} )} {data && ( @@ -186,7 +200,7 @@ export function DiskUsageCard() { className="text-xs text-muted-foreground" data-testid="system-disk-usage-computed-at" > - Computed at {formatComputedAt(data.computed_at)} + {t("system:diskComputedAt", { at: formatComputedAt(data.computed_at) })}

)} diff --git a/apps/web/components/settings/system/factory-reset-dialog.tsx b/apps/web/components/settings/system/factory-reset-dialog.tsx index 90ffff756f..efc9e0eaed 100644 --- a/apps/web/components/settings/system/factory-reset-dialog.tsx +++ b/apps/web/components/settings/system/factory-reset-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -21,8 +22,18 @@ type Props = { onOpenChange: (open: boolean) => void; }; +/** + * Never translated. The confirm button is gated on `typed === CONFIRM_TOKEN` + * and the same token is sent to `resetDatabase`, so a localized copy would + * leave a dialog the user cannot satisfy in that locale — and the wipe is + * irreversible. It travels as an interpolated value into the visible sentence + * and the input's placeholder/aria-label so shown and compared cannot drift. + */ const CONFIRM_TOKEN = "RESET"; +/** A filesystem path shown to the user; interpolated as a value, never translated. */ +const BACKUP_DIR_PLACEHOLDER = "/backups/"; + function ConfirmView({ typed, onTyped, @@ -40,25 +51,28 @@ function ConfirmView({ onCancel: () => void; onConfirm: () => void; }) { + const { t } = useTranslation(); return ( <> - Factory Reset + {t("system:factoryResetTitle")} - - This wipes the entire kandev install: database, worktrees, repo clones, sessions, tasks, - and quick-chat sessions. - + {t("system:factoryResetWipes")} - A snapshot is created automatically under {`/backups/`} before - the wipe runs, so you can restore from the Backups page if you change your mind. + + A snapshot is created automatically under {BACKUP_DIR_PLACEHOLDER} before + the wipe runs, so you can restore from the Backups page if you change your mind. + - Type RESET to enable the confirm button. After the wipe completes - you'll be asked to quit and relaunch Kandev - the backend does not auto-restart. + + Type {CONFIRM_TOKEN} to enable the confirm button. After the wipe + completes you'll be asked to quit and relaunch Kandev - the backend does not + auto-restart. + @@ -66,7 +80,8 @@ function ConfirmView({
onTyped(e.target.value)} disabled={submitting} @@ -82,7 +97,7 @@ function ConfirmView({ className="flex items-center gap-2 text-sm text-muted-foreground" data-testid="system-factory-reset-pending" > - Wiping data... + {t("system:factoryResetWiping")}
)} @@ -95,7 +110,7 @@ function ConfirmView({ className="cursor-pointer" data-testid="system-factory-reset-cancel" > - Cancel + {t("common:cancel")} @@ -112,20 +127,16 @@ function ConfirmView({ } function SuccessView({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); return ( <> - Factory reset complete + {t("system:factoryResetCompleteTitle")} - - Kandev's data has been wiped. Quit and relaunch the app to start fresh - the - running backend still holds connections to the now-empty database and will not rebuild - its caches until you restart. The pre-reset snapshot is available on the Backups page if - you need to roll back. - + {t("system:factoryResetCompleteBody")} @@ -135,7 +146,7 @@ function SuccessView({ onClose }: { onClose: () => void }) { className="cursor-pointer" data-testid="system-factory-reset-close" > - Close + {t("system:close")} @@ -143,6 +154,7 @@ function SuccessView({ onClose }: { onClose: () => void }) { } export function FactoryResetDialog({ open, onOpenChange }: Props) { + const { t } = useTranslation(); const [typed, setTyped] = useState(""); const [jobId, setJobId] = useState(null); const [requestPending, setRequestPending] = useState(false); @@ -152,7 +164,8 @@ export function FactoryResetDialog({ open, onOpenChange }: Props) { const succeeded = job?.state === "succeeded"; const failed = job?.state === "failed"; const submitting = requestPending || (jobId !== null && !succeeded && !failed); - const error = requestError ?? (failed ? (job?.message ?? "Reset failed") : null); + // `job.message` is the backend's own diagnostic text and stays as sent. + const error = requestError ?? (failed ? (job?.message ?? t("system:factoryResetFailed")) : null); const enabled = typed === CONFIRM_TOKEN && !submitting && !succeeded; const handleClose = (next: boolean) => { @@ -173,7 +186,7 @@ export function FactoryResetDialog({ open, onOpenChange }: Props) { const res = await resetDatabase(CONFIRM_TOKEN); setJobId(res.job_id); } catch (err) { - setRequestError(err instanceof Error ? err.message : "Reset request failed"); + setRequestError(err instanceof Error ? err.message : t("system:factoryResetRequestFailed")); } finally { setRequestPending(false); } diff --git a/apps/web/components/settings/system/feature-toggle-card.tsx b/apps/web/components/settings/system/feature-toggle-card.tsx index e96bec2b31..a7b5c0cf23 100644 --- a/apps/web/components/settings/system/feature-toggle-card.tsx +++ b/apps/web/components/settings/system/feature-toggle-card.tsx @@ -1,10 +1,12 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Switch } from "@kandev/ui/switch"; import { IconFlask, IconLock, IconRefresh } from "@tabler/icons-react"; +import type { TFunction } from "i18next"; import type { RuntimeFlagState } from "@/lib/types/runtime-flags"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -23,11 +25,15 @@ export function FeatureToggleCard({ onChange, onReset, }: FeatureToggleCardProps) { + const { t } = useTranslation(); const disabled = saving || flag.env_locked || !flag.mutable; return (
+ {/* `label`, `description` and `risk_description` are authored in + runtimeflags/registry.go and rendered by the backend, so they stay + English here — localizing them needs a key/value split in Go. */} {flag.label} @@ -39,7 +45,7 @@ export function FeatureToggleCard({ data-settings-dirty={isDirty} disabled={disabled} onCheckedChange={onChange} - aria-label={`Toggle ${flag.label}`} + aria-label={t("system:featureToggleSwitchLabel", { label: flag.label })} className="cursor-pointer disabled:cursor-not-allowed" /> @@ -57,12 +63,12 @@ export function FeatureToggleCard({ className="cursor-pointer disabled:cursor-not-allowed" > - Use default + {t("system:featureToggleUseDefault")} {flag.env_locked && ( - Controlled by launch environment + {t("system:featureToggleEnvControlled")} )}
@@ -72,34 +78,40 @@ export function FeatureToggleCard({ } function FlagBadges({ flag }: { flag: RuntimeFlagState }) { + const { t } = useTranslation(); return ( <> {flag.stability === "experimental" && ( - Experimental + {t("system:featureToggleExperimental")} )} - {flag.kind === "debug" && Debug} + {flag.kind === "debug" && {t("system:featureToggleDebug")}} ); } function FlagMetadata({ flag }: { flag: RuntimeFlagState }) { + const { t } = useTranslation(); return (
- Source: {sourceLabel(flag)} - Env: {flag.env_var} - {flag.restart_required && Requires restart} + {t("system:featureToggleSource", { source: sourceLabel(flag, t) })} + {/* `env_var` is the process environment variable name — an identifier. */} + {t("system:featureToggleEnv", { envVar: flag.env_var })} + {flag.restart_required && {t("system:featureToggleRequiresRestart")}} {flag.requires_restart_to_apply && ( - Pending restart + + {t("system:featureTogglePendingRestart")} + )}
); } -function sourceLabel(flag: RuntimeFlagState): string { - if (flag.source === "env") return "Environment"; - if (flag.source === "override") return "Saved override"; - return "Default"; +/** `source` is a wire enum; only its label is copy. */ +function sourceLabel(flag: RuntimeFlagState, t: TFunction): string { + if (flag.source === "env") return t("system:featureToggleSourceEnv"); + if (flag.source === "override") return t("system:featureToggleSourceOverride"); + return t("system:featureToggleSourceDefault"); } diff --git a/apps/web/components/settings/system/feature-toggles-settings.tsx b/apps/web/components/settings/system/feature-toggles-settings.tsx index e8fd665862..44b084dbb9 100644 --- a/apps/web/components/settings/system/feature-toggles-settings.tsx +++ b/apps/web/components/settings/system/feature-toggles-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useTranslation } from "react-i18next"; import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; @@ -10,6 +11,7 @@ import { IconInfoCircle, IconPower, IconRotateClockwise } from "@tabler/icons-re import { useToast } from "@/components/toast-provider"; import { useKandevRestart } from "@/hooks/domains/system/use-kandev-restart"; import { fetchRuntimeFlags, updateRuntimeFlag } from "@/lib/api/domains/runtime-flags-api"; +import type { TFunction } from "i18next"; import type { RuntimeFlagState } from "@/lib/types/runtime-flags"; import type { RestartCapability } from "@/lib/types/system"; import { FeatureToggleCard } from "./feature-toggle-card"; @@ -69,6 +71,7 @@ export function FeatureTogglesSettings({ initialFlags, restartCapability }: Prop } function useRuntimeFlagsDraft(initialFlags: RuntimeFlagState[]) { + const { t } = useTranslation(); const [flags, setFlags] = useState(initialFlags); const [savedFlags, setSavedFlags] = useState(initialFlags); const [isLoadingFlags, setIsLoadingFlags] = useState(initialFlags.length === 0); @@ -88,7 +91,7 @@ function useRuntimeFlagsDraft(initialFlags: RuntimeFlagState[]) { } } catch (err) { toast({ - title: "Failed to load feature toggles", + title: t("system:featureTogglesLoadFailed"), description: errorMessage(err), variant: "error", }); @@ -98,7 +101,7 @@ function useRuntimeFlagsDraft(initialFlags: RuntimeFlagState[]) { } } }, - [toast], + [toast, t], ); useEffect(() => { @@ -144,7 +147,7 @@ function useRuntimeFlagsDraft(initialFlags: RuntimeFlagState[]) { } setSavedFlags(persisted); setFlags((current) => (current === submitted ? persisted : current)); - toast({ title: "Feature toggles saved", variant: "success" }); + toast({ title: t("system:featureTogglesSaved"), variant: "success" }); }, discard: () => setFlags(savedFlags), }); @@ -169,12 +172,13 @@ function FeatureTogglesEmptyState({ isLoading: boolean; onRetry: () => void; }) { + const { t } = useTranslation(); if (isLoading) { return ( - Loading feature toggles... + {t("system:featureTogglesLoading")} ); @@ -182,9 +186,9 @@ function FeatureTogglesEmptyState({ return ( - Feature toggles could not be loaded. + {t("system:featureTogglesLoadError")} @@ -200,18 +204,19 @@ function RestartRequiredAlert({ restarting: boolean; onRestart: () => void; }) { + const { t } = useTranslation(); const supported = capability?.supported === true; return ( - Restart required + {t("system:restartRequired")} - Saved toggle changes will apply the next time Kandev starts. - {!supported && " Restart it from your terminal or service manager when convenient."} + {t("system:restartPendingChanges")} + {!supported && ` ${t("system:restartManualHint")}`} {supported && ( )} @@ -236,32 +241,33 @@ function RestartSupportInfo({ supported: boolean; reason: string | undefined; }) { + const { t } = useTranslation(); return ( - {restartSupportMessage(supported, reason)} + {restartSupportMessage(supported, reason, t)} ); } -function restartSupportMessage(supported: boolean, reason: string | undefined): string { - if (supported) { - return "Restart from this page is available when Kandev is running under a supported local supervisor."; - } - return ( - reason ?? - "Automatic restart is not available in deploy previews, unmanaged terminal runs, or launch modes without a restart supervisor." - ); +/** `reason`, when present, is the backend's own explanation and stays as sent. */ +function restartSupportMessage( + supported: boolean, + reason: string | undefined, + t: TFunction, +): string { + if (supported) return t("system:restartSupportedHelp"); + return reason ?? t("system:restartUnsupportedHelp"); } function errorMessage(err: unknown): string { diff --git a/apps/web/components/settings/system/health-issues-card.tsx b/apps/web/components/settings/system/health-issues-card.tsx index a9afcf3cbd..07681e31a4 100644 --- a/apps/web/components/settings/system/health-issues-card.tsx +++ b/apps/web/components/settings/system/health-issues-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; @@ -23,11 +24,19 @@ function severityIcon(severity: HealthSeverity) { return ; } -function severityLabel(severity: HealthSeverity): string { - return severity.charAt(0).toUpperCase() + severity.slice(1); -} +/** + * `severity` is a wire enum; only the badge label is copy. Title-casing the + * raw token was English-shaped by accident. Unknown severities from a newer + * backend fall back to the token itself. + */ +const SEVERITY_LABEL_KEYS: Record = { + error: "system:healthSeverityError", + warning: "system:healthSeverityWarning", + info: "system:healthSeverityInfo", +}; function HealthIssueRow({ issue }: { issue: HealthIssue }) { + const { t } = useTranslation(); const router = useRouter(); const workspaceId = useAppStore((s) => s.workspaces.activeId); const resolveUrl = (url: string) => url.replace("{workspaceId}", workspaceId ?? ""); @@ -40,9 +49,12 @@ function HealthIssueRow({ issue }: { issue: HealthIssue }) { {severityIcon(issue.severity)}
+ {/* title, message and fix_label are all rendered by the backend. */} {issue.title} - {severityLabel(issue.severity)} + {SEVERITY_LABEL_KEYS[issue.severity] + ? t(SEVERITY_LABEL_KEYS[issue.severity]) + : issue.severity}
{issue.message &&

{issue.message}

} @@ -64,13 +76,14 @@ function HealthIssueRow({ issue }: { issue: HealthIssue }) { } function ChecksPopover({ checks }: { checks: HealthCheckSummary[] }) { + const { t } = useTranslation(); if (checks.length === 0) return null; return ( -

System checks

+

{t("system:healthSystemChecks")}

    {checks.map((c) => (
  • )} + {/* `c.name` is the backend's check name. */} {c.name} - {c.passing ? "Passing" : "Issue"} + {c.passing ? t("system:healthPassing") : t("system:healthIssue")}
  • ))} @@ -104,6 +118,7 @@ function ChecksPopover({ checks }: { checks: HealthCheckSummary[] }) { } export function HealthIssuesCard() { + const { t } = useTranslation(); const { issues, checks, loaded } = useSystemHealth(); const nonInfo = issues.filter((i) => i.severity !== "info"); const hasIssues = nonInfo.length > 0; @@ -113,10 +128,12 @@ export function HealthIssuesCard() { - Health + {t("system:healthTitle")} {loaded && ( - {hasIssues ? `${nonInfo.length} issue${nonInfo.length === 1 ? "" : "s"}` : "Healthy"} + {hasIssues + ? t("system:healthIssueCount", { count: nonInfo.length }) + : t("system:healthHealthy")} )} @@ -125,7 +142,7 @@ export function HealthIssuesCard() { {!loaded && (

    - Loading health checks... + {t("system:healthLoading")}

    )} {loaded && issues.length === 0 && ( @@ -134,7 +151,7 @@ export function HealthIssuesCard() { data-testid="system-health-empty" > - All system checks pass. + {t("system:healthAllPass")}
)} {loaded && issues.map((issue) => )} diff --git a/apps/web/components/settings/system/invite-dialog.tsx b/apps/web/components/settings/system/invite-dialog.tsx index d9a0112b96..6a3fe5f77a 100644 --- a/apps/web/components/settings/system/invite-dialog.tsx +++ b/apps/web/components/settings/system/invite-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -42,19 +43,17 @@ function InviteForm({ onCancel: () => void; onSubmit: () => void; }) { + const { t } = useTranslation(); return ( <> - Invite a user - - Generates a one-time invite link. Leave the email blank to create a link anyone can use to - sign up with the selected role; set an email to pin the invite to that address. - + {t("system:inviteTitle")} + {t("system:inviteDescription")}
@@ -86,7 +86,7 @@ function InviteForm({
@@ -102,6 +102,7 @@ function InviteForm({ } function InviteLinkResult({ url, onDone }: { url: string; onDone: () => void }) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); const onCopy = async () => { if (await copyToClipboard(url)) setCopied(true); @@ -109,18 +110,24 @@ function InviteLinkResult({ url, onDone }: { url: string; onDone: () => void }) return ( <> - Invite link ready - - Share this link with the invitee. It is shown only once — copy it now. - + {t("system:inviteReadyTitle")} + {t("system:inviteReadyDescription")}
- + {/* The generated invite URL is a value. */} +
@@ -136,6 +143,7 @@ function InviteLinkResult({ url, onDone }: { url: string; onDone: () => void }) } export function InviteDialog({ open, onOpenChange, onCreated }: Props) { + const { t } = useTranslation(); const [email, setEmail] = useState(""); const [role, setRole] = useState("member"); const [error, setError] = useState(null); @@ -157,7 +165,7 @@ export function InviteDialog({ open, onOpenChange, onCreated }: Props) { setResultUrl(`${window.location.origin}${url}`); onCreated(); } catch (err) { - setError(err instanceof ApiError ? err.message : "Could not create invite."); + setError(err instanceof ApiError ? err.message : t("system:inviteFailed")); } finally { setSubmitting(false); } diff --git a/apps/web/components/settings/system/job-progress-indicator.tsx b/apps/web/components/settings/system/job-progress-indicator.tsx index da1f8231a0..f252acf4b3 100644 --- a/apps/web/components/settings/system/job-progress-indicator.tsx +++ b/apps/web/components/settings/system/job-progress-indicator.tsx @@ -1,5 +1,7 @@ "use client"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Spinner } from "@kandev/ui/spinner"; import { IconCheck, IconAlertTriangle } from "@tabler/icons-react"; @@ -34,16 +36,21 @@ function badgeVariant(state: SystemJob["state"]): "destructive" | "secondary" | return "outline"; } -function stateLabel(state: SystemJob["state"]): string { +/** + * `state` is a wire enum, so it travels as the key and only the label is copy. + * The `default` branch echoes the raw token so a state from a newer backend + * still renders something rather than blank. + */ +function stateLabel(state: SystemJob["state"], t: TFunction): string { switch (state) { case "queued": - return "Queued"; + return t("system:jobStateQueued"); case "running": - return "Running"; + return t("system:jobStateRunning"); case "succeeded": - return "Done"; + return t("system:jobStateDone"); case "failed": - return "Failed"; + return t("system:jobStateFailed"); default: return state; } @@ -55,6 +62,7 @@ export function JobProgressIndicator({ successLabel, testId, }: JobProgressIndicatorProps) { + const { t } = useTranslation(); const pinnedJob = useSystemJob(jobId); const jobs = useSystemJobs(kind); const job = jobId ? (pinnedJob ?? pickJob(jobs, jobId)) : pickJob(jobs); @@ -75,7 +83,7 @@ export function JobProgressIndicator({ {isSuccess && } {isFailed && } - {isSuccess && successLabel ? successLabel : stateLabel(job.state)} + {isSuccess && successLabel ? successLabel : stateLabel(job.state, t)} {job.message && {job.message}}
diff --git a/apps/web/components/settings/system/licenses-list.tsx b/apps/web/components/settings/system/licenses-list.tsx index 78dedbd689..f1c008c409 100644 --- a/apps/web/components/settings/system/licenses-list.tsx +++ b/apps/web/components/settings/system/licenses-list.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Badge } from "@kandev/ui/badge"; @@ -20,6 +21,7 @@ type Props = { }; function LicenseRow({ entry }: { entry: LicenseEntry }) { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); return (
setExpanded((v) => !v)} + aria-label={t("system:licensesToggleLabel", { name: entry.name })} data-testid="system-license-toggle" disabled={!entry.license_text} > @@ -44,6 +47,9 @@ function LicenseRow({ entry }: { entry: LicenseEntry }) { )}
+ {/* Package name, version, ecosystem and the SPDX identifier are all + identifiers, and `license_text` is third-party content. None of + them is ever translated. */}
{entry.name} @@ -69,7 +75,7 @@ function LicenseRow({ entry }: { entry: LicenseEntry }) { className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 cursor-pointer" data-testid="system-license-repo" > - source + {t("system:licensesSource")} )}
@@ -100,6 +106,7 @@ function filterEntries(entries: LicenseEntry[], query: string): LicenseEntry[] { } export function LicensesList({ entries }: Props) { + const { t } = useTranslation(); const [query, setQuery] = useState(""); const visible = useMemo(() => filterEntries(entries, query), [entries, query]); const hasStaleGoEntries = useMemo( @@ -111,22 +118,20 @@ export function LicensesList({ entries }: Props) { - Third-party licenses + {t("system:licensesTitle")} {hasStaleGoEntries && ( - - Go license entries were reused from the committed manifest because generation failed. - Regenerate licenses when go-licenses is healthy to refresh Go dependency data. - + {t("system:licensesStaleWarning")} )}
setQuery(e.target.value)} data-testid="system-licenses-filter" @@ -135,7 +140,7 @@ export function LicensesList({ entries }: Props) { className="text-xs text-muted-foreground whitespace-nowrap" data-testid="system-licenses-count" > - {visible.length} of {entries.length} + {t("system:licensesCount", { visible: visible.length, total: entries.length })}

- No entries match the filter. + {t("system:licensesEmpty")}

)}
diff --git a/apps/web/components/settings/system/restart-progress-dialog.tsx b/apps/web/components/settings/system/restart-progress-dialog.tsx index 41433612d7..303033f317 100644 --- a/apps/web/components/settings/system/restart-progress-dialog.tsx +++ b/apps/web/components/settings/system/restart-progress-dialog.tsx @@ -1,5 +1,7 @@ "use client"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -24,6 +26,7 @@ export function RestartProgressDialog({ errorMessage, onDismiss, }: RestartProgressDialogProps) { + const { t } = useTranslation(); if (phase === "idle") return null; const done = phase === "done"; const failed = phase === "error"; @@ -37,9 +40,9 @@ export function RestartProgressDialog({ - {restartTitle(phase)} + {restartTitle(phase, t)} - {restartDescription(phase, errorMessage)} + {restartDescription(phase, errorMessage, t)} {(done || failed) && ( @@ -48,7 +51,7 @@ export function RestartProgressDialog({ className="w-full cursor-pointer sm:w-auto" onClick={done ? () => window.location.reload() : onDismiss} > - {done ? "Reload page" : "Dismiss"} + {done ? t("system:reloadPage") : t("system:dismiss")} )} @@ -63,31 +66,37 @@ function RestartStatusIcon({ phase }: { phase: KandevRestartPhase }) { return ; } -function restartTitle(phase: KandevRestartPhase): string { +/** `phase` is a wire enum; only these labels are copy. */ +function restartTitle(phase: KandevRestartPhase, t: TFunction): string { switch (phase) { case "starting": - return "Requesting restart"; + return t("system:restartPhaseStartingTitle"); case "restarting": - return "Restarting Kandev"; + return t("system:restartPhaseRestartingTitle"); case "done": - return "Kandev restarted"; + return t("system:restartPhaseDoneTitle"); case "error": - return "Restart failed"; + return t("system:restartPhaseErrorTitle"); default: - return "Restarting Kandev"; + return t("system:restartPhaseRestartingTitle"); } } -function restartDescription(phase: KandevRestartPhase, errorMessage: string | null): string { +function restartDescription( + phase: KandevRestartPhase, + errorMessage: string | null, + t: TFunction, +): string { switch (phase) { case "starting": - return "Preparing the local supervisor restart request."; + return t("system:restartPhaseStartingBody"); case "restarting": - return "Kandev is stopping and starting again. This page will detect the new process automatically."; + return t("system:restartPhaseRestartingBody"); case "done": - return "The backend is running again. Reload the page to reconnect with the latest feature toggle state."; + return t("system:restartPhaseDoneBody"); case "error": - return errorMessage ?? "The restart could not be completed."; + // `errorMessage` originates from the restart API and stays as sent. + return errorMessage ?? t("system:restartPhaseErrorBody"); default: return ""; } diff --git a/apps/web/components/settings/system/restore-dialog.tsx b/apps/web/components/settings/system/restore-dialog.tsx index 5ba89d159b..2b15fe6b27 100644 --- a/apps/web/components/settings/system/restore-dialog.tsx +++ b/apps/web/components/settings/system/restore-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -22,6 +23,12 @@ type Props = { name: string; }; +/** + * Never translated, for the same reason as the factory-reset token: the confirm + * button is gated on `typed === CONFIRM_TOKEN` and the token is sent to + * `restoreBackup`, and writing a snapshot over the live database is + * destructive. It travels as an interpolated value into every place it is shown. + */ const CONFIRM_TOKEN = "RESTORE"; function ConfirmView({ @@ -43,21 +50,27 @@ function ConfirmView({ onCancel: () => void; onConfirm: () => void; }) { + const { t } = useTranslation(); return ( <> - Restore snapshot + {t("system:restoreTitle")} - Restore {name} over the current database. After the - staged copy is in place you will be asked to quit and relaunch Kandev so the new data is - loaded fresh - the backend does not auto-restart. + {/* `name` is the backup filename — a value, never translated. */} + + Restore {name} over the current database. After the + staged copy is in place you will be asked to quit and relaunch Kandev so the new data + is loaded fresh - the backend does not auto-restart. + - Type RESTORE to enable the confirm button. + + Type {CONFIRM_TOKEN} to enable the confirm button. + @@ -65,7 +78,8 @@ function ConfirmView({
onTyped(e.target.value)} disabled={submitting} @@ -81,7 +95,7 @@ function ConfirmView({ className="flex items-center gap-2 text-sm text-muted-foreground" data-testid="system-restore-pending" > - Writing the snapshot over the live database... + {t("system:restoreWriting")}
)}
@@ -94,7 +108,7 @@ function ConfirmView({ className="cursor-pointer" data-testid="system-restore-cancel" > - Cancel + {t("common:cancel")} @@ -111,18 +125,21 @@ function ConfirmView({ } function SuccessView({ name, onClose }: { name: string; onClose: () => void }) { + const { t } = useTranslation(); return ( <> - Restore complete + {t("system:restoreCompleteTitle")} - {name} has been written over the current database. - Quit and relaunch Kandev to load the restored data - the running backend still holds - connections to the previous version and will serve stale rows until you restart. + + {name} has been written over the current database. + Quit and relaunch Kandev to load the restored data - the running backend still holds + connections to the previous version and will serve stale rows until you restart. + @@ -133,7 +150,7 @@ function SuccessView({ name, onClose }: { name: string; onClose: () => void }) { className="cursor-pointer" data-testid="system-restore-close" > - Close + {t("system:close")} @@ -141,6 +158,7 @@ function SuccessView({ name, onClose }: { name: string; onClose: () => void }) { } export function RestoreDialog({ open, onOpenChange, name }: Props) { + const { t } = useTranslation(); const [typed, setTyped] = useState(""); const [jobId, setJobId] = useState(null); const [requestPending, setRequestPending] = useState(false); @@ -151,7 +169,8 @@ export function RestoreDialog({ open, onOpenChange, name }: Props) { const failed = job?.state === "failed"; // submitting spans both the HTTP roundtrip and the in-flight backend job. const submitting = requestPending || (jobId !== null && !succeeded && !failed); - const error = requestError ?? (failed ? (job?.message ?? "Restore failed") : null); + // `job.message` is the backend's own diagnostic text and stays as sent. + const error = requestError ?? (failed ? (job?.message ?? t("system:restoreFailed")) : null); const enabled = typed === CONFIRM_TOKEN && !submitting && !succeeded; const handleClose = (next: boolean) => { @@ -172,7 +191,7 @@ export function RestoreDialog({ open, onOpenChange, name }: Props) { const res = await restoreBackup(name, CONFIRM_TOKEN); setJobId(res.job_id); } catch (err) { - setRequestError(err instanceof Error ? err.message : "Restore request failed"); + setRequestError(err instanceof Error ? err.message : t("system:restoreRequestFailed")); } finally { setRequestPending(false); } diff --git a/apps/web/components/settings/system/self-update-progress.tsx b/apps/web/components/settings/system/self-update-progress.tsx index 05ac6e6ae3..06597d655c 100644 --- a/apps/web/components/settings/system/self-update-progress.tsx +++ b/apps/web/components/settings/system/self-update-progress.tsx @@ -1,5 +1,7 @@ "use client"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; import { IconCheck, IconAlertTriangle } from "@tabler/icons-react"; @@ -12,15 +14,23 @@ type SelfUpdateProgressProps = { onDismiss: () => void; }; -function activeText(phase: SelfUpdatePhase, target: string | null): string { - const version = target ?? "the new version"; +/** + * The version string is a value; only the frame around it is copy. When the + * target version is not known yet the whole sentence switches to a variant + * that does not name one, rather than interpolating a translated noun phrase. + */ +function activeText(phase: SelfUpdatePhase, target: string | null, t: TFunction): string { switch (phase) { case "starting": - return `Starting update to ${version}…`; + return target + ? t("system:selfUpdateStarting", { version: target }) + : t("system:selfUpdateStartingUnknown"); case "installing": - return `Downloading and installing ${version}…`; + return target + ? t("system:selfUpdateInstalling", { version: target }) + : t("system:selfUpdateInstallingUnknown"); case "restarting": - return "Restarting Kandev — this can take up to a minute."; + return t("system:selfUpdateRestarting"); default: return ""; } @@ -33,25 +43,29 @@ function ActiveRow({ phase: SelfUpdatePhase; targetVersion: string | null; }) { + const { t } = useTranslation(); return (
- {activeText(phase, targetVersion)} + {activeText(phase, targetVersion, t)}
-

- Keep this page open. It will refresh automatically when the update finishes. -

+

{t("system:selfUpdateKeepOpen")}

); } function DoneRow({ targetVersion }: { targetVersion: string | null }) { + const { t } = useTranslation(); return (
- Updated to {targetVersion ?? "the latest version"}. + + {targetVersion + ? t("system:selfUpdateDone", { version: targetVersion }) + : t("system:selfUpdateDoneUnknown")} +
); } function ErrorRow({ message, onDismiss }: { message: string | null; onDismiss: () => void }) { + const { t } = useTranslation(); return (
- {message ?? "The update failed."} + {/* `message` comes from the update API and stays as sent. */} + {message ?? t("system:selfUpdateFailed")}
); diff --git a/apps/web/components/settings/system/system-confirmation-dialogs.test.tsx b/apps/web/components/settings/system/system-confirmation-dialogs.test.tsx new file mode 100644 index 0000000000..c0170a34d7 --- /dev/null +++ b/apps/web/components/settings/system/system-confirmation-dialogs.test.tsx @@ -0,0 +1,137 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { activateLocale } from "@/lib/i18n"; +import { FactoryResetDialog } from "./factory-reset-dialog"; +import { RestoreDialog } from "./restore-dialog"; + +vi.mock("@/hooks/domains/system/use-system-jobs", () => ({ + useSystemJob: () => null, + useSystemJobs: () => [], +})); + +afterEach(cleanup); + +/** + * Both dialogs gate their confirm button on `typed === CONFIRM_TOKEN` and send + * the same token to the API, and both actions are irreversible. Translating a + * token would leave a dialog the user cannot satisfy in that locale, and + * nothing would fail until a second language shipped. + */ +describe("system type-to-confirm dialogs", () => { + it("keeps the factory-reset token verbatim and gates the confirm button on it", () => { + render(); + + // The whole reconstructed sentence: a tag index drifting off its + // child reassembles the copy into fragments without failing + // anything else in the suite. + expect(screen.getByText(/to enable the confirm button/).textContent).toBe( + "Type RESET to enable the confirm button. After the wipe completes you'll be asked to " + + "quit and relaunch Kandev - the backend does not auto-restart.", + ); + + const input = screen.getByTestId("system-factory-reset-input"); + const confirm = screen.getByTestId("system-factory-reset-confirm") as HTMLButtonElement; + expect(input.getAttribute("placeholder")).toBe("Type RESET to confirm"); + expect(confirm.disabled).toBe(true); + + fireEvent.change(input, { target: { value: "reset" } }); + expect(confirm.disabled).toBe(true); + + fireEvent.change(input, { target: { value: "RESET" } }); + expect(confirm.disabled).toBe(false); + }); + + it("keeps the restore token verbatim and interpolates the snapshot name", () => { + render(); + + // The filename is a value; only the frame around it is copy. + expect(screen.getByText(/over the current database/).textContent).toBe( + "Restore kandev-20260803.db over the current database. After the staged copy is in place " + + "you will be asked to quit and relaunch Kandev so the new data is loaded fresh - the " + + "backend does not auto-restart.", + ); + + const input = screen.getByTestId("system-restore-input"); + const confirm = screen.getByTestId("system-restore-confirm") as HTMLButtonElement; + expect(input.getAttribute("placeholder")).toBe("Type RESTORE to confirm"); + expect(confirm.disabled).toBe(true); + + fireEvent.change(input, { target: { value: "RESTORE" } }); + expect(confirm.disabled).toBe(false); + }); +}); + +/** + * The pseudo-locale oracle, at unit scale. + * + * `i18next/no-literal-string` runs in `mode: "jsx-only"`, so copy these dialogs + * build outside JSX is invisible to lint and a clean lint is not proof they are + * done. Under `pseudo` every catalog message is accented, so any word-like + * ASCII left in the dialog is a literal that never reached the catalog — except + * the confirmation token, which must stay verbatim for `typed === CONFIRM_TOKEN` + * to be satisfiable. + * + * Accessibility copy is scanned too, and that is the point: the live pseudo + * oracle only sees what is painted, so an `aria-label` is exactly the string + * that survives a migration untranslated because nothing on screen looks wrong. + */ +describe("system type-to-confirm dialogs under the pseudo-locale", () => { + const ACCENTED = /[À-ɏ]/; + const WORDLIKE = /[A-Za-z]{4,}/; + /** Attributes that carry copy, mirroring the eslint guard's include list. */ + const COPY_ATTRIBUTES = ["aria-label", "aria-description", "title", "placeholder", "alt"]; + + function unlocalizedText(): string[] { + const dialog = document.querySelector("[role=dialog]"); + if (!dialog) throw new Error("dialog did not render"); + const leftovers = new Set(); + const consider = (value: string | null | undefined) => { + const text = (value ?? "").trim(); + if (text && WORDLIKE.test(text) && !ACCENTED.test(text)) leftovers.add(text); + }; + + const walker = document.createTreeWalker(dialog, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + consider(node.textContent); + node = walker.nextNode(); + } + for (const element of [dialog, ...dialog.querySelectorAll("*")]) { + for (const attribute of COPY_ATTRIBUTES) consider(element.getAttribute(attribute)); + } + return [...leftovers]; + } + + beforeAll(async () => { + await activateLocale("pseudo"); + }); + afterAll(async () => { + await activateLocale("en"); + }); + + /** + * `Close` is `@kandev/ui`'s own `DialogContent` dismiss button. Localizing it + * needs a strings-provider seam in that package (see docs/i18n.md, "Shared + * UI"), so it is expected here rather than filtered out — if the package ever + * gains that seam, this assertion is what tells us to drop it. + */ + const UI_PACKAGE_CLOSE = "Close"; + + it("leaves only the RESET token unaccented in the factory-reset dialog", () => { + render(); + // `/backups/` is a path placeholder and stays a value. + expect(unlocalizedText().sort()).toEqual(["/backups/", UI_PACKAGE_CLOSE, "RESET"]); + // Still typeable, and still announced, under a non-English locale. + expect(screen.getByTestId("system-factory-reset-input").getAttribute("aria-label")).toContain( + "RESET", + ); + }); + + it("leaves only the RESTORE token and the snapshot name unaccented", () => { + render(); + expect(unlocalizedText().sort()).toEqual([UI_PACKAGE_CLOSE, "RESTORE", "snapshot-1.db"]); + expect(screen.getByTestId("system-restore-input").getAttribute("aria-label")).toContain( + "RESTORE", + ); + }); +}); diff --git a/apps/web/components/settings/system/system-invisible-copy.test.tsx b/apps/web/components/settings/system/system-invisible-copy.test.tsx new file mode 100644 index 0000000000..db4883e84a --- /dev/null +++ b/apps/web/components/settings/system/system-invisible-copy.test.tsx @@ -0,0 +1,189 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { activateLocale, t } from "@/lib/i18n"; +import type { HealthCheckSummary, HealthIssue } from "@/lib/types/health"; +import { HealthIssuesCard } from "./health-issues-card"; +import { JobProgressIndicator } from "./job-progress-indicator"; +import { SystemGroup } from "@/components/app-sidebar/sections/settings/system-group"; + +afterEach(cleanup); + +const jobs = vi.hoisted(() => ({ current: [] as unknown[] })); +vi.mock("@/hooks/domains/system/use-system-jobs", () => ({ + useSystemJob: () => null, + useSystemJobs: () => jobs.current, +})); + +const health = vi.hoisted(() => ({ + issues: [] as HealthIssue[], + checks: [] as HealthCheckSummary[], + loaded: true, +})); +vi.mock("@/hooks/domains/settings/use-system-health", () => ({ + useSystemHealth: () => health, +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: unknown) => unknown) => + selector({ workspaces: { activeId: "ws-1" }, auth: { user: { role: "admin" } } }), +})); +vi.mock("@/hooks/domains/features/use-feature", () => ({ useFeature: () => true })); + +/** + * `stateLabel()` returns its four labels from a plain function, so + * `mode: "jsx-only"` reported this file as clean while every label was a + * hardcoded literal. It renders on four cards — including + * `storage-quarantine-card.tsx`, which ships on a route this PR does not + * otherwise touch — so a regression here is not visible from the System pages. + */ +describe("JobProgressIndicator state labels", () => { + const job = (state: string) => ({ + id: "j1", + kind: "vacuum", + state, + started_at: "2026-08-03T10:00:00Z", + message: "", + }); + + it.each([ + ["queued", "Queued"], + ["running", "Running"], + ["succeeded", "Done"], + ["failed", "Failed"], + ])("renders %s as %s", (state, label) => { + jobs.current = [job(state)]; + render(); + expect(screen.getByTestId("system-job-vacuum").textContent).toContain(label); + }); + + // The wire enum is echoed rather than rendering blank, so a state from a + // newer backend still shows something. + it("echoes an unknown state token", () => { + jobs.current = [job("throttled")]; + render(); + expect(screen.getByTestId("system-job-vacuum").textContent).toContain("throttled"); + }); +}); + +/** + * This badge used to build its plural by hand — `issue${n === 1 ? "" : "s"}` — + * which renders correctly in English and is untranslatable everywhere else. + */ +describe("HealthIssuesCard issue count", () => { + it("agrees the noun with the number", () => { + health.issues = [ + { id: "a", severity: "error", title: "Disk full", message: "", fix_url: "", fix_label: "" }, + ] as HealthIssue[]; + render(); + expect(screen.getByTestId("system-health-card").textContent).toContain("1 issue"); + + cleanup(); + health.issues = [ + { id: "a", severity: "error", title: "Disk full", message: "", fix_url: "", fix_label: "" }, + { id: "b", severity: "warning", title: "Slow", message: "", fix_url: "", fix_label: "" }, + ] as HealthIssue[]; + render(); + expect(screen.getByTestId("system-health-card").textContent).toContain("2 issues"); + }); + + // `severity` is a wire enum. Title-casing the raw token was English-shaped by + // accident; unknown severities still echo rather than render blank. + it("labels a known severity and echoes an unknown one", () => { + health.issues = [ + { id: "a", severity: "warning", title: "Slow", message: "", fix_url: "", fix_label: "" }, + { + id: "b", + severity: "critical" as HealthIssue["severity"], + title: "Boom", + message: "", + fix_url: "", + fix_label: "", + }, + ] as HealthIssue[]; + render(); + const card = screen.getByTestId("system-health-card").textContent ?? ""; + expect(card).toContain("Warning"); + expect(card).toContain("critical"); + }); +}); + +/** + * `BASE_ITEMS` / `AUTH_ITEMS` are SCREAMING_CASE, which the guard skips + * entirely: it reported one finding here (`label="System"`) while ten nav + * labels sat in those two tables. They now resolve at render from `labelKey`. + */ +describe("SystemGroup nav labels", () => { + it("renders every System route label through the catalog", () => { + render(); + const labels = [ + "Status", + "Feature Toggles", + "Database", + "Backups", + "Storage", + "Logs", + "Updates", + "About", + "Licenses", + "Users", + ]; + for (const label of labels) { + expect(screen.getByText(label)).toBeTruthy(); + } + }); +}); + +/** + * The English assertions above pin the copy, but they cannot tell a `t()` call + * from a literal that happens to read the same — reverting + * `t("system:jobStateQueued")` to `"Queued"` passes every one of them. Only a + * non-English locale separates the two, and both surfaces here were reported as + * clean by lint, so this is the check that keeps them migrated. + */ +describe("copy the guard cannot see, under the pseudo-locale", () => { + const ACCENTED = /[À-ɏ]/; + + beforeAll(async () => { + await activateLocale("pseudo"); + }); + afterAll(async () => { + await activateLocale("en"); + }); + + it("accents every job state label", () => { + for (const state of ["queued", "running", "succeeded", "failed"]) { + jobs.current = [ + { id: "j1", kind: "vacuum", state, started_at: "2026-08-03T10:00:00Z", message: "" }, + ]; + render(); + expect(screen.getByTestId("system-job-vacuum").textContent).toMatch(ACCENTED); + cleanup(); + } + }); + + /** + * The Backups description carries a SQL statement and a filesystem path. Baked + * into the message they render `VÀĆŨŨḾ ĨŃŢŌ` and `<ďàţà-ďĩŕ>/ƀàćķũƥś/` — a + * command the user runs and a directory they have to find, both turned into + * dead pointers. They are interpolated as values instead, so the frame + * accents and they do not. + */ + it("keeps the backup SQL command and path literal inside a translated frame", () => { + const description = t("system:backupsPageDescription", { + command: "VACUUM INTO", + path: "/backups/", + }); + expect(description).toContain("VACUUM INTO"); + expect(description).toContain("/backups/"); + // The surrounding sentence is still translated. + expect(description).toMatch(ACCENTED); + }); + + it("accents every System nav label", () => { + render(); + const leaves = document.querySelectorAll("a"); + expect(leaves.length).toBeGreaterThan(0); + for (const leaf of leaves) { + expect(leaf.textContent ?? "").toMatch(ACCENTED); + } + }); +}); diff --git a/apps/web/components/settings/system/system-route-copy.test.ts b/apps/web/components/settings/system/system-route-copy.test.ts new file mode 100644 index 0000000000..4314ec14e0 --- /dev/null +++ b/apps/web/components/settings/system/system-route-copy.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { t } from "@/lib/i18n"; +import { BACKUP_DIR, BACKUP_SQL_COMMAND } from "./system-route-shell"; + +/** + * Byte-for-byte English for the nine System route headers, as `SETTINGS_ROUTES` + * in `src/settings-routes.tsx` rendered them before this migration. + * + * This exists because the copy was duplicated. Each of these routes had an + * unreferenced `app/settings/system//page.tsx` twin, and two of the + * twins had already drifted from the live route table — `logs` was the worse + * one, because the dead page rendered `settings:logsPageDescription` + * ("Download a bounded diagnostic ZIP containing frontend and backend logs.") + * while the live route rendered "Create a diagnostic ZIP with frontend and + * backend logs.". Pointing the live route at the existing key silently changed + * user-visible English; only `logs-page.spec.ts` pins that sentence, so the + * other eight routes had no check at all. + * + * An i18n migration must not change copy. This table is the check that says so + * for all nine, not just the one route that happened to have an e2e assertion. + */ +const ROUTE_COPY: Array<{ route: string; titleKey: string; title: string; description: string }> = [ + { + route: "status", + titleKey: "common:status", + title: "Status", + description: "Health checks, disk usage, and version summary.", + }, + { + route: "feature-toggles", + titleKey: "system:navFeatureToggles", + title: "Feature Toggles", + description: "Manage Kandev feature and diagnostic switches.", + }, + { + route: "database", + titleKey: "system:navDatabase", + title: "Database", + description: "Database driver, size, and available maintenance controls.", + }, + { + route: "logs", + titleKey: "settings:logsPageTitle", + title: "Logs", + description: "Create a diagnostic ZIP with frontend and backend logs.", + }, + { + route: "updates", + titleKey: "system:navUpdates", + title: "Updates", + description: "Current vs latest release plus the full kandev changelog.", + }, + { + route: "about", + titleKey: "system:navAbout", + title: "About", + description: "Version, build metadata, and links.", + }, + { + route: "licenses", + titleKey: "system:navLicenses", + title: "Licenses", + description: "Open-source licenses for every npm and Go dependency shipped with kandev.", + }, + { + route: "users", + titleKey: "system:navUsers", + title: "Users", + description: "Manage accounts, roles, and invite links for this instance.", + }, +]; + +describe("System route headers keep their pre-migration English", () => { + it.each(ROUTE_COPY)("$route", ({ titleKey, title, description }) => { + expect(t(titleKey)).toBe(title); + const descriptionKey = + titleKey === "settings:logsPageTitle" + ? "settings:logsPageDescription" + : `system:${camelRoute(title)}PageDescription`; + expect(t(descriptionKey)).toBe(description); + }); + + /** + * Backups is separate: its description interpolates the SQL statement and the + * snapshot directory, so both survive translation as values rather than being + * pseudo-localized into dead pointers. + */ + it("backups", () => { + expect(t("system:navBackups")).toBe("Backups"); + expect( + t("system:backupsPageDescription", { command: BACKUP_SQL_COMMAND, path: BACKUP_DIR }), + ).toBe("VACUUM INTO snapshots stored under /backups/."); + }); +}); + +/** "Feature Toggles" -> "featureToggles", matching the catalog key suffix. */ +function camelRoute(title: string): string { + const [first, ...rest] = title.split(" "); + return first.toLowerCase() + rest.join(""); +} diff --git a/apps/web/components/settings/system/system-route-shell.tsx b/apps/web/components/settings/system/system-route-shell.tsx new file mode 100644 index 0000000000..7c82a6b3cc --- /dev/null +++ b/apps/web/components/settings/system/system-route-shell.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { SystemPageShell } from "./system-page-shell"; + +/** + * The SQL statement that produces a snapshot, and the directory it writes to. + * Both are interpolated into the Backups description as values: baked into the + * message, the pseudo-locale renders them `VÀĆŨŨḾ ĨŃŢŌ` and + * `<ďàţà-ďĩŕ>/ƀàćķũƥś/`, turning a command the user runs and a path they have to + * find into dead pointers. + */ +export const BACKUP_SQL_COMMAND = "VACUUM INTO"; +export const BACKUP_DIR = "/backups/"; + +/** + * The shell the System routes render through. + * + * Titles and descriptions travel as catalog keys rather than resolved strings + * because the SPA's route table (`SETTINGS_ROUTES` in `src/settings-routes.tsx`) + * is a SCREAMING_CASE identifier, which `i18next/no-literal-string` skips + * entirely — the literals this replaced were invisible to lint and to the + * new-code ratchet alike. + */ +export function SystemRouteShell({ + titleKey, + descriptionKey, + descriptionValues, + children, +}: { + titleKey: string; + descriptionKey: string; + /** Identifiers the description interpolates, so they survive translation. */ + descriptionValues?: Record; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( + + {children} + + ); +} diff --git a/apps/web/components/settings/system/ui-state-card.tsx b/apps/web/components/settings/system/ui-state-card.tsx index ad0b89b2fa..c8ed82d4b2 100644 --- a/apps/web/components/settings/system/ui-state-card.tsx +++ b/apps/web/components/settings/system/ui-state-card.tsx @@ -1,13 +1,11 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconLayoutDashboard, IconRefresh } from "@tabler/icons-react"; -const HELP = - "Clears layout and UI preferences saved in your browser (panel sizes, pinned tasks, expanded groups, etc.). Use this if the app looks broken, panels are stuck, or the layout feels wrong. Your tasks, sessions, and server data are not affected - only the per-browser UI state. The page reloads after the reset."; - function resetBrowserStorage() { if (typeof window === "undefined") return; try { @@ -19,18 +17,21 @@ function resetBrowserStorage() { } export function UIStateCard() { + const { t } = useTranslation(); + // Shown both inline and in the button's tooltip. + const help = t("system:uiStateHelp"); return ( - UI state + {t("system:uiStateTitle")}
-

Reset browser layout

-

{HELP}

+

{t("system:uiStateResetLabel")}

+

{help}

@@ -42,10 +43,10 @@ export function UIStateCard() { data-testid="system-ui-state-reset" > - Reset + {t("system:uiStateReset")} - {HELP} + {help}
diff --git a/apps/web/components/settings/system/updates-card.tsx b/apps/web/components/settings/system/updates-card.tsx index 909cce7cb0..c33fa7e14b 100644 --- a/apps/web/components/settings/system/updates-card.tsx +++ b/apps/web/components/settings/system/updates-card.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { AlertDialog, AlertDialogAction, @@ -23,6 +25,7 @@ import { type DesktopUpdaterController, } from "@/hooks/domains/system/use-desktop-updater"; import { useUpdates } from "@/hooks/domains/system/use-updates"; +import { formatDateTime } from "@/lib/i18n/formats"; import type { UpdatesResponse } from "@/lib/types/system"; import { SelfUpdateProgress } from "./self-update-progress"; @@ -40,11 +43,11 @@ function reloadCurrentDocument(): void { window.location.reload(); } -function formatChecked(value: string | number | null | undefined): string { - if (!value) return "never"; +function formatChecked(value: string | number | null | undefined, t: TFunction): string { + if (!value) return t("system:updatesNever"); const d = new Date(value); if (Number.isNaN(d.getTime())) return String(value); - return d.toLocaleString(); + return formatDateTime(d); } function errorMessage(err: unknown, fallback: string): string { @@ -89,6 +92,7 @@ function serviceCardView( } export function UpdatesCard({ reloadDocument = reloadCurrentDocument }: UpdatesCardProps = {}) { + const { t } = useTranslation(); const { updates, check } = useUpdates(); const selfUpdate = useSelfUpdate({ latestVersion: updates?.latest, onComplete: reloadDocument }); const desktopUpdater = useDesktopUpdater(); @@ -107,7 +111,7 @@ export function UpdatesCard({ reloadDocument = reloadCurrentDocument }: UpdatesC try { await check(); } catch (err) { - const message = errorMessage(err, "Update check failed"); + const message = errorMessage(err, t("system:updatesCheckFailed")); setError(message); setRetryAfter(retryAfterSeconds(message)); } finally { @@ -209,10 +213,11 @@ function desktopCardView(updater: DesktopUpdaterController) { } function DesktopCurrentStatus({ phase }: { phase: string | undefined }) { + const { t } = useTranslation(); if (phase !== "up-to-date") return null; return (

- Kandev is up to date. + {t("system:updatesUpToDate")}

); } @@ -222,14 +227,19 @@ async function ignoreFailure(operation: Promise): Promise { } function DesktopUpdateProgress({ updater }: { updater: DesktopUpdaterController }) { + const { t } = useTranslation(); const state = updater.state; if (state?.phase !== "downloading" && state?.phase !== "installing") return null; - let detail = "Installing update..."; + // Built outside JSX, which is why `mode: "jsx-only"` never reported it. + let detail = t("system:updatesInstalling"); if (state.phase === "downloading") { const downloaded = state.downloadedBytes ?? 0; detail = state.totalBytes - ? `Downloading update (${downloaded} of ${state.totalBytes} bytes)...` - : `Downloading update (${downloaded} bytes)...`; + ? t("system:updatesDownloadingOfTotal", { + downloaded, + total: state.totalBytes, + }) + : t("system:updatesDownloading", { downloaded }); } return (
- Updates + {t("system:updatesTitle")} {available && ( - Update available + {t("system:updateAvailable")} )} @@ -258,10 +269,19 @@ function UpdatesHeader({ available }: { available: boolean }) { } function VersionGrid({ current, latest }: { current: string; latest: string }) { + const { t } = useTranslation(); return (
- - + +
); } @@ -278,9 +298,10 @@ function VersionValue({ label, value, testId }: { label: string; value: string; } function LastChecked({ checkedAt }: { checkedAt?: string | number | null }) { + const { t } = useTranslation(); return (
- Last checked {formatChecked(checkedAt)} + {t("system:updatesLastChecked", { at: formatChecked(checkedAt, t) })}
); } @@ -320,6 +341,7 @@ function CheckNowButton({ checking: boolean; onCheck: () => Promise; }) { + const { t } = useTranslation(); return ( ); } function ReleaseNotesLink({ url }: { url?: string }) { + const { t } = useTranslation(); if (!url) return null; return ( @@ -365,32 +388,34 @@ interface ApplyUpdateDialogProps { } function ApplyUpdateDialog({ showApply, latest, onApply, desktop }: ApplyUpdateDialogProps) { + const { t } = useTranslation(); if (!showApply) return null; return ( - Apply update? + {t("system:updatesApplyConfirmTitle")} + {/* The version is a value. */} {desktop - ? `Kandev will install ${latest} and restart the desktop app.` - : `Kandev will update to ${latest}, reinstall the user service, and restart it.`} + ? t("system:updatesApplyConfirmDesktop", { version: latest }) + : t("system:updatesApplyConfirmService", { version: latest })} - Cancel + {t("common:cancel")} void onApply()} data-testid="system-updates-apply-confirm" > - Apply update + {t("system:updatesApply")} @@ -410,6 +435,7 @@ function ManualUpdateInstructions({ if (!show || !reason) return null; return (
+ {/* `reason` and every manual command come from the updates API. */}

{reason}

@@ -430,10 +456,11 @@ function ManualCommands({ commands }: { commands: string[] }) { } function UpdateError({ error, retryAfter }: { error: string | null; retryAfter: number | null }) { + const { t } = useTranslation(); if (!error) return null; return (

- {retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error} + {retryAfter ? t("system:updatesRetryAfter", { count: retryAfter }) : error}

); } diff --git a/apps/web/components/settings/system/users-table.tsx b/apps/web/components/settings/system/users-table.tsx index cd7c207814..74da531b3f 100644 --- a/apps/web/components/settings/system/users-table.tsx +++ b/apps/web/components/settings/system/users-table.tsx @@ -1,6 +1,8 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { AlertDialog, AlertDialogAction, @@ -25,7 +27,27 @@ import { InviteDialog } from "./invite-dialog"; type PendingAction = { user: AuthUser; next: { role?: string; status?: string }; label: string }; -function useUsersList() { +/** + * `role` and `status` are wire values sent straight back to the API, so the + * `next` payload above always carries the raw token. Only these badge/button + * labels are copy, and an unrecognized value from a newer backend echoes the + * token rather than rendering blank. + */ +const ROLE_LABEL_KEYS: Record = { + admin: "system:usersRoleAdmin", + member: "system:usersRoleMember", +}; + +const STATUS_LABEL_KEYS: Record = { + active: "system:usersStatusActive", + disabled: "system:usersStatusDisabled", +}; + +function roleLabel(role: string, t: TFunction): string { + return ROLE_LABEL_KEYS[role] ? t(ROLE_LABEL_KEYS[role]) : role; +} + +function useUsersList(t: TFunction) { const [users, setUsers] = useState([]); const [loaded, setLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -39,11 +61,11 @@ function useUsersList() { setUsers(res.users); setLoaded(true); } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to load users."); + setError(err instanceof ApiError ? err.message : t("system:usersLoadFailed")); } finally { setIsLoading(false); } - }, []); + }, [t]); useEffect(() => { void reload(); @@ -53,9 +75,10 @@ function useUsersList() { } function StatusBadge({ status }: { status: string }) { + const { t } = useTranslation(); return ( - {status} + {STATUS_LABEL_KEYS[status] ? t(STATUS_LABEL_KEYS[status]) : status} ); } @@ -69,16 +92,18 @@ function UserRow({ onToggleRole: (user: AuthUser) => void; onToggleStatus: (user: AuthUser) => void; }) { + const { t } = useTranslation(); const isDisabled = user.status === "disabled"; return ( + {/* Email and display name are user data. */} {user.email} {user.display_name} - {user.role} + {roleLabel(user.role, t)} @@ -93,7 +118,10 @@ function UserRow({ onClick={() => onToggleRole(user)} data-testid="users-table-toggle-role" > - Make {user.role === "admin" ? "member" : "admin"} + {/* Two whole-word variants get their own keys rather than + interpolating a role name into "Make {x}", which does not + survive languages that inflect the object. */} + {user.role === "admin" ? t("system:usersMakeMember") : t("system:usersMakeAdmin")}
@@ -119,19 +147,20 @@ function UsersConfirmDialog({ onCancel: () => void; onConfirm: () => void; }) { + const { t } = useTranslation(); return ( !open && onCancel()}> {pending?.label} - {pending?.user.email} — this takes effect immediately. + {t("system:usersTakesEffectImmediately", { email: pending?.user.email ?? "" })} - Cancel + {t("common:cancel")} - Confirm + {t("system:usersConfirm")} @@ -139,19 +168,28 @@ function UsersConfirmDialog({ ); } -function roleTogglePending(u: AuthUser): PendingAction { +/** + * These two build the confirmation sentence outside JSX, which is why + * `mode: "jsx-only"` never reported them. The email is interpolated as a value + * and the target role travels as its own translated label. + */ +function roleTogglePending(u: AuthUser, t: TFunction): PendingAction { const next = u.role === "admin" ? "member" : "admin"; - return { user: u, next: { role: next }, label: `Change ${u.email} to ${next}?` }; + return { + user: u, + next: { role: next }, + label: t("system:usersConfirmRoleChange", { email: u.email, role: roleLabel(next, t) }), + }; } -function statusTogglePending(u: AuthUser): PendingAction { +function statusTogglePending(u: AuthUser, t: TFunction): PendingAction { const disabling = u.status !== "disabled"; return { user: u, next: { status: disabling ? "disabled" : "active" }, label: disabling - ? `Disable ${u.email}? They will be signed out everywhere.` - : `Re-enable ${u.email}?`, + ? t("system:usersConfirmDisable", { email: u.email }) + : t("system:usersConfirmEnable", { email: u.email }), }; } @@ -164,15 +202,16 @@ function UsersTableList({ onToggleRole: (u: AuthUser) => void; onToggleStatus: (u: AuthUser) => void; }) { + const { t } = useTranslation(); return (
- Email - Name - Role - Status - Actions + {t("system:usersColumnEmail")} + {t("system:usersColumnName")} + {t("system:usersColumnRole")} + {t("system:usersColumnStatus")} + {t("system:usersColumnActions")} @@ -190,7 +229,8 @@ function UsersTableList({ } export function UsersTable() { - const { users, loaded, isLoading, error, reload } = useUsersList(); + const { t } = useTranslation(); + const { users, loaded, isLoading, error, reload } = useUsersList(t); const [createOpen, setCreateOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [pending, setPending] = useState(null); @@ -204,11 +244,8 @@ export function UsersTable() { } catch (err) { toast({ variant: "error", - title: "Could not update user", - description: - err instanceof ApiError - ? err.message - : "This deployment must keep at least one active admin.", + title: t("system:usersUpdateFailed"), + description: err instanceof ApiError ? err.message : t("system:usersLastAdminGuard"), }); } finally { setPending(null); @@ -219,7 +256,7 @@ export function UsersTable() { - Users + {t("system:usersTitle")}
@@ -249,19 +286,19 @@ export function UsersTable() { )} {!loaded && isLoading && (
- Loading users... + {t("system:usersLoading")}
)} {loaded && users.length > 0 && ( setPending(roleTogglePending(u))} - onToggleStatus={(u) => setPending(statusTogglePending(u))} + onToggleRole={(u) => setPending(roleTogglePending(u, t))} + onToggleStatus={(u) => setPending(statusTogglePending(u, t))} /> )} {loaded && users.length === 0 && !error && (

- No users yet. + {t("system:usersEmpty")}

)} diff --git a/apps/web/components/settings/system/version-summary-card.tsx b/apps/web/components/settings/system/version-summary-card.tsx index 6f6844c288..2fc7a7f45c 100644 --- a/apps/web/components/settings/system/version-summary-card.tsx +++ b/apps/web/components/settings/system/version-summary-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Badge } from "@kandev/ui/badge"; @@ -8,6 +9,7 @@ import { IconRefresh, IconArrowRight } from "@tabler/icons-react"; import { useUpdates } from "@/hooks/domains/system/use-updates"; export function VersionSummaryCard() { + const { t } = useTranslation(); const { updates } = useUpdates(); const current = updates?.current ?? "-"; const latest = updates?.latest ?? "-"; @@ -18,24 +20,25 @@ export function VersionSummaryCard() { - Version + {t("system:versionTitle")} {updateAvailable && ( - Update available + {t("system:updateAvailable")} )} + {/* Both version strings are values. */}
-
Current
+
{t("system:versionCurrent")}
{current}
-
Latest
+
{t("system:versionLatest")}
{latest}
@@ -43,7 +46,7 @@ export function VersionSummaryCard() {
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index 160130ad1b..ab4ccef4c5 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -714,4 +714,103 @@ export const i18nGuardFiles = [ // icons render here on every pipeline node, so leaving them would have left // plain English inside a card this PR claims is done. "components/step-capability-icons.tsx", + // Settings → System, the remaining eight routes (about, backups, database, + // feature-toggles, licenses, logs, status, updates) plus Users, which is a + // ninth System route reachable only when the `auth` feature is on. This + // completes the group: `components/settings/system/**` is now migrated in + // full, so the storage-only globs above are the historical record of which + // PR did which half rather than a boundary that still means anything. + // + // **The live titles and descriptions were not in `components/` at all.** + // `app/settings/system/{about,backups,database,feature-toggles,licenses, + // status,updates}/page.tsx` are unreferenced Next-era leftovers — nothing + // imports them; `src/settings-routes.tsx` is the SPA's real route table and + // `StoragePage` is the only `app/settings/system` page it still pulls in. + // Those nine titles/descriptions live in `SETTINGS_ROUTES`, a SCREAMING_CASE + // identifier that `i18next/no-literal-string` skips *entirely* — the guard + // reported 5 findings in a 652-line file holding copy for every settings + // route. They now travel as `titleKey`/`descriptionKey` through a + // `SystemRouteShell` wrapper. The dead pages are migrated too rather than + // deleted, which keeps this PR copy-only; deleting them is a separate call. + // + // `src/settings-routes.tsx` is deliberately NOT on this list. Only its System + // entries are migrated — Executors, Workflows, Integrations, Workspaces and + // the account routes still hold English titles there, and the file is one + // file, so allowlisting it would claim a completeness this PR does not have. + // Whichever sibling migration lands last should add it. + // + // Six entries hold no JSX, so `mode: "jsx-only"` never inspects them; they + // record that the file is migrated and only the pseudo-locale can prove it + // stays that way. Three are hooks that were in no lint-derived list at all + // (`use-kandev-restart`, `use-self-update`, `use-desktop-updater` own seven + // restart/update failure messages between them); all three import the + // module-level `t` so each string resolves when its callback fires, keeping + // `t` out of the callbacks' dependency arrays. + // + // Four more shapes the guard structurally cannot see were migrated here, and + // every one was found by reading rather than by lint: + // - `job-progress-indicator.tsx` reported 0 and returned `Queued` / + // `Running` / `Done` / `Failed` from `stateLabel()`. It renders on four + // cards, one of which (`storage-quarantine-card.tsx`) is #2194's and is + // on a route this PR does not otherwise touch. + // - `action-button-content.tsx` reported 0: its `Running...` / `Done` / + // `Failed` were destructuring defaults, which evaluate before the + // component body, so they resolve inside the body now. + // - SCREAMING_CASE config tables: `ROWS` in `disk-usage-card.tsx` (7 row + // labels), `BASE_ITEMS`/`AUTH_ITEMS` in the sidebar's `system-group.tsx` + // (10 nav labels — the guard saw only `label="System"`), and the four + // `*_HELP` maintenance blurbs in `database-stats-card.tsx`. + // - `aria-label`s, which the pseudo-locale oracle cannot see either: + // `What is {label}?`, `Toggle {flag.label}`, `Restart support details`, + // `What's monitored`, and the three icon-only backup row actions, which + // had no accessible name at all before this PR. + // + // `bundle-customizer.tsx` (533 lines) and `log-viewer.tsx` (281) also report + // 0 and are listed because they are genuinely already migrated — an earlier + // diagnostics PR did them under `settings:diagnostic*`. Those 47 keys stay in + // `settings.json` rather than moving to `system`: unlike the twelve + // `storage*` keys #2194 relocated, they are already keyed, already correct, + // and moving them would be churn with a rename-typo risk and no user-visible + // gain. + // + // Deliberately left in English, none of it copy: + // - The type-to-confirm tokens `RESET` (`factory-reset-dialog.tsx`) and + // `RESTORE` (`restore-dialog.tsx`). Both gate their confirm button on + // `typed === CONFIRM_TOKEN` *and* are sent to the API (`resetDatabase`, + // `restoreBackup`), so translating either would make an irreversible + // dialog impossible to satisfy in that locale. Each travels as an + // interpolated value into the visible sentence, the placeholder and the + // input's aria-label, so shown and compared cannot drift. + // - Backend-owned copy, on the same contract as #2193's `PermissionSetting`: + // `RuntimeFlagState.label` / `.description` / `.risk_description` are + // authored in `runtimeflags/registry.go`; `HealthIssue.title` / + // `.message` / `.fix_label`, `HealthCheckSummary.name`, `SystemJob.message`, + // `RestartCapability.reason`, and `UpdatesResponse.apply_unsupported_reason` + // / `.manual_commands` are all rendered by the API. Localizing them needs + // a key/value split in Go, not a frontend change. + // - Wire values, each rendered beside its own translated label: the runtime + // flag `key` and `env_var` (the persisted registry identity — never + // translate either), the user `role` / `status` (the `value` on each + // `SelectItem` is the token posted to the API; only the child text is + // copy), the snapshot `kind`, the job `state`, the health `severity`, and + // the restart/update `phase` unions. + // - Identifiers and third-party content in `licenses-list.tsx`: package + // `name`, `version`, `ecosystem`, the SPDX `license` id (`MIT`, + // `Apache-2.0`) and the full `license_text`. None of it is ours to + // translate. + // - Build and release values in `about-card.tsx` / `version-summary-card.tsx` + // / `updates-card.tsx`: version strings, commit SHA, build time, Go + // version, OS and arch. `PostgreSQL` / `SQLite` in `formatDriver` are the + // products' own spellings, and `GitHub` is a brand noun. + // - Every filesystem path: the data directory, the database `path`, the + // backup filenames, and the `/backups/` placeholder — all + // interpolated as values so the pseudo-locale cannot turn them into dead + // pointers. + "app/settings/system/**/*.{ts,tsx}", + "components/app-sidebar/sections/settings/system-group.tsx", + "components/settings/changelog-list.tsx", + "components/settings/system/*.{ts,tsx}", + "hooks/domains/system/use-desktop-updater.ts", + "hooks/domains/system/use-kandev-restart.ts", + "hooks/domains/system/use-self-update.ts", ]; diff --git a/apps/web/hooks/domains/system/use-desktop-updater.ts b/apps/web/hooks/domains/system/use-desktop-updater.ts index 6c182f1bce..83c80b2b5b 100644 --- a/apps/web/hooks/domains/system/use-desktop-updater.ts +++ b/apps/web/hooks/domains/system/use-desktop-updater.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { desktopUpdater } from "@/lib/desktop/updater-client"; +// Module-level `t`: resolved at call time, no JSX in this file for lint to see. +import { t } from "@/lib/i18n"; import type { DesktopUpdateState } from "@/lib/desktop/protocol"; import type { DesktopUpdaterAdapter } from "@/lib/desktop/updater-adapter"; @@ -127,5 +129,5 @@ export function useDesktopUpdater( } function message(error: unknown): string { - return error instanceof Error ? error.message : "Desktop update failed"; + return error instanceof Error ? error.message : t("system:desktopUpdateFailed"); } diff --git a/apps/web/hooks/domains/system/use-kandev-restart.ts b/apps/web/hooks/domains/system/use-kandev-restart.ts index 7312de1d6b..3f9a549b04 100644 --- a/apps/web/hooks/domains/system/use-kandev-restart.ts +++ b/apps/web/hooks/domains/system/use-kandev-restart.ts @@ -2,6 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchSystemInfo, requestRestart } from "@/lib/api/domains/system-api"; +// The module-level `t` (not the hook) so these resolve when the callback fires +// and stay out of the callbacks' dependency arrays. This file has no JSX, so +// `mode: "jsx-only"` can never see the strings below. +import { t } from "@/lib/i18n"; export type KandevRestartPhase = "idle" | "starting" | "restarting" | "done" | "error"; @@ -47,7 +51,7 @@ export function useKandevRestart({ await requestRestart(); setPhase("restarting"); } catch (e) { - fail(e instanceof Error ? e.message : "Failed to restart Kandev"); + fail(e instanceof Error ? e.message : t("system:restartStartFailed")); } }, [fail]); @@ -68,7 +72,7 @@ export function useKandevRestart({ const startedAt = startedAtRef.current ?? Date.now(); if (Date.now() - startedAt > MAX_DURATION_MS) { if (!cancelled) { - fail("Restart is taking longer than expected. Refresh to check the current status."); + fail(t("system:restartTimedOut")); } return; } diff --git a/apps/web/hooks/domains/system/use-self-update.ts b/apps/web/hooks/domains/system/use-self-update.ts index d5d41fff3e..76e1ae5743 100644 --- a/apps/web/hooks/domains/system/use-self-update.ts +++ b/apps/web/hooks/domains/system/use-self-update.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { applyUpdate, fetchSystemInfo, fetchSystemJob } from "@/lib/api/domains/system-api"; +// Module-level `t`: resolved at call time, no JSX in this file for lint to see. +import { t } from "@/lib/i18n"; /** * Lifecycle of an in-UI self-update, from the user's perspective: @@ -111,7 +113,7 @@ export function useSelfUpdate({ setJobId(res.job_id); setPhase("installing"); } catch (e) { - fail(e instanceof Error ? e.message : "Failed to start the update"); + fail(e instanceof Error ? e.message : t("system:selfUpdateStartFailed")); } }, [latestVersion, fail]); @@ -153,12 +155,12 @@ export function useSelfUpdate({ const tick = async () => { if (Date.now() - startedAt > MAX_DURATION_MS) { if (!cancelled) { - fail("Update is taking longer than expected. Refresh to check the current version."); + fail(t("system:selfUpdateTimedOut")); } return; } if (await launchFailed(jobId)) { - if (!cancelled) fail("The update helper failed to start. Check the service logs."); + if (!cancelled) fail(t("system:selfUpdateHelperFailed")); return; } try { diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 56bf5264fd..a753f2e185 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -375,7 +375,7 @@ "useAgentForNewTaskTitles": "Use the agent for new task titles", "agentGeneratedTaskTitlesDisabledHint": "Disabled by default; save this setting before opening a creation dialog.", "logsPageTitle": "Logs", - "logsPageDescription": "Download a bounded diagnostic ZIP containing frontend and backend logs.", + "logsPageDescription": "Create a diagnostic ZIP with frontend and backend logs.", "diagnosticReviewBeforeSharing": "Review before sharing", "diagnosticReviewDescription": "This ZIP combines up to three days of backend logs with locally retained frontend console events from connected browsers. It may contain URLs, console arguments, stacks, paths, runtime metadata, and user-visible errors.", "diagnosticBundleTitle": "Frontend + backend diagnostic logs", diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index a38f938df9..7356023bd9 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -1,183 +1,433 @@ { - "storageTitle": "Storage", - "storageDescription": "Review disk use and reclaim space from Kandev-owned workspaces, caches, and Docker resources whenever your installation needs it.", - - "storageLoadingData": "Loading storage data…", - "storageSectionUnavailable": "This storage section could not be loaded", - "storageAdoptionPending": "Wait for Go cache adoption to finish.", - "storagePolicyLoadingBlock": "Wait for storage settings to finish loading.", + "aboutArch": "Arch", + "aboutBuildTime": "Build time", + "aboutCommit": "Commit", + "aboutDocumentation": "Documentation", + "aboutGoVersion": "Go version", + "aboutLoading": "Loading...", + "aboutOs": "OS", + "aboutPageDescription": "Version, build metadata, and links.", + "aboutReportIssue": "Report an issue", + "aboutTitle": "About kandev", + "aboutVersion": "Version", + "actionDone": "Done", + "actionFailed": "Failed", + "actionRunning": "Running...", + "backupsColumnActions": "Actions", + "backupsColumnCreated": "Created", + "backupsColumnKind": "Kind", + "backupsColumnName": "Name", + "backupsColumnSize": "Size", + "backupsCreate": "Create snapshot", + "backupsCreateFailed": "Create snapshot failed", + "backupsCreateTimeout": "Create snapshot did not finish within {{seconds}}s", + "backupsCreating": "Creating...", + "backupsDeleteFailed": "Delete failed", + "backupsDeleteLabel": "Delete {{name}}", + "backupsDownloadLabel": "Download {{name}}", + "backupsEmpty": "No backups yet. Snapshots created automatically on version upgrade or manually via the button above will appear here.", + "backupsHelp": "A backup is a full copy of Kandev's database (tasks, sessions, settings). Kandev automatically creates one before every version upgrade so you can roll back if something goes wrong. You can also create one manually before risky operations like factory reset.", + "backupsKindAuto": "auto", + "backupsKindManual": "manual", + "backupsLoading": "Loading backups...", + "backupsPageDescription": "{{command}} snapshots stored under {{path}}.", + "backupsRestoreLabel": "Restore {{name}}", + "backupsRetention_one": "Only the latest backup is kept on disk.", + "backupsRetention_other": "Only the latest {{count}} backups are kept on disk.", + "backupsTitle": "Backups", + "changelogEmpty": "No changelog entries available.", + "changelogViewOnGitHub": "View on GitHub", + "close": "Close", + "createUserDescription": "Creates an account directly with a password you set. Prefer an invite link when the user should choose their own password.", + "createUserDisplayName": "Display name", + "createUserEmail": "Email", + "createUserFailed": "Could not create user.", + "createUserPassword": "Password", + "createUserRole": "Role", + "createUserSubmit": "Create user", + "createUserSubmitting": "Creating...", + "createUserTitle": "Add a user", + "databaseDriver": "Driver", + "databaseFactoryReset": "Factory reset", + "databaseFactoryResetHelp": "Wipes ALL Kandev data: tasks, sessions, settings, repositories, and worktrees. There is no undo. A backup is taken first, but restoring it requires using the Backups page after the reset. Only use this if you want to start over from scratch.", + "databaseLastBackup": "Last backup", + "databaseLoading": "Loading database stats...", + "databaseMaintenanceUnavailable": "SQLite maintenance actions are unavailable for {{driver}}.", + "databaseNever": "Never", + "databaseOptimize": "Optimize", + "databaseOptimizeHelp": "Asks the database to refresh its internal indexes so common queries stay fast. Run it after deleting a lot of data or if the app feels sluggish. Quick and safe - no data is changed.", + "databasePageDescription": "Database driver, size, and available maintenance controls.", + "databasePath": "Path", + "databaseRunOptimize": "Run optimize", + "databaseRunVacuum": "Run vacuum", + "databaseSchemaVersion": "Schema version", + "databaseSize": "Size", + "databaseSizeLong": "Database size", + "databaseTitle": "Database", + "databaseVacuum": "Vacuum", + "databaseVacuumHelp": "Reclaims unused space inside the database file. The file can grow over time as you delete tasks or sessions; vacuum compacts it back down. Safe to run anytime - it does not change any data. Use it once in a while if the database size looks large.", + "databaseWal": "WAL", + "databaseWalHelp": "Write-Ahead Log. SQLite writes new changes to this companion file first and merges them into the main database over time. A non-zero WAL size is normal - it temporarily grows under load and shrinks back during checkpoints. Running vacuum will reset it.", + "databaseWhatIs": "What is {{label}}?", + "desktopUpdateFailed": "Desktop update failed", + "diskCalculating": "Calculating...", + "diskColumnPath": "Path", + "diskColumnSize": "Size", + "diskComputedAt": "Computed at {{at}}", + "diskOpen": "Open", + "diskRefresh": "Refresh", + "diskRefreshed": "Refreshed", + "diskRefreshHelp": "Walks every directory inside the data folder and adds up the file sizes. Results are cached for a couple of hours; click to compute fresh numbers right now. Can take a few seconds on large workspaces.", + "diskRefreshing": "Refreshing...", + "diskRowBackups": "Backups", + "diskRowDataDir": "Data directory", + "diskRowQuickChat": "Quick chat", + "diskRowRepos": "Repositories", + "diskRowSessions": "Sessions", + "diskRowTasks": "Tasks", + "diskRowWorktrees": "Worktrees", + "diskTotal": "Total", + "diskUnmeasuredDirs": "Some directories could not be measured:", + "diskUsageTitle": "Disk Usage", + "dismiss": "Dismiss", + "factoryResetCompleteBody": "Kandev's data has been wiped. Quit and relaunch the app to start fresh - the running backend still holds connections to the now-empty database and will not rebuild its caches until you restart. The pre-reset snapshot is available on the Backups page if you need to roll back.", + "factoryResetCompleteTitle": "Factory reset complete", + "factoryResetFailed": "Reset failed", + "factoryResetPlaceholder": "Type {{token}} to confirm", + "factoryResetRequestFailed": "Reset request failed", + "factoryResetSnapshot": "A snapshot is created automatically under <1>{{path}} before the wipe runs, so you can restore from the Backups page if you change your mind.", + "factoryResetTitle": "Factory Reset", + "factoryResetTypeToConfirm": "Type <1>{{token}} to enable the confirm button. After the wipe completes you'll be asked to quit and relaunch Kandev - the backend does not auto-restart.", + "factoryResetWipes": "This wipes the entire kandev install: database, worktrees, repo clones, sessions, tasks, and quick-chat sessions.", + "factoryResetWiping": "Wiping data...", + "featureToggleDebug": "Debug", + "featureToggleEnv": "Env: {{envVar}}", + "featureToggleEnvControlled": "Controlled by launch environment", + "featureToggleExperimental": "Experimental", + "featureTogglePendingRestart": "Pending restart", + "featureToggleRequiresRestart": "Requires restart", + "featureTogglesLoadError": "Feature toggles could not be loaded.", + "featureTogglesLoadFailed": "Failed to load feature toggles", + "featureTogglesLoading": "Loading feature toggles...", + "featureToggleSource": "Source: {{source}}", + "featureToggleSourceDefault": "Default", + "featureToggleSourceEnv": "Environment", + "featureToggleSourceOverride": "Saved override", + "featureTogglesPageDescription": "Manage Kandev feature and diagnostic switches.", + "featureTogglesRetry": "Retry", + "featureTogglesSaved": "Feature toggles saved", + "featureToggleSwitchLabel": "Toggle {{label}}", + "featureToggleUseDefault": "Use default", + "healthAllPass": "All system checks pass.", + "healthHealthy": "Healthy", + "healthIssue": "Issue", + "healthIssueCount_one": "{{count}} issue", + "healthIssueCount_other": "{{count}} issues", + "healthLoading": "Loading health checks...", + "healthPassing": "Passing", + "healthSeverityError": "Error", + "healthSeverityInfo": "Info", + "healthSeverityWarning": "Warning", + "healthSystemChecks": "System checks", + "healthTitle": "Health", + "healthWhatsMonitored": "What's monitored", + "inviteCopied": "Invite link copied", + "inviteCopy": "Copy invite link", + "inviteCreate": "Create invite link", + "inviteCreating": "Creating...", + "inviteDescription": "Generates a one-time invite link. Leave the email blank to create a link anyone can use to sign up with the selected role; set an email to pin the invite to that address.", + "inviteDone": "Done", + "inviteEmailOptional": "Email (optional)", + "inviteFailed": "Could not create invite.", + "inviteReadyDescription": "Share this link with the invitee. It is shown only once — copy it now.", + "inviteReadyTitle": "Invite link ready", + "inviteTitle": "Invite a user", + "inviteUrlLabel": "Invite link", + "jobStateDone": "Done", + "jobStateFailed": "Failed", + "jobStateQueued": "Queued", + "jobStateRunning": "Running", + "licensesCount": "{{visible}} of {{total}}", + "licensesEmpty": "No entries match the filter.", + "licensesFilterPlaceholder": "Filter by name, license, or ecosystem...", + "licensesPageDescription": "Open-source licenses for every npm and Go dependency shipped with kandev.", + "licensesSource": "source", + "licensesStaleWarning": "Go license entries were reused from the committed manifest because generation failed. Regenerate licenses when go-licenses is healthy to refresh Go dependency data.", + "licensesTitle": "Third-party licenses", + "licensesToggleLabel": "Show license text for {{name}}", + "navAbout": "About", + "navBackups": "Backups", + "navDatabase": "Database", + "navFeatureToggles": "Feature Toggles", + "navLicenses": "Licenses", + "navLogs": "Logs", + "navStorage": "Storage", + "navUpdates": "Updates", + "navUsers": "Users", + "reloadPage": "Reload page", + "restartAction": "Restart", + "restartManualHint": "Restart it from your terminal or service manager when convenient.", + "restartPendingChanges": "Saved toggle changes will apply the next time Kandev starts.", + "restartPhaseDoneBody": "The backend is running again. Reload the page to reconnect with the latest feature toggle state.", + "restartPhaseDoneTitle": "Kandev restarted", + "restartPhaseErrorBody": "The restart could not be completed.", + "restartPhaseErrorTitle": "Restart failed", + "restartPhaseRestartingBody": "Kandev is stopping and starting again. This page will detect the new process automatically.", + "restartPhaseRestartingTitle": "Restarting Kandev", + "restartPhaseStartingBody": "Preparing the local supervisor restart request.", + "restartPhaseStartingTitle": "Requesting restart", + "restartRequired": "Restart required", + "restartStartFailed": "Failed to restart Kandev", + "restartSupportDetails": "Restart support details", + "restartSupportedHelp": "Restart from this page is available when Kandev is running under a supported local supervisor.", + "restartTimedOut": "Restart is taking longer than expected. Refresh to check the current status.", + "restartUnsupportedHelp": "Automatic restart is not available in deploy previews, unmanaged terminal runs, or launch modes without a restart supervisor.", + "restoreAction": "Restore", + "restoreBody": "Restore <1>{{name}} over the current database. After the staged copy is in place you will be asked to quit and relaunch Kandev so the new data is loaded fresh - the backend does not auto-restart.", + "restoreCompleteBody": "<0>{{name}} has been written over the current database. Quit and relaunch Kandev to load the restored data - the running backend still holds connections to the previous version and will serve stale rows until you restart.", + "restoreCompleteTitle": "Restore complete", + "restoreFailed": "Restore failed", + "restorePlaceholder": "Type {{token}} to confirm", + "restoreRequestFailed": "Restore request failed", + "restoreTitle": "Restore snapshot", + "restoreTypeToConfirm": "Type <1>{{token}} to enable the confirm button.", + "restoreWriting": "Writing the snapshot over the live database...", + "selfUpdateDone": "Updated to {{version}}.", + "selfUpdateDoneUnknown": "Updated to the latest version.", + "selfUpdateFailed": "The update failed.", + "selfUpdateHelperFailed": "The update helper failed to start. Check the service logs.", + "selfUpdateInstalling": "Downloading and installing {{version}}…", + "selfUpdateInstallingUnknown": "Downloading and installing the new version…", + "selfUpdateKeepOpen": "Keep this page open. It will refresh automatically when the update finishes.", + "selfUpdateRestarting": "Restarting Kandev — this can take up to a minute.", + "selfUpdateStartFailed": "Failed to start the update", + "selfUpdateStarting": "Starting update to {{version}}…", + "selfUpdateStartingUnknown": "Starting update to the new version…", + "selfUpdateTimedOut": "Update is taking longer than expected. Refresh to check the current version.", + "settingsRouteNotPorted": "This settings route is handled by the SPA shell, but its dedicated client page is still being ported: <1>{{pathname}}", + "statusPageDescription": "Health checks, disk usage, and version summary.", + "storageAcknowledgeDaemon": "Acknowledge daemon", + "storageAcknowledgeDedicatedFirst": "Acknowledge a dedicated Docker daemon first.", + "storageActionFailed": "Storage action failed", "storageActionPending": "Wait for the current storage action to finish.", - - "storageActionsTitle": "Reclaim disk space safely", "storageActionsDescription": "Analyze for a read-only snapshot, or run the enabled cleanup rules when you want to recover space immediately.", - "storageAnalyze": "Analyze", - "storageAnalyzing": "Analyzing...", + "storageActionsTitle": "Reclaim disk space safely", + "storageAdoptCache": "Adopt cache", + "storageAdoptDialogDescription": "Kandev will be allowed to rotate and quarantine the existing cache at {{path}}. This path must be absolute and on the same filesystem as Kandev trash.", + "storageAdoptDialogFallbackPath": "the selected path", + "storageAdoptDialogTitle": "Adopt an external Go build cache", + "storageAdoptionPending": "Wait for Go cache adoption to finish.", "storageAnalysisComplete": "Analysis complete", + "storageAnalysisDescription": "A read-only breakdown of current usage and reclaimable space. Run Analyze occasionally to refresh these estimates; it never deletes or moves anything.", "storageAnalysisFailed": "Analysis failed", "storageAnalysisRunning": "Storage analysis is still running.", - "storageRunNow": "Run now", + "storageAnalysisTitle": "Storage analysis", + "storageAnalyze": "Analyze", + "storageAnalyzing": "Analyzing...", + "storageBuildCacheKeepHelp": "Docker keeps approximately this much build cache when pruning eligible records. A larger value preserves more reusable build layers but reclaims less disk space.", + "storageBuildCacheKeepLabel": "Build cache to retain (GB)", + "storageBuildCacheUnusedHelp": "Only build cache records older than this unused-age threshold are eligible. Increasing it protects recent build layers for longer.", + "storageBuildCacheUnusedLabel": "Build cache must be unused for (hours)", + "storageBusyForceHint": "Running cleanup anyway may disrupt this active work.", + "storageBusyIntro": "Cleanup may disrupt the following work:", + "storageBusyTitle": "Storage cleanup found active Kandev work", + "storageCheckIntervalHelp": "How often Kandev checks whether scheduled maintenance is due. A check that finds Kandev busy is skipped and tried again at the next interval.", + "storageCheckIntervalLabel": "Check every (hours)", + "storageCleanContainersAria": "Clean Kandev containers", + "storageCleanDockerBuildCacheAria": "Clean Docker build cache", + "storageCleanGoCache": "Clean Go cache", "storageCleaning": "Cleaning...", + "storageCleanOrphanWorkspacesAria": "Clean orphan task workspaces", + "storageCleanUnusedImagesAria": "Clean unused Docker images", "storageCleanupComplete": "Cleanup complete", "storageCleanupFailed": "Cleanup failed", "storageCleanupRunning": "Storage cleanup is still running.", - "storageActionFailed": "Storage action failed", - "storageBusyTitle": "Storage cleanup found active Kandev work", - "storageBusyIntro": "Cleanup may disrupt the following work:", - "storageBusyForceHint": "Running cleanup anyway may disrupt this active work.", - "storageRunAnyway": "Run anyway", - - "storageAnalysisTitle": "Storage analysis", - "storageAnalysisDescription": "A read-only breakdown of current usage and reclaimable space. Run Analyze occasionally to refresh these estimates; it never deletes or moves anything.", - "storageDockerUnavailableBadge": "Docker unavailable", - "storageLastAnalyzed": "Last analyzed {{time}}", - "storageTotalCounted": "Total counted: {{size}}", - "storageTotalPartial": "Partial measurement", - "storageUnavailableValue": "Unavailable", - "storageTaskWorkspaces": "Task workspaces", - "storageWorkspacesDetail": "{{reclaimable}} reclaimable after the grace period · {{active}} active", - "storageQuarantinedResources": "Quarantined resources", - "storageQuarantineUnmeasured": "Quarantine usage could not be measured", - "storageQuarantineMovedAside_one": "{{count}} item moved aside for recovery before permanent deletion", - "storageQuarantineMovedAside_other": "{{count}} items moved aside for recovery before permanent deletion", - "storageDockerUnmeasured": "Docker usage could not be measured", - "storageKandevContainers": "Kandev containers", - "storageManagedContainerCount_one": "{{count}} managed container", - "storageManagedContainerCount_other": "{{count}} managed containers", - "storageGoBuildCache": "Go build cache", - "storageUserGoBuildCache": "User Go build cache", - "storageDockerImageLayers": "Docker image layers", - "storageDefaultDockerHost": "Default Docker host", - "storageDockerBuildCache": "Docker build cache", - "storageUnusedDockerImages": "Unused Docker images", - "storageUnusedImagesDetail": "Unused by every container and older than the configured age", - "storageCleanGoCache": "Clean Go cache", - "storageGoCacheNotOwned": "Only a Kandev-owned Go build cache can be cleaned.", - "storageGoCacheBelowLimit": "The Go build cache is below its configured size limit.", - - "storagePolicyTitle": "Maintenance policy", - "storagePolicyDescription": "Choose what Kandev may reclaim automatically and the safety limits it must follow.", - "storageMoreInformationAbout": "More information about {{label}}", - - "storageScheduleTitle": "Schedule", - "storageScheduleDescription": "Controls when automatic maintenance is allowed to start. Manual actions remain available when scheduling is off.", - "storageScheduledMaintenance": "Scheduled maintenance", - "storageScheduledMaintenanceDescription": "Periodically reclaim disk space using the enabled resource rules.", - "storageScheduledMaintenanceHelp": "When enabled, Kandev checks this policy at the configured interval and starts only after Kandev has been idle for the required period. Turning it off does not disable Analyze or Run now.", - "storageCheckIntervalLabel": "Check every (hours)", - "storageCheckIntervalHelp": "How often Kandev checks whether scheduled maintenance is due. A check that finds Kandev busy is skipped and tried again at the next interval.", - "storageIdleLabel": "Require idle for (minutes)", - "storageIdleHelp": "Scheduled cleanup starts only after no task, shell command, test, setup, cleanup, or image build has used managed resources for this long. Run now does not wait for this timer, but it still refuses to run while resources are active.", - - "storageWorkspacesTitle": "Workspaces and containers", - "storageWorkspacesDescription": "Reclaim resources that Kandev can positively identify as no longer in use.", - "storageOrphanWorkspaces": "Orphan task workspaces", - "storageOrphanWorkspacesDescription": "Move confirmed orphan workspaces to quarantine.", - "storageOrphanWorkspacesHelp": "Kandev only selects a task workspace after inventory confirms that no active task, environment, session, or protected worktree uses it. The workspace is moved to quarantine first, where it can be restored before permanent deletion.", - "storageCleanOrphanWorkspacesAria": "Clean orphan task workspaces", - "storageOrphanGraceLabel": "Wait before orphaning (hours)", - "storageOrphanGraceHelp": "A workspace must be unused for at least this long before it can be classified as an orphan. Increasing this value keeps old workspaces longer before they enter quarantine.", + "storageClearEligible": "Clear eligible", + "storageClearEligibleDialogTitle": "Clear eligible quarantine", "storageContainersDescription": "Remove stopped, unused containers created and labeled by Kandev.", "storageContainersHelp": "Only stopped containers labeled as Kandev-managed are considered, and inventory must confirm they are no longer needed. Running containers and unrelated Docker containers are never removed by this option.", - "storageCleanContainersAria": "Clean Kandev containers", - - "storageGoCacheSectionDescription": "Use and trim a Kandev-owned cache for new host-local executions.", - "storageManagedGoCache": "Managed Go cache", - "storageManagedGoCacheDescription": "New host-local executions use {{path}}.", - "storageManagedGoCacheHelp": "When enabled, Kandev gives new local task processes a dedicated Go build cache and may rotate it during maintenance when it exceeds the maximum. Turning this off stops using the managed cache for new executions but does not delete it.", - "storageEnableManagedGoCacheAria": "Enable managed Go cache", - "storageGoCacheMaxLabel": "Maximum cache size (GB)", - "storageGoCacheMaxHelp": "This is a cleanup trigger, not a hard quota. The cache may grow past this size while tasks are active. Maintenance rotates the owned cache into quarantine and recreates an empty cache after the limit is exceeded.", - "storageExternalGoCache": "External Go cache", - "storageExternalGoCacheHelp": "Adoption gives Kandev explicit permission to rotate this existing cache. Kandev never cleans a default user cache or another path unless you adopt that exact absolute path and confirm the destructive access.", - "storageExternalGoCacheDescription": "Optionally allow Kandev to maintain an existing host cache outside its managed path.", - "storageEnableGoCacheFirst": "Enable the managed Go cache first.", - "storageEnterCachePathFirst": "Enter an absolute cache path first.", - "storageAdoptCache": "Adopt cache", - - "storageDockerTitle": "Docker cleanup", - "storageDockerDescription": "Optional daemon-wide cleanup. Enable it only when this Docker daemon is dedicated to Kandev.", - "storageDockerUnavailable": "Docker is unavailable on the configured host.", - "storageDockerUnavailableNotice": "Docker is unavailable; Docker cleanup options cannot run on this host.", - "storageAcknowledgeDedicatedFirst": "Acknowledge a dedicated Docker daemon first.", "storageDedicatedDaemon": "Dedicated Docker daemon", "storageDedicatedDaemonDescription": "Confirm that unrelated workloads do not share this Docker daemon.", "storageDedicatedDaemonHelp": "Build cache and image ownership cannot be attributed reliably to Kandev. This acknowledgment unlocks daemon-wide cleanup and should only be enabled when the configured Docker daemon is used exclusively by Kandev. Kandev never performs a volume-wide prune.", - "storageDockerBuildCacheDescription": "Remove old build cache while retaining the configured amount.", - "storageDockerBuildCacheHelp": "Uses Docker's age and storage filters to remove old build cache. It does not run docker system prune, does not remove volumes globally, and remains disabled until the dedicated-daemon acknowledgment is confirmed.", - "storageCleanDockerBuildCacheAria": "Clean Docker build cache", - "storageBuildCacheKeepLabel": "Build cache to retain (GB)", - "storageBuildCacheKeepHelp": "Docker keeps approximately this much build cache when pruning eligible records. A larger value preserves more reusable build layers but reclaims less disk space.", - "storageBuildCacheUnusedLabel": "Build cache must be unused for (hours)", - "storageBuildCacheUnusedHelp": "Only build cache records older than this unused-age threshold are eligible. Increasing it protects recent build layers for longer.", - "storageUnusedImagesDescription": "Remove old images that no container uses.", - "storageUnusedImagesHelp": "Removes an image only when no running or stopped container references it and it is older than the configured age. This is daemon-wide and therefore requires the dedicated-daemon acknowledgment.", - "storageCleanUnusedImagesAria": "Clean unused Docker images", - "storageUnusedImagesHoursLabel": "Image must be unused for (hours)", - "storageUnusedImagesHoursHelp": "An image must be unused by every container and older than this age before Kandev can remove it. Increasing the value keeps old images available for longer.", - - "storageQuarantineSafetyTitle": "Quarantine safety", - "storageQuarantineSafetyDescription": "Keep recoverable resources for a grace period before permanent deletion.", - "storageQuarantineRetentionLabel": "Keep quarantined items for (hours)", - "storageQuarantineRetentionHelp": "Cleanup first moves orphan workspaces and rotated Go caches into Kandev's trash area instead of deleting them immediately. During this retention period you can restore an item. After the deadline, a later maintenance run may permanently delete it.", - - "storageTypeToConfirm": "Type <1>{{phrase}} to continue.", - "storageTypeToConfirmAria": "Type {{phrase}} to confirm", - "storageDedicatedDialogTitle": "Use this dedicated Docker daemon", "storageDedicatedDialogDescription": "Build-cache and unused-image cleanup affect the entire configured daemon, including resources created outside Kandev. Only acknowledge a daemon dedicated to this installation.", - "storageAcknowledgeDaemon": "Acknowledge daemon", - "storageAdoptDialogTitle": "Adopt an external Go build cache", - "storageAdoptDialogDescription": "Kandev will be allowed to rotate and quarantine the existing cache at {{path}}. This path must be absolute and on the same filesystem as Kandev trash.", - "storageAdoptDialogFallbackPath": "the selected path", - "storageDeleteDialogTitle": "Permanently delete quarantined data", + "storageDedicatedDialogTitle": "Use this dedicated Docker daemon", + "storageDefaultDockerHost": "Default Docker host", + "storageDelete": "Delete", "storageDeleteDialogDescription": "This cannot be undone. Kandev will permanently remove {{path}}.", "storageDeleteDialogFallbackPath": "the selected quarantine entry", + "storageDeleteDialogTitle": "Permanently delete quarantined data", "storageDeletePermanently": "Delete permanently", - "storageClearEligibleDialogTitle": "Clear eligible quarantine", + "storageDeletionComplete": "Deletion complete", + "storageDescription": "Review disk use and reclaim space from Kandev-owned workspaces, caches, and Docker resources whenever your installation needs it.", + "storageDockerBuildCache": "Docker build cache", + "storageDockerBuildCacheDescription": "Remove old build cache while retaining the configured amount.", + "storageDockerBuildCacheHelp": "Uses Docker's age and storage filters to remove old build cache. It does not run docker system prune, does not remove volumes globally, and remains disabled until the dedicated-daemon acknowledgment is confirmed.", + "storageDockerDescription": "Optional daemon-wide cleanup. Enable it only when this Docker daemon is dedicated to Kandev.", + "storageDockerImageLayers": "Docker image layers", + "storageDockerTitle": "Docker cleanup", + "storageDockerUnavailable": "Docker is unavailable on the configured host.", + "storageDockerUnavailableBadge": "Docker unavailable", + "storageDockerUnavailableNotice": "Docker is unavailable; Docker cleanup options cannot run on this host.", + "storageDockerUnmeasured": "Docker usage could not be measured", + "storageEligibleNow": "Eligible now", + "storageEnableGoCacheFirst": "Enable the managed Go cache first.", + "storageEnableManagedGoCacheAria": "Enable managed Go cache", + "storageEnterCachePathFirst": "Enter an absolute cache path first.", + "storageExternalGoCache": "External Go cache", + "storageExternalGoCacheDescription": "Optionally allow Kandev to maintain an existing host cache outside its managed path.", + "storageExternalGoCacheHelp": "Adoption gives Kandev explicit permission to rotate this existing cache. Kandev never cleans a default user cache or another path unless you adopt that exact absolute path and confirm the destructive access.", + "storageForceClearAll": "Force clear all", + "storageForceClearDescription": "This attempts to permanently remove every quarantined item immediately and cannot be undone for entries that succeed. Failed entries may remain visible and retryable.", "storageForceClearDialogTitle": "Force clear all quarantine", + "storageGoBuildCache": "Go build cache", + "storageGoCacheBelowLimit": "The Go build cache is below its configured size limit.", + "storageGoCacheMaxHelp": "This is a cleanup trigger, not a hard quota. The cache may grow past this size while tasks are active. Maintenance rotates the owned cache into quarantine and recreates an empty cache after the limit is exceeded.", + "storageGoCacheMaxLabel": "Maximum cache size (GB)", + "storageGoCacheNotOwned": "Only a Kandev-owned Go build cache can be cleaned.", + "storageGoCacheSectionDescription": "Use and trim a Kandev-owned cache for new host-local executions.", + "storageHistoryEmpty": "No storage maintenance runs yet.", + "storageHistoryLoading": "Loading maintenance history…", + "storageIdleHelp": "Scheduled cleanup starts only after no task, shell command, test, setup, cleanup, or image build has used managed resources for this long. Run now does not wait for this timer, but it still refuses to run while resources are active.", + "storageIdleLabel": "Require idle for (minutes)", + "storageKandevContainers": "Kandev containers", + "storageLastAnalyzed": "Last analyzed {{time}}", + "storageLoadingData": "Loading storage data…", + "storageMaintenanceHistory": "Maintenance history", + "storageManagedContainerCount_one": "{{count}} managed container", + "storageManagedContainerCount_other": "{{count}} managed containers", + "storageManagedGoCache": "Managed Go cache", + "storageManagedGoCacheDescription": "New host-local executions use {{path}}.", + "storageManagedGoCacheHelp": "When enabled, Kandev gives new local task processes a dedicated Go build cache and may rotate it during maintenance when it exceeds the maximum. Turning this off stops using the managed cache for new executions but does not delete it.", + "storageMoreInformationAbout": "More information about {{label}}", + "storageNoEligibleEntries": "No quarantine entries are eligible yet.", + "storageOrphanGraceHelp": "A workspace must be unused for at least this long before it can be classified as an orphan. Increasing this value keeps old workspaces longer before they enter quarantine.", + "storageOrphanGraceLabel": "Wait before orphaning (hours)", + "storageOrphanWorkspaces": "Orphan task workspaces", + "storageOrphanWorkspacesDescription": "Move confirmed orphan workspaces to quarantine.", + "storageOrphanWorkspacesHelp": "Kandev only selects a task workspace after inventory confirms that no active task, environment, session, or protected worktree uses it. The workspace is moved to quarantine first, where it can be restored before permanent deletion.", + "storagePolicyDescription": "Choose what Kandev may reclaim automatically and the safety limits it must follow.", + "storagePolicyLoadingBlock": "Wait for storage settings to finish loading.", + "storagePolicyTitle": "Maintenance policy", + "storageProtectedUntil": "Protected until <1>{{deadline}}", "storagePurgeEligibleCount_one": "This will permanently remove {{count}} eligible item.", "storagePurgeEligibleCount_other": "This will permanently remove {{count}} eligible items.", "storagePurgeProtectedCount_one": "{{count}} protected item remains until its retention deadline.", "storagePurgeProtectedCount_other": "{{count}} protected items remain until their retention deadlines.", - "storageForceClearDescription": "This attempts to permanently remove every quarantined item immediately and cannot be undone for entries that succeed. Failed entries may remain visible and retryable.", - - "storageQuarantineHeading": "Quarantine", - "storageQuarantineHelp": "Quarantine is Kandev's recoverable holding area. Orphan task workspaces and rotated Go caches are moved here before deletion. You can restore an item during its retention period; after the deadline, a later maintenance run may permanently delete it.", "storageQuarantineCardDescription": "Cleanup moves recoverable data here first instead of deleting it immediately. Restore an item if you still need it, or delete it permanently when you are certain.", + "storageQuarantineCleanupRunning": "A quarantine cleanup is still running.", + "storageQuarantinedResources": "Quarantined resources", "storageQuarantineEligibleCount_one": "{{count}} eligible now", "storageQuarantineEligibleCount_other": "{{count}} eligible now", + "storageQuarantineEmpty": "No restorable quarantined resources.", + "storageQuarantineHeading": "Quarantine", + "storageQuarantineHelp": "Quarantine is Kandev's recoverable holding area. Orphan task workspaces and rotated Go caches are moved here before deletion. You can restore an item during its retention period; after the deadline, a later maintenance run may permanently delete it.", + "storageQuarantineLoading": "Loading quarantine…", + "storageQuarantineMovedAside_one": "{{count}} item moved aside for recovery before permanent deletion", + "storageQuarantineMovedAside_other": "{{count}} items moved aside for recovery before permanent deletion", "storageQuarantineProtectedCount_one": "{{count}} protected", "storageQuarantineProtectedCount_other": "{{count}} protected", + "storageQuarantineRetentionHelp": "Cleanup first moves orphan workspaces and rotated Go caches into Kandev's trash area instead of deleting them immediately. During this retention period you can restore an item. After the deadline, a later maintenance run may permanently delete it.", + "storageQuarantineRetentionLabel": "Keep quarantined items for (hours)", + "storageQuarantineSafetyDescription": "Keep recoverable resources for a grace period before permanent deletion.", + "storageQuarantineSafetyTitle": "Quarantine safety", + "storageQuarantineScheduleOff": "Automatic quarantine cleanup is off. Run maintenance manually or use a quarantine action when you are ready.", "storageQuarantineScheduleOn_one": "Eligible entries are removed by the first successful maintenance run after their deadline (every {{count}} hour when the idle gate allows it).", "storageQuarantineScheduleOn_other": "Eligible entries are removed by the first successful maintenance run after their deadline (every {{count}} hours when the idle gate allows it).", - "storageQuarantineScheduleOff": "Automatic quarantine cleanup is off. Run maintenance manually or use a quarantine action when you are ready.", "storageQuarantineTotal": "Total quarantined: {{size}}", - "storageQuarantineLoading": "Loading quarantine…", - "storageQuarantineEmpty": "No restorable quarantined resources.", - "storageNoEligibleEntries": "No quarantine entries are eligible yet.", - "storageQuarantineCleanupRunning": "A quarantine cleanup is still running.", - "storageClearEligible": "Clear eligible", - "storageForceClearAll": "Force clear all", - "storageDeletionComplete": "Deletion complete", - "storageResourceTypeTaskWorkspace": "task workspace", + "storageQuarantineUnmeasured": "Quarantine usage could not be measured", + "storageRefreshFailed": "Refresh storage data: {{message}}", "storageResourceTypeGoCache": "go cache", - "storageTrashPath": "Trash: {{path}}", - "storageEligibleNow": "Eligible now", - "storageProtectedUntil": "Protected until <1>{{deadline}}", + "storageResourceTypeTaskWorkspace": "task workspace", "storageRestore": "Restore", - "storageDelete": "Delete", "storageRetentionEnds": "Retention ends {{deadline}}.", - - "storageMaintenanceHistory": "Maintenance history", - "storageHistoryLoading": "Loading maintenance history…", - "storageHistoryEmpty": "No storage maintenance runs yet.", - - "storageToastPolicySaved": "Storage policy saved", + "storageRunAnyway": "Run anyway", + "storageRunNow": "Run now", + "storageScheduleDescription": "Controls when automatic maintenance is allowed to start. Manual actions remain available when scheduling is off.", + "storageScheduledMaintenance": "Scheduled maintenance", + "storageScheduledMaintenanceDescription": "Periodically reclaim disk space using the enabled resource rules.", + "storageScheduledMaintenanceHelp": "When enabled, Kandev checks this policy at the configured interval and starts only after Kandev has been idle for the required period. Turning it off does not disable Analyze or Run now.", + "storageScheduleTitle": "Schedule", + "storageSectionUnavailable": "This storage section could not be loaded", + "storageTaskWorkspaces": "Task workspaces", + "storageTitle": "Storage", + "storageToastAnalysisStarted": "Storage analysis started", "storageToastCacheAdopted": "Go build cache adopted", "storageToastDeletionStarted": "Permanent deletion started", "storageToastEligiblePurgeStarted": "Eligible quarantine cleanup started", "storageToastForcedPurgeStarted": "Forced quarantine cleanup started", - "storageToastAnalysisStarted": "Storage analysis started", "storageToastMaintenanceStarted": "Storage maintenance started", + "storageToastPolicySaved": "Storage policy saved", "storageToastQuarantineRestored": "Quarantined resource restored", - "storageRefreshFailed": "Refresh storage data: {{message}}" + "storageTotalCounted": "Total counted: {{size}}", + "storageTotalPartial": "Partial measurement", + "storageTrashPath": "Trash: {{path}}", + "storageTypeToConfirm": "Type <1>{{phrase}} to continue.", + "storageTypeToConfirmAria": "Type {{phrase}} to confirm", + "storageUnavailableValue": "Unavailable", + "storageUnusedDockerImages": "Unused Docker images", + "storageUnusedImagesDescription": "Remove old images that no container uses.", + "storageUnusedImagesDetail": "Unused by every container and older than the configured age", + "storageUnusedImagesHelp": "Removes an image only when no running or stopped container references it and it is older than the configured age. This is daemon-wide and therefore requires the dedicated-daemon acknowledgment.", + "storageUnusedImagesHoursHelp": "An image must be unused by every container and older than this age before Kandev can remove it. Increasing the value keeps old images available for longer.", + "storageUnusedImagesHoursLabel": "Image must be unused for (hours)", + "storageUserGoBuildCache": "User Go build cache", + "storageWorkspacesDescription": "Reclaim resources that Kandev can positively identify as no longer in use.", + "storageWorkspacesDetail": "{{reclaimable}} reclaimable after the grace period · {{active}} active", + "storageWorkspacesTitle": "Workspaces and containers", + "uiStateHelp": "Clears layout and UI preferences saved in your browser (panel sizes, pinned tasks, expanded groups, etc.). Use this if the app looks broken, panels are stuck, or the layout feels wrong. Your tasks, sessions, and server data are not affected - only the per-browser UI state. The page reloads after the reset.", + "uiStateReset": "Reset", + "uiStateResetLabel": "Reset browser layout", + "uiStateTitle": "UI state", + "updateAvailable": "Update available", + "updatesApply": "Apply update", + "updatesApplyConfirmDesktop": "Kandev will install {{version}} and restart the desktop app.", + "updatesApplyConfirmService": "Kandev will update to {{version}}, reinstall the user service, and restart it.", + "updatesApplyConfirmTitle": "Apply update?", + "updatesCheckFailed": "Update check failed", + "updatesCheckNow": "Check now", + "updatesCurrentVersion": "Current version", + "updatesDownloading": "Downloading update ({{downloaded}} bytes)...", + "updatesDownloadingOfTotal": "Downloading update ({{downloaded}} of {{total}} bytes)...", + "updatesInstalling": "Installing update...", + "updatesLastChecked": "Last checked {{at}}", + "updatesLatestRelease": "Latest release", + "updatesNever": "never", + "updatesNotificationsHint": "Notification preferences are managed in <2>Notifications.", + "updatesPageDescription": "Current vs latest release plus the full kandev changelog.", + "updatesReleaseNotes": "Release notes", + "updatesRetryAfter_one": "Already checked. Try again in {{count}}s.", + "updatesRetryAfter_other": "Already checked. Try again in {{count}}s.", + "updatesTitle": "Updates", + "updatesUpToDate": "Kandev is up to date.", + "usersAddUser": "Add user", + "usersColumnActions": "Actions", + "usersColumnEmail": "Email", + "usersColumnName": "Name", + "usersColumnRole": "Role", + "usersColumnStatus": "Status", + "usersConfirm": "Confirm", + "usersConfirmDisable": "Disable {{email}}? They will be signed out everywhere.", + "usersConfirmEnable": "Re-enable {{email}}?", + "usersConfirmRoleChange": "Change {{email}} to {{role}}?", + "usersDisable": "Disable", + "usersEmpty": "No users yet.", + "usersEnable": "Enable", + "usersInviteLink": "Invite link", + "usersLastAdminGuard": "This deployment must keep at least one active admin.", + "usersLoadFailed": "Failed to load users.", + "usersLoading": "Loading users...", + "usersMakeAdmin": "Make admin", + "usersMakeMember": "Make member", + "usersPageDescription": "Manage accounts, roles, and invite links for this instance.", + "usersRoleAdmin": "admin", + "usersRoleMember": "member", + "usersStatusActive": "active", + "usersStatusDisabled": "disabled", + "usersTakesEffectImmediately": "{{email}} — this takes effect immediately.", + "usersTitle": "Users", + "usersUpdateFailed": "Could not update user", + "versionCurrent": "Current", + "versionLatest": "Latest", + "versionTitle": "Version", + "versionViewUpdates": "View updates" } diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 8c2a3b7dd7..1407316473 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -375,7 +375,7 @@ "useAgentForNewTaskTitles": "Ũśē ţĥē àĝēńţ ƒōŕ ńēŵ ţàśķ ţĩţĺēś", "agentGeneratedTaskTitlesDisabledHint": "Ďĩśàƀĺēď ƀŷ ďēƒàũĺţ; śàvē ţĥĩś śēţţĩńĝ ƀēƒōŕē ōƥēńĩńĝ à ćŕēàţĩōń ďĩàĺōĝ.", "logsPageTitle": "Ĺōĝś", - "logsPageDescription": "Ďōŵńĺōàď à ƀōũńďēď ďĩàĝńōśţĩć ŹĨƤ ćōńţàĩńĩńĝ ƒŕōńţēńď àńď ƀàćķēńď ĺōĝś.", + "logsPageDescription": "Ćŕēàţē à ďĩàĝńōśţĩć ŹĨƤ ŵĩţĥ ƒŕōńţēńď àńď ƀàćķēńď ĺōĝś.", "diagnosticReviewBeforeSharing": "Ŕēvĩēŵ ƀēƒōŕē śĥàŕĩńĝ", "diagnosticReviewDescription": "Ţĥĩś ŹĨƤ ćōḿƀĩńēś ũƥ ţō ţĥŕēē ďàŷś ōƒ ƀàćķēńď ĺōĝś ŵĩţĥ ĺōćàĺĺŷ ŕēţàĩńēď ƒŕōńţēńď ćōńśōĺē ēvēńţś ƒŕōḿ ćōńńēćţēď ƀŕōŵśēŕś. Ĩţ ḿàŷ ćōńţàĩń ŨŔĹś, ćōńśōĺē àŕĝũḿēńţś, śţàćķś, ƥàţĥś, ŕũńţĩḿē ḿēţàďàţà, àńď ũśēŕ-vĩśĩƀĺē ēŕŕōŕś.", "diagnosticBundleTitle": "Ƒŕōńţēńď + ƀàćķēńď ďĩàĝńōśţĩć ĺōĝś", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index 5487b1aad4..9c9aafcf3a 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -1,170 +1,433 @@ { - "storageTitle": "Śţōŕàĝē", - "storageDescription": "Ŕēvĩēŵ ďĩśķ ũśē àńď ŕēćĺàĩḿ śƥàćē ƒŕōḿ Ķàńďēv-ōŵńēď ŵōŕķśƥàćēś, ćàćĥēś, àńď Ďōćķēŕ ŕēśōũŕćēś ŵĥēńēvēŕ ŷōũŕ ĩńśţàĺĺàţĩōń ńēēďś ĩţ.", - "storageLoadingData": "Ĺōàďĩńĝ śţōŕàĝē ďàţà…", - "storageSectionUnavailable": "Ţĥĩś śţōŕàĝē śēćţĩōń ćōũĺď ńōţ ƀē ĺōàďēď", - "storageAdoptionPending": "Ŵàĩţ ƒōŕ Ĝō ćàćĥē àďōƥţĩōń ţō ƒĩńĩśĥ.", - "storagePolicyLoadingBlock": "Ŵàĩţ ƒōŕ śţōŕàĝē śēţţĩńĝś ţō ƒĩńĩśĥ ĺōàďĩńĝ.", + "aboutArch": "Àŕćĥ", + "aboutBuildTime": "Ɓũĩĺď ţĩḿē", + "aboutCommit": "Ćōḿḿĩţ", + "aboutDocumentation": "Ďōćũḿēńţàţĩōń", + "aboutGoVersion": "Ĝō vēŕśĩōń", + "aboutLoading": "Ĺōàďĩńĝ...", + "aboutOs": "ŌŚ", + "aboutPageDescription": "Vēŕśĩōń, ƀũĩĺď ḿēţàďàţà, àńď ĺĩńķś.", + "aboutReportIssue": "Ŕēƥōŕţ àń ĩśśũē", + "aboutTitle": "Àƀōũţ ķàńďēv", + "aboutVersion": "Vēŕśĩōń", + "actionDone": "Ďōńē", + "actionFailed": "Ƒàĩĺēď", + "actionRunning": "Ŕũńńĩńĝ...", + "backupsColumnActions": "Àćţĩōńś", + "backupsColumnCreated": "Ćŕēàţēď", + "backupsColumnKind": "Ķĩńď", + "backupsColumnName": "Ńàḿē", + "backupsColumnSize": "Śĩźē", + "backupsCreate": "Ćŕēàţē śńàƥśĥōţ", + "backupsCreateFailed": "Ćŕēàţē śńàƥśĥōţ ƒàĩĺēď", + "backupsCreateTimeout": "Ćŕēàţē śńàƥśĥōţ ďĩď ńōţ ƒĩńĩśĥ ŵĩţĥĩń {{seconds}}ś", + "backupsCreating": "Ćŕēàţĩńĝ...", + "backupsDeleteFailed": "Ďēĺēţē ƒàĩĺēď", + "backupsDeleteLabel": "Ďēĺēţē {{name}}", + "backupsDownloadLabel": "Ďōŵńĺōàď {{name}}", + "backupsEmpty": "Ńō ƀàćķũƥś ŷēţ. Śńàƥśĥōţś ćŕēàţēď àũţōḿàţĩćàĺĺŷ ōń vēŕśĩōń ũƥĝŕàďē ōŕ ḿàńũàĺĺŷ vĩà ţĥē ƀũţţōń àƀōvē ŵĩĺĺ àƥƥēàŕ ĥēŕē.", + "backupsHelp": "À ƀàćķũƥ ĩś à ƒũĺĺ ćōƥŷ ōƒ Ķàńďēv'ś ďàţàƀàśē (ţàśķś, śēśśĩōńś, śēţţĩńĝś). Ķàńďēv àũţōḿàţĩćàĺĺŷ ćŕēàţēś ōńē ƀēƒōŕē ēvēŕŷ vēŕśĩōń ũƥĝŕàďē śō ŷōũ ćàń ŕōĺĺ ƀàćķ ĩƒ śōḿēţĥĩńĝ ĝōēś ŵŕōńĝ. Ŷōũ ćàń àĺśō ćŕēàţē ōńē ḿàńũàĺĺŷ ƀēƒōŕē ŕĩśķŷ ōƥēŕàţĩōńś ĺĩķē ƒàćţōŕŷ ŕēśēţ.", + "backupsKindAuto": "àũţō", + "backupsKindManual": "ḿàńũàĺ", + "backupsLoading": "Ĺōàďĩńĝ ƀàćķũƥś...", + "backupsPageDescription": "{{command}} śńàƥśĥōţś śţōŕēď ũńďēŕ {{path}}.", + "backupsRestoreLabel": "Ŕēśţōŕē {{name}}", + "backupsRetention_one": "Ōńĺŷ ţĥē ĺàţēśţ ƀàćķũƥ ĩś ķēƥţ ōń ďĩśķ.", + "backupsRetention_other": "Ōńĺŷ ţĥē ĺàţēśţ {{count}} ƀàćķũƥś àŕē ķēƥţ ōń ďĩśķ.", + "backupsTitle": "Ɓàćķũƥś", + "changelogEmpty": "Ńō ćĥàńĝēĺōĝ ēńţŕĩēś àvàĩĺàƀĺē.", + "changelogViewOnGitHub": "Vĩēŵ ōń ĜĩţĤũƀ", + "close": "Ćĺōśē", + "createUserDescription": "Ćŕēàţēś àń àććōũńţ ďĩŕēćţĺŷ ŵĩţĥ à ƥàśśŵōŕď ŷōũ śēţ. Ƥŕēƒēŕ àń ĩńvĩţē ĺĩńķ ŵĥēń ţĥē ũśēŕ śĥōũĺď ćĥōōśē ţĥēĩŕ ōŵń ƥàśśŵōŕď.", + "createUserDisplayName": "Ďĩśƥĺàŷ ńàḿē", + "createUserEmail": "Ēḿàĩĺ", + "createUserFailed": "Ćōũĺď ńōţ ćŕēàţē ũśēŕ.", + "createUserPassword": "Ƥàśśŵōŕď", + "createUserRole": "Ŕōĺē", + "createUserSubmit": "Ćŕēàţē ũśēŕ", + "createUserSubmitting": "Ćŕēàţĩńĝ...", + "createUserTitle": "Àďď à ũśēŕ", + "databaseDriver": "Ďŕĩvēŕ", + "databaseFactoryReset": "Ƒàćţōŕŷ ŕēśēţ", + "databaseFactoryResetHelp": "Ŵĩƥēś ÀĹĹ Ķàńďēv ďàţà: ţàśķś, śēśśĩōńś, śēţţĩńĝś, ŕēƥōśĩţōŕĩēś, àńď ŵōŕķţŕēēś. Ţĥēŕē ĩś ńō ũńďō. À ƀàćķũƥ ĩś ţàķēń ƒĩŕśţ, ƀũţ ŕēśţōŕĩńĝ ĩţ ŕēqũĩŕēś ũśĩńĝ ţĥē Ɓàćķũƥś ƥàĝē àƒţēŕ ţĥē ŕēśēţ. Ōńĺŷ ũśē ţĥĩś ĩƒ ŷōũ ŵàńţ ţō śţàŕţ ōvēŕ ƒŕōḿ śćŕàţćĥ.", + "databaseLastBackup": "Ĺàśţ ƀàćķũƥ", + "databaseLoading": "Ĺōàďĩńĝ ďàţàƀàśē śţàţś...", + "databaseMaintenanceUnavailable": "ŚQĹĩţē ḿàĩńţēńàńćē àćţĩōńś àŕē ũńàvàĩĺàƀĺē ƒōŕ {{driver}}.", + "databaseNever": "Ńēvēŕ", + "databaseOptimize": "Ōƥţĩḿĩźē", + "databaseOptimizeHelp": "Àśķś ţĥē ďàţàƀàśē ţō ŕēƒŕēśĥ ĩţś ĩńţēŕńàĺ ĩńďēxēś śō ćōḿḿōń qũēŕĩēś śţàŷ ƒàśţ. Ŕũń ĩţ àƒţēŕ ďēĺēţĩńĝ à ĺōţ ōƒ ďàţà ōŕ ĩƒ ţĥē àƥƥ ƒēēĺś śĺũĝĝĩśĥ. Qũĩćķ àńď śàƒē - ńō ďàţà ĩś ćĥàńĝēď.", + "databasePageDescription": "Ďàţàƀàśē ďŕĩvēŕ, śĩźē, àńď àvàĩĺàƀĺē ḿàĩńţēńàńćē ćōńţŕōĺś.", + "databasePath": "Ƥàţĥ", + "databaseRunOptimize": "Ŕũń ōƥţĩḿĩźē", + "databaseRunVacuum": "Ŕũń vàćũũḿ", + "databaseSchemaVersion": "Śćĥēḿà vēŕśĩōń", + "databaseSize": "Śĩźē", + "databaseSizeLong": "Ďàţàƀàśē śĩźē", + "databaseTitle": "Ďàţàƀàśē", + "databaseVacuum": "Vàćũũḿ", + "databaseVacuumHelp": "Ŕēćĺàĩḿś ũńũśēď śƥàćē ĩńśĩďē ţĥē ďàţàƀàśē ƒĩĺē. Ţĥē ƒĩĺē ćàń ĝŕōŵ ōvēŕ ţĩḿē àś ŷōũ ďēĺēţē ţàśķś ōŕ śēśśĩōńś; vàćũũḿ ćōḿƥàćţś ĩţ ƀàćķ ďōŵń. Śàƒē ţō ŕũń àńŷţĩḿē - ĩţ ďōēś ńōţ ćĥàńĝē àńŷ ďàţà. Ũśē ĩţ ōńćē ĩń à ŵĥĩĺē ĩƒ ţĥē ďàţàƀàśē śĩźē ĺōōķś ĺàŕĝē.", + "databaseWal": "ŴÀĹ", + "databaseWalHelp": "Ŵŕĩţē-Àĥēàď Ĺōĝ. ŚQĹĩţē ŵŕĩţēś ńēŵ ćĥàńĝēś ţō ţĥĩś ćōḿƥàńĩōń ƒĩĺē ƒĩŕśţ àńď ḿēŕĝēś ţĥēḿ ĩńţō ţĥē ḿàĩń ďàţàƀàśē ōvēŕ ţĩḿē. À ńōń-źēŕō ŴÀĹ śĩźē ĩś ńōŕḿàĺ - ĩţ ţēḿƥōŕàŕĩĺŷ ĝŕōŵś ũńďēŕ ĺōàď àńď śĥŕĩńķś ƀàćķ ďũŕĩńĝ ćĥēćķƥōĩńţś. Ŕũńńĩńĝ vàćũũḿ ŵĩĺĺ ŕēśēţ ĩţ.", + "databaseWhatIs": "Ŵĥàţ ĩś {{label}}?", + "desktopUpdateFailed": "Ďēśķţōƥ ũƥďàţē ƒàĩĺēď", + "diskCalculating": "Ćàĺćũĺàţĩńĝ...", + "diskColumnPath": "Ƥàţĥ", + "diskColumnSize": "Śĩźē", + "diskComputedAt": "Ćōḿƥũţēď àţ {{at}}", + "diskOpen": "Ōƥēń", + "diskRefresh": "Ŕēƒŕēśĥ", + "diskRefreshed": "Ŕēƒŕēśĥēď", + "diskRefreshHelp": "Ŵàĺķś ēvēŕŷ ďĩŕēćţōŕŷ ĩńśĩďē ţĥē ďàţà ƒōĺďēŕ àńď àďďś ũƥ ţĥē ƒĩĺē śĩźēś. Ŕēśũĺţś àŕē ćàćĥēď ƒōŕ à ćōũƥĺē ōƒ ĥōũŕś; ćĺĩćķ ţō ćōḿƥũţē ƒŕēśĥ ńũḿƀēŕś ŕĩĝĥţ ńōŵ. Ćàń ţàķē à ƒēŵ śēćōńďś ōń ĺàŕĝē ŵōŕķśƥàćēś.", + "diskRefreshing": "Ŕēƒŕēśĥĩńĝ...", + "diskRowBackups": "Ɓàćķũƥś", + "diskRowDataDir": "Ďàţà ďĩŕēćţōŕŷ", + "diskRowQuickChat": "Qũĩćķ ćĥàţ", + "diskRowRepos": "Ŕēƥōśĩţōŕĩēś", + "diskRowSessions": "Śēśśĩōńś", + "diskRowTasks": "Ţàśķś", + "diskRowWorktrees": "Ŵōŕķţŕēēś", + "diskTotal": "Ţōţàĺ", + "diskUnmeasuredDirs": "Śōḿē ďĩŕēćţōŕĩēś ćōũĺď ńōţ ƀē ḿēàśũŕēď:", + "diskUsageTitle": "Ďĩśķ Ũśàĝē", + "dismiss": "Ďĩśḿĩśś", + "factoryResetCompleteBody": "Ķàńďēv'ś ďàţà ĥàś ƀēēń ŵĩƥēď. Qũĩţ àńď ŕēĺàũńćĥ ţĥē àƥƥ ţō śţàŕţ ƒŕēśĥ - ţĥē ŕũńńĩńĝ ƀàćķēńď śţĩĺĺ ĥōĺďś ćōńńēćţĩōńś ţō ţĥē ńōŵ-ēḿƥţŷ ďàţàƀàśē àńď ŵĩĺĺ ńōţ ŕēƀũĩĺď ĩţś ćàćĥēś ũńţĩĺ ŷōũ ŕēśţàŕţ. Ţĥē ƥŕē-ŕēśēţ śńàƥśĥōţ ĩś àvàĩĺàƀĺē ōń ţĥē Ɓàćķũƥś ƥàĝē ĩƒ ŷōũ ńēēď ţō ŕōĺĺ ƀàćķ.", + "factoryResetCompleteTitle": "Ƒàćţōŕŷ ŕēśēţ ćōḿƥĺēţē", + "factoryResetFailed": "Ŕēśēţ ƒàĩĺēď", + "factoryResetPlaceholder": "Ţŷƥē {{token}} ţō ćōńƒĩŕḿ", + "factoryResetRequestFailed": "Ŕēśēţ ŕēqũēśţ ƒàĩĺēď", + "factoryResetSnapshot": "À śńàƥśĥōţ ĩś ćŕēàţēď àũţōḿàţĩćàĺĺŷ ũńďēŕ <1>{{path}} ƀēƒōŕē ţĥē ŵĩƥē ŕũńś, śō ŷōũ ćàń ŕēśţōŕē ƒŕōḿ ţĥē Ɓàćķũƥś ƥàĝē ĩƒ ŷōũ ćĥàńĝē ŷōũŕ ḿĩńď.", + "factoryResetTitle": "Ƒàćţōŕŷ Ŕēśēţ", + "factoryResetTypeToConfirm": "Ţŷƥē <1>{{token}} ţō ēńàƀĺē ţĥē ćōńƒĩŕḿ ƀũţţōń. Àƒţēŕ ţĥē ŵĩƥē ćōḿƥĺēţēś ŷōũ'ĺĺ ƀē àśķēď ţō qũĩţ àńď ŕēĺàũńćĥ Ķàńďēv - ţĥē ƀàćķēńď ďōēś ńōţ àũţō-ŕēśţàŕţ.", + "factoryResetWipes": "Ţĥĩś ŵĩƥēś ţĥē ēńţĩŕē ķàńďēv ĩńśţàĺĺ: ďàţàƀàśē, ŵōŕķţŕēēś, ŕēƥō ćĺōńēś, śēśśĩōńś, ţàśķś, àńď qũĩćķ-ćĥàţ śēśśĩōńś.", + "factoryResetWiping": "Ŵĩƥĩńĝ ďàţà...", + "featureToggleDebug": "Ďēƀũĝ", + "featureToggleEnv": "Ēńv: {{envVar}}", + "featureToggleEnvControlled": "Ćōńţŕōĺĺēď ƀŷ ĺàũńćĥ ēńvĩŕōńḿēńţ", + "featureToggleExperimental": "Ēxƥēŕĩḿēńţàĺ", + "featureTogglePendingRestart": "Ƥēńďĩńĝ ŕēśţàŕţ", + "featureToggleRequiresRestart": "Ŕēqũĩŕēś ŕēśţàŕţ", + "featureTogglesLoadError": "Ƒēàţũŕē ţōĝĝĺēś ćōũĺď ńōţ ƀē ĺōàďēď.", + "featureTogglesLoadFailed": "Ƒàĩĺēď ţō ĺōàď ƒēàţũŕē ţōĝĝĺēś", + "featureTogglesLoading": "Ĺōàďĩńĝ ƒēàţũŕē ţōĝĝĺēś...", + "featureToggleSource": "Śōũŕćē: {{source}}", + "featureToggleSourceDefault": "Ďēƒàũĺţ", + "featureToggleSourceEnv": "Ēńvĩŕōńḿēńţ", + "featureToggleSourceOverride": "Śàvēď ōvēŕŕĩďē", + "featureTogglesPageDescription": "Ḿàńàĝē Ķàńďēv ƒēàţũŕē àńď ďĩàĝńōśţĩć śŵĩţćĥēś.", + "featureTogglesRetry": "Ŕēţŕŷ", + "featureTogglesSaved": "Ƒēàţũŕē ţōĝĝĺēś śàvēď", + "featureToggleSwitchLabel": "Ţōĝĝĺē {{label}}", + "featureToggleUseDefault": "Ũśē ďēƒàũĺţ", + "healthAllPass": "Àĺĺ śŷśţēḿ ćĥēćķś ƥàśś.", + "healthHealthy": "Ĥēàĺţĥŷ", + "healthIssue": "Ĩśśũē", + "healthIssueCount_one": "{{count}} ĩśśũē", + "healthIssueCount_other": "{{count}} ĩśśũēś", + "healthLoading": "Ĺōàďĩńĝ ĥēàĺţĥ ćĥēćķś...", + "healthPassing": "Ƥàśśĩńĝ", + "healthSeverityError": "Ēŕŕōŕ", + "healthSeverityInfo": "Ĩńƒō", + "healthSeverityWarning": "Ŵàŕńĩńĝ", + "healthSystemChecks": "Śŷśţēḿ ćĥēćķś", + "healthTitle": "Ĥēàĺţĥ", + "healthWhatsMonitored": "Ŵĥàţ'ś ḿōńĩţōŕēď", + "inviteCopied": "Ĩńvĩţē ĺĩńķ ćōƥĩēď", + "inviteCopy": "Ćōƥŷ ĩńvĩţē ĺĩńķ", + "inviteCreate": "Ćŕēàţē ĩńvĩţē ĺĩńķ", + "inviteCreating": "Ćŕēàţĩńĝ...", + "inviteDescription": "Ĝēńēŕàţēś à ōńē-ţĩḿē ĩńvĩţē ĺĩńķ. Ĺēàvē ţĥē ēḿàĩĺ ƀĺàńķ ţō ćŕēàţē à ĺĩńķ àńŷōńē ćàń ũśē ţō śĩĝń ũƥ ŵĩţĥ ţĥē śēĺēćţēď ŕōĺē; śēţ àń ēḿàĩĺ ţō ƥĩń ţĥē ĩńvĩţē ţō ţĥàţ àďďŕēśś.", + "inviteDone": "Ďōńē", + "inviteEmailOptional": "Ēḿàĩĺ (ōƥţĩōńàĺ)", + "inviteFailed": "Ćōũĺď ńōţ ćŕēàţē ĩńvĩţē.", + "inviteReadyDescription": "Śĥàŕē ţĥĩś ĺĩńķ ŵĩţĥ ţĥē ĩńvĩţēē. Ĩţ ĩś śĥōŵń ōńĺŷ ōńćē — ćōƥŷ ĩţ ńōŵ.", + "inviteReadyTitle": "Ĩńvĩţē ĺĩńķ ŕēàďŷ", + "inviteTitle": "Ĩńvĩţē à ũśēŕ", + "inviteUrlLabel": "Ĩńvĩţē ĺĩńķ", + "jobStateDone": "Ďōńē", + "jobStateFailed": "Ƒàĩĺēď", + "jobStateQueued": "Qũēũēď", + "jobStateRunning": "Ŕũńńĩńĝ", + "licensesCount": "{{visible}} ōƒ {{total}}", + "licensesEmpty": "Ńō ēńţŕĩēś ḿàţćĥ ţĥē ƒĩĺţēŕ.", + "licensesFilterPlaceholder": "Ƒĩĺţēŕ ƀŷ ńàḿē, ĺĩćēńśē, ōŕ ēćōśŷśţēḿ...", + "licensesPageDescription": "Ōƥēń-śōũŕćē ĺĩćēńśēś ƒōŕ ēvēŕŷ ńƥḿ àńď Ĝō ďēƥēńďēńćŷ śĥĩƥƥēď ŵĩţĥ ķàńďēv.", + "licensesSource": "śōũŕćē", + "licensesStaleWarning": "Ĝō ĺĩćēńśē ēńţŕĩēś ŵēŕē ŕēũśēď ƒŕōḿ ţĥē ćōḿḿĩţţēď ḿàńĩƒēśţ ƀēćàũśē ĝēńēŕàţĩōń ƒàĩĺēď. Ŕēĝēńēŕàţē ĺĩćēńśēś ŵĥēń ĝō-ĺĩćēńśēś ĩś ĥēàĺţĥŷ ţō ŕēƒŕēśĥ Ĝō ďēƥēńďēńćŷ ďàţà.", + "licensesTitle": "Ţĥĩŕď-ƥàŕţŷ ĺĩćēńśēś", + "licensesToggleLabel": "Śĥōŵ ĺĩćēńśē ţēxţ ƒōŕ {{name}}", + "navAbout": "Àƀōũţ", + "navBackups": "Ɓàćķũƥś", + "navDatabase": "Ďàţàƀàśē", + "navFeatureToggles": "Ƒēàţũŕē Ţōĝĝĺēś", + "navLicenses": "Ĺĩćēńśēś", + "navLogs": "Ĺōĝś", + "navStorage": "Śţōŕàĝē", + "navUpdates": "Ũƥďàţēś", + "navUsers": "Ũśēŕś", + "reloadPage": "Ŕēĺōàď ƥàĝē", + "restartAction": "Ŕēśţàŕţ", + "restartManualHint": "Ŕēśţàŕţ ĩţ ƒŕōḿ ŷōũŕ ţēŕḿĩńàĺ ōŕ śēŕvĩćē ḿàńàĝēŕ ŵĥēń ćōńvēńĩēńţ.", + "restartPendingChanges": "Śàvēď ţōĝĝĺē ćĥàńĝēś ŵĩĺĺ àƥƥĺŷ ţĥē ńēxţ ţĩḿē Ķàńďēv śţàŕţś.", + "restartPhaseDoneBody": "Ţĥē ƀàćķēńď ĩś ŕũńńĩńĝ àĝàĩń. Ŕēĺōàď ţĥē ƥàĝē ţō ŕēćōńńēćţ ŵĩţĥ ţĥē ĺàţēśţ ƒēàţũŕē ţōĝĝĺē śţàţē.", + "restartPhaseDoneTitle": "Ķàńďēv ŕēśţàŕţēď", + "restartPhaseErrorBody": "Ţĥē ŕēśţàŕţ ćōũĺď ńōţ ƀē ćōḿƥĺēţēď.", + "restartPhaseErrorTitle": "Ŕēśţàŕţ ƒàĩĺēď", + "restartPhaseRestartingBody": "Ķàńďēv ĩś śţōƥƥĩńĝ àńď śţàŕţĩńĝ àĝàĩń. Ţĥĩś ƥàĝē ŵĩĺĺ ďēţēćţ ţĥē ńēŵ ƥŕōćēśś àũţōḿàţĩćàĺĺŷ.", + "restartPhaseRestartingTitle": "Ŕēśţàŕţĩńĝ Ķàńďēv", + "restartPhaseStartingBody": "Ƥŕēƥàŕĩńĝ ţĥē ĺōćàĺ śũƥēŕvĩśōŕ ŕēśţàŕţ ŕēqũēśţ.", + "restartPhaseStartingTitle": "Ŕēqũēśţĩńĝ ŕēśţàŕţ", + "restartRequired": "Ŕēśţàŕţ ŕēqũĩŕēď", + "restartStartFailed": "Ƒàĩĺēď ţō ŕēśţàŕţ Ķàńďēv", + "restartSupportDetails": "Ŕēśţàŕţ śũƥƥōŕţ ďēţàĩĺś", + "restartSupportedHelp": "Ŕēśţàŕţ ƒŕōḿ ţĥĩś ƥàĝē ĩś àvàĩĺàƀĺē ŵĥēń Ķàńďēv ĩś ŕũńńĩńĝ ũńďēŕ à śũƥƥōŕţēď ĺōćàĺ śũƥēŕvĩśōŕ.", + "restartTimedOut": "Ŕēśţàŕţ ĩś ţàķĩńĝ ĺōńĝēŕ ţĥàń ēxƥēćţēď. Ŕēƒŕēśĥ ţō ćĥēćķ ţĥē ćũŕŕēńţ śţàţũś.", + "restartUnsupportedHelp": "Àũţōḿàţĩć ŕēśţàŕţ ĩś ńōţ àvàĩĺàƀĺē ĩń ďēƥĺōŷ ƥŕēvĩēŵś, ũńḿàńàĝēď ţēŕḿĩńàĺ ŕũńś, ōŕ ĺàũńćĥ ḿōďēś ŵĩţĥōũţ à ŕēśţàŕţ śũƥēŕvĩśōŕ.", + "restoreAction": "Ŕēśţōŕē", + "restoreBody": "Ŕēśţōŕē <1>{{name}} ōvēŕ ţĥē ćũŕŕēńţ ďàţàƀàśē. Àƒţēŕ ţĥē śţàĝēď ćōƥŷ ĩś ĩń ƥĺàćē ŷōũ ŵĩĺĺ ƀē àśķēď ţō qũĩţ àńď ŕēĺàũńćĥ Ķàńďēv śō ţĥē ńēŵ ďàţà ĩś ĺōàďēď ƒŕēśĥ - ţĥē ƀàćķēńď ďōēś ńōţ àũţō-ŕēśţàŕţ.", + "restoreCompleteBody": "<0>{{name}} ĥàś ƀēēń ŵŕĩţţēń ōvēŕ ţĥē ćũŕŕēńţ ďàţàƀàśē. Qũĩţ àńď ŕēĺàũńćĥ Ķàńďēv ţō ĺōàď ţĥē ŕēśţōŕēď ďàţà - ţĥē ŕũńńĩńĝ ƀàćķēńď śţĩĺĺ ĥōĺďś ćōńńēćţĩōńś ţō ţĥē ƥŕēvĩōũś vēŕśĩōń àńď ŵĩĺĺ śēŕvē śţàĺē ŕōŵś ũńţĩĺ ŷōũ ŕēśţàŕţ.", + "restoreCompleteTitle": "Ŕēśţōŕē ćōḿƥĺēţē", + "restoreFailed": "Ŕēśţōŕē ƒàĩĺēď", + "restorePlaceholder": "Ţŷƥē {{token}} ţō ćōńƒĩŕḿ", + "restoreRequestFailed": "Ŕēśţōŕē ŕēqũēśţ ƒàĩĺēď", + "restoreTitle": "Ŕēśţōŕē śńàƥśĥōţ", + "restoreTypeToConfirm": "Ţŷƥē <1>{{token}} ţō ēńàƀĺē ţĥē ćōńƒĩŕḿ ƀũţţōń.", + "restoreWriting": "Ŵŕĩţĩńĝ ţĥē śńàƥśĥōţ ōvēŕ ţĥē ĺĩvē ďàţàƀàśē...", + "selfUpdateDone": "Ũƥďàţēď ţō {{version}}.", + "selfUpdateDoneUnknown": "Ũƥďàţēď ţō ţĥē ĺàţēśţ vēŕśĩōń.", + "selfUpdateFailed": "Ţĥē ũƥďàţē ƒàĩĺēď.", + "selfUpdateHelperFailed": "Ţĥē ũƥďàţē ĥēĺƥēŕ ƒàĩĺēď ţō śţàŕţ. Ćĥēćķ ţĥē śēŕvĩćē ĺōĝś.", + "selfUpdateInstalling": "Ďōŵńĺōàďĩńĝ àńď ĩńśţàĺĺĩńĝ {{version}}…", + "selfUpdateInstallingUnknown": "Ďōŵńĺōàďĩńĝ àńď ĩńśţàĺĺĩńĝ ţĥē ńēŵ vēŕśĩōń…", + "selfUpdateKeepOpen": "Ķēēƥ ţĥĩś ƥàĝē ōƥēń. Ĩţ ŵĩĺĺ ŕēƒŕēśĥ àũţōḿàţĩćàĺĺŷ ŵĥēń ţĥē ũƥďàţē ƒĩńĩśĥēś.", + "selfUpdateRestarting": "Ŕēśţàŕţĩńĝ Ķàńďēv — ţĥĩś ćàń ţàķē ũƥ ţō à ḿĩńũţē.", + "selfUpdateStartFailed": "Ƒàĩĺēď ţō śţàŕţ ţĥē ũƥďàţē", + "selfUpdateStarting": "Śţàŕţĩńĝ ũƥďàţē ţō {{version}}…", + "selfUpdateStartingUnknown": "Śţàŕţĩńĝ ũƥďàţē ţō ţĥē ńēŵ vēŕśĩōń…", + "selfUpdateTimedOut": "Ũƥďàţē ĩś ţàķĩńĝ ĺōńĝēŕ ţĥàń ēxƥēćţēď. Ŕēƒŕēśĥ ţō ćĥēćķ ţĥē ćũŕŕēńţ vēŕśĩōń.", + "settingsRouteNotPorted": "Ţĥĩś śēţţĩńĝś ŕōũţē ĩś ĥàńďĺēď ƀŷ ţĥē ŚƤÀ śĥēĺĺ, ƀũţ ĩţś ďēďĩćàţēď ćĺĩēńţ ƥàĝē ĩś śţĩĺĺ ƀēĩńĝ ƥōŕţēď: <1>{{pathname}}", + "statusPageDescription": "Ĥēàĺţĥ ćĥēćķś, ďĩśķ ũśàĝē, àńď vēŕśĩōń śũḿḿàŕŷ.", + "storageAcknowledgeDaemon": "Àćķńōŵĺēďĝē ďàēḿōń", + "storageAcknowledgeDedicatedFirst": "Àćķńōŵĺēďĝē à ďēďĩćàţēď Ďōćķēŕ ďàēḿōń ƒĩŕśţ.", + "storageActionFailed": "Śţōŕàĝē àćţĩōń ƒàĩĺēď", "storageActionPending": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ śţōŕàĝē àćţĩōń ţō ƒĩńĩśĥ.", - "storageActionsTitle": "Ŕēćĺàĩḿ ďĩśķ śƥàćē śàƒēĺŷ", "storageActionsDescription": "Àńàĺŷźē ƒōŕ à ŕēàď-ōńĺŷ śńàƥśĥōţ, ōŕ ŕũń ţĥē ēńàƀĺēď ćĺēàńũƥ ŕũĺēś ŵĥēń ŷōũ ŵàńţ ţō ŕēćōvēŕ śƥàćē ĩḿḿēďĩàţēĺŷ.", - "storageAnalyze": "Àńàĺŷźē", - "storageAnalyzing": "Àńàĺŷźĩńĝ...", + "storageActionsTitle": "Ŕēćĺàĩḿ ďĩśķ śƥàćē śàƒēĺŷ", + "storageAdoptCache": "Àďōƥţ ćàćĥē", + "storageAdoptDialogDescription": "Ķàńďēv ŵĩĺĺ ƀē àĺĺōŵēď ţō ŕōţàţē àńď qũàŕàńţĩńē ţĥē ēxĩśţĩńĝ ćàćĥē àţ {{path}}. Ţĥĩś ƥàţĥ ḿũśţ ƀē àƀśōĺũţē àńď ōń ţĥē śàḿē ƒĩĺēśŷśţēḿ àś Ķàńďēv ţŕàśĥ.", + "storageAdoptDialogFallbackPath": "ţĥē śēĺēćţēď ƥàţĥ", + "storageAdoptDialogTitle": "Àďōƥţ àń ēxţēŕńàĺ Ĝō ƀũĩĺď ćàćĥē", + "storageAdoptionPending": "Ŵàĩţ ƒōŕ Ĝō ćàćĥē àďōƥţĩōń ţō ƒĩńĩśĥ.", "storageAnalysisComplete": "Àńàĺŷśĩś ćōḿƥĺēţē", + "storageAnalysisDescription": "À ŕēàď-ōńĺŷ ƀŕēàķďōŵń ōƒ ćũŕŕēńţ ũśàĝē àńď ŕēćĺàĩḿàƀĺē śƥàćē. Ŕũń Àńàĺŷźē ōććàśĩōńàĺĺŷ ţō ŕēƒŕēśĥ ţĥēśē ēśţĩḿàţēś; ĩţ ńēvēŕ ďēĺēţēś ōŕ ḿōvēś àńŷţĥĩńĝ.", "storageAnalysisFailed": "Àńàĺŷśĩś ƒàĩĺēď", "storageAnalysisRunning": "Śţōŕàĝē àńàĺŷśĩś ĩś śţĩĺĺ ŕũńńĩńĝ.", - "storageRunNow": "Ŕũń ńōŵ", + "storageAnalysisTitle": "Śţōŕàĝē àńàĺŷśĩś", + "storageAnalyze": "Àńàĺŷźē", + "storageAnalyzing": "Àńàĺŷźĩńĝ...", + "storageBuildCacheKeepHelp": "Ďōćķēŕ ķēēƥś àƥƥŕōxĩḿàţēĺŷ ţĥĩś ḿũćĥ ƀũĩĺď ćàćĥē ŵĥēń ƥŕũńĩńĝ ēĺĩĝĩƀĺē ŕēćōŕďś. À ĺàŕĝēŕ vàĺũē ƥŕēśēŕvēś ḿōŕē ŕēũśàƀĺē ƀũĩĺď ĺàŷēŕś ƀũţ ŕēćĺàĩḿś ĺēśś ďĩśķ śƥàćē.", + "storageBuildCacheKeepLabel": "Ɓũĩĺď ćàćĥē ţō ŕēţàĩń (ĜƁ)", + "storageBuildCacheUnusedHelp": "Ōńĺŷ ƀũĩĺď ćàćĥē ŕēćōŕďś ōĺďēŕ ţĥàń ţĥĩś ũńũśēď-àĝē ţĥŕēśĥōĺď àŕē ēĺĩĝĩƀĺē. Ĩńćŕēàśĩńĝ ĩţ ƥŕōţēćţś ŕēćēńţ ƀũĩĺď ĺàŷēŕś ƒōŕ ĺōńĝēŕ.", + "storageBuildCacheUnusedLabel": "Ɓũĩĺď ćàćĥē ḿũśţ ƀē ũńũśēď ƒōŕ (ĥōũŕś)", + "storageBusyForceHint": "Ŕũńńĩńĝ ćĺēàńũƥ àńŷŵàŷ ḿàŷ ďĩśŕũƥţ ţĥĩś àćţĩvē ŵōŕķ.", + "storageBusyIntro": "Ćĺēàńũƥ ḿàŷ ďĩśŕũƥţ ţĥē ƒōĺĺōŵĩńĝ ŵōŕķ:", + "storageBusyTitle": "Śţōŕàĝē ćĺēàńũƥ ƒōũńď àćţĩvē Ķàńďēv ŵōŕķ", + "storageCheckIntervalHelp": "Ĥōŵ ōƒţēń Ķàńďēv ćĥēćķś ŵĥēţĥēŕ śćĥēďũĺēď ḿàĩńţēńàńćē ĩś ďũē. À ćĥēćķ ţĥàţ ƒĩńďś Ķàńďēv ƀũśŷ ĩś śķĩƥƥēď àńď ţŕĩēď àĝàĩń àţ ţĥē ńēxţ ĩńţēŕvàĺ.", + "storageCheckIntervalLabel": "Ćĥēćķ ēvēŕŷ (ĥōũŕś)", + "storageCleanContainersAria": "Ćĺēàń Ķàńďēv ćōńţàĩńēŕś", + "storageCleanDockerBuildCacheAria": "Ćĺēàń Ďōćķēŕ ƀũĩĺď ćàćĥē", + "storageCleanGoCache": "Ćĺēàń Ĝō ćàćĥē", "storageCleaning": "Ćĺēàńĩńĝ...", + "storageCleanOrphanWorkspacesAria": "Ćĺēàń ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś", + "storageCleanUnusedImagesAria": "Ćĺēàń ũńũśēď Ďōćķēŕ ĩḿàĝēś", "storageCleanupComplete": "Ćĺēàńũƥ ćōḿƥĺēţē", "storageCleanupFailed": "Ćĺēàńũƥ ƒàĩĺēď", "storageCleanupRunning": "Śţōŕàĝē ćĺēàńũƥ ĩś śţĩĺĺ ŕũńńĩńĝ.", - "storageActionFailed": "Śţōŕàĝē àćţĩōń ƒàĩĺēď", - "storageBusyTitle": "Śţōŕàĝē ćĺēàńũƥ ƒōũńď àćţĩvē Ķàńďēv ŵōŕķ", - "storageBusyIntro": "Ćĺēàńũƥ ḿàŷ ďĩśŕũƥţ ţĥē ƒōĺĺōŵĩńĝ ŵōŕķ:", - "storageBusyForceHint": "Ŕũńńĩńĝ ćĺēàńũƥ àńŷŵàŷ ḿàŷ ďĩśŕũƥţ ţĥĩś àćţĩvē ŵōŕķ.", - "storageRunAnyway": "Ŕũń àńŷŵàŷ", - "storageAnalysisTitle": "Śţōŕàĝē àńàĺŷśĩś", - "storageAnalysisDescription": "À ŕēàď-ōńĺŷ ƀŕēàķďōŵń ōƒ ćũŕŕēńţ ũśàĝē àńď ŕēćĺàĩḿàƀĺē śƥàćē. Ŕũń Àńàĺŷźē ōććàśĩōńàĺĺŷ ţō ŕēƒŕēśĥ ţĥēśē ēśţĩḿàţēś; ĩţ ńēvēŕ ďēĺēţēś ōŕ ḿōvēś àńŷţĥĩńĝ.", - "storageDockerUnavailableBadge": "Ďōćķēŕ ũńàvàĩĺàƀĺē", - "storageLastAnalyzed": "Ĺàśţ àńàĺŷźēď {{time}}", - "storageTotalCounted": "Ţōţàĺ ćōũńţēď: {{size}}", - "storageTotalPartial": "Ƥàŕţĩàĺ ḿēàśũŕēḿēńţ", - "storageUnavailableValue": "Ũńàvàĩĺàƀĺē", - "storageTaskWorkspaces": "Ţàśķ ŵōŕķśƥàćēś", - "storageWorkspacesDetail": "{{reclaimable}} ŕēćĺàĩḿàƀĺē àƒţēŕ ţĥē ĝŕàćē ƥēŕĩōď · {{active}} àćţĩvē", - "storageQuarantinedResources": "Qũàŕàńţĩńēď ŕēśōũŕćēś", - "storageQuarantineUnmeasured": "Qũàŕàńţĩńē ũśàĝē ćōũĺď ńōţ ƀē ḿēàśũŕēď", - "storageQuarantineMovedAside_one": "{{count}} ĩţēḿ ḿōvēď àśĩďē ƒōŕ ŕēćōvēŕŷ ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń", - "storageQuarantineMovedAside_other": "{{count}} ĩţēḿś ḿōvēď àśĩďē ƒōŕ ŕēćōvēŕŷ ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń", - "storageDockerUnmeasured": "Ďōćķēŕ ũśàĝē ćōũĺď ńōţ ƀē ḿēàśũŕēď", - "storageKandevContainers": "Ķàńďēv ćōńţàĩńēŕś", - "storageManagedContainerCount_one": "{{count}} ḿàńàĝēď ćōńţàĩńēŕ", - "storageManagedContainerCount_other": "{{count}} ḿàńàĝēď ćōńţàĩńēŕś", - "storageGoBuildCache": "Ĝō ƀũĩĺď ćàćĥē", - "storageUserGoBuildCache": "Ũśēŕ Ĝō ƀũĩĺď ćàćĥē", - "storageDockerImageLayers": "Ďōćķēŕ ĩḿàĝē ĺàŷēŕś", - "storageDefaultDockerHost": "Ďēƒàũĺţ Ďōćķēŕ ĥōśţ", - "storageDockerBuildCache": "Ďōćķēŕ ƀũĩĺď ćàćĥē", - "storageUnusedDockerImages": "Ũńũśēď Ďōćķēŕ ĩḿàĝēś", - "storageUnusedImagesDetail": "Ũńũśēď ƀŷ ēvēŕŷ ćōńţàĩńēŕ àńď ōĺďēŕ ţĥàń ţĥē ćōńƒĩĝũŕēď àĝē", - "storageCleanGoCache": "Ćĺēàń Ĝō ćàćĥē", - "storageGoCacheNotOwned": "Ōńĺŷ à Ķàńďēv-ōŵńēď Ĝō ƀũĩĺď ćàćĥē ćàń ƀē ćĺēàńēď.", - "storageGoCacheBelowLimit": "Ţĥē Ĝō ƀũĩĺď ćàćĥē ĩś ƀēĺōŵ ĩţś ćōńƒĩĝũŕēď śĩźē ĺĩḿĩţ.", - "storagePolicyTitle": "Ḿàĩńţēńàńćē ƥōĺĩćŷ", - "storagePolicyDescription": "Ćĥōōśē ŵĥàţ Ķàńďēv ḿàŷ ŕēćĺàĩḿ àũţōḿàţĩćàĺĺŷ àńď ţĥē śàƒēţŷ ĺĩḿĩţś ĩţ ḿũśţ ƒōĺĺōŵ.", - "storageMoreInformationAbout": "Ḿōŕē ĩńƒōŕḿàţĩōń àƀōũţ {{label}}", - "storageScheduleTitle": "Śćĥēďũĺē", - "storageScheduleDescription": "Ćōńţŕōĺś ŵĥēń àũţōḿàţĩć ḿàĩńţēńàńćē ĩś àĺĺōŵēď ţō śţàŕţ. Ḿàńũàĺ àćţĩōńś ŕēḿàĩń àvàĩĺàƀĺē ŵĥēń śćĥēďũĺĩńĝ ĩś ōƒƒ.", - "storageScheduledMaintenance": "Śćĥēďũĺēď ḿàĩńţēńàńćē", - "storageScheduledMaintenanceDescription": "Ƥēŕĩōďĩćàĺĺŷ ŕēćĺàĩḿ ďĩśķ śƥàćē ũśĩńĝ ţĥē ēńàƀĺēď ŕēśōũŕćē ŕũĺēś.", - "storageScheduledMaintenanceHelp": "Ŵĥēń ēńàƀĺēď, Ķàńďēv ćĥēćķś ţĥĩś ƥōĺĩćŷ àţ ţĥē ćōńƒĩĝũŕēď ĩńţēŕvàĺ àńď śţàŕţś ōńĺŷ àƒţēŕ Ķàńďēv ĥàś ƀēēń ĩďĺē ƒōŕ ţĥē ŕēqũĩŕēď ƥēŕĩōď. Ţũŕńĩńĝ ĩţ ōƒƒ ďōēś ńōţ ďĩśàƀĺē Àńàĺŷźē ōŕ Ŕũń ńōŵ.", - "storageCheckIntervalLabel": "Ćĥēćķ ēvēŕŷ (ĥōũŕś)", - "storageCheckIntervalHelp": "Ĥōŵ ōƒţēń Ķàńďēv ćĥēćķś ŵĥēţĥēŕ śćĥēďũĺēď ḿàĩńţēńàńćē ĩś ďũē. À ćĥēćķ ţĥàţ ƒĩńďś Ķàńďēv ƀũśŷ ĩś śķĩƥƥēď àńď ţŕĩēď àĝàĩń àţ ţĥē ńēxţ ĩńţēŕvàĺ.", - "storageIdleLabel": "Ŕēqũĩŕē ĩďĺē ƒōŕ (ḿĩńũţēś)", - "storageIdleHelp": "Śćĥēďũĺēď ćĺēàńũƥ śţàŕţś ōńĺŷ àƒţēŕ ńō ţàśķ, śĥēĺĺ ćōḿḿàńď, ţēśţ, śēţũƥ, ćĺēàńũƥ, ōŕ ĩḿàĝē ƀũĩĺď ĥàś ũśēď ḿàńàĝēď ŕēśōũŕćēś ƒōŕ ţĥĩś ĺōńĝ. Ŕũń ńōŵ ďōēś ńōţ ŵàĩţ ƒōŕ ţĥĩś ţĩḿēŕ, ƀũţ ĩţ śţĩĺĺ ŕēƒũśēś ţō ŕũń ŵĥĩĺē ŕēśōũŕćēś àŕē àćţĩvē.", - "storageWorkspacesTitle": "Ŵōŕķśƥàćēś àńď ćōńţàĩńēŕś", - "storageWorkspacesDescription": "Ŕēćĺàĩḿ ŕēśōũŕćēś ţĥàţ Ķàńďēv ćàń ƥōśĩţĩvēĺŷ ĩďēńţĩƒŷ àś ńō ĺōńĝēŕ ĩń ũśē.", - "storageOrphanWorkspaces": "Ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś", - "storageOrphanWorkspacesDescription": "Ḿōvē ćōńƒĩŕḿēď ōŕƥĥàń ŵōŕķśƥàćēś ţō qũàŕàńţĩńē.", - "storageOrphanWorkspacesHelp": "Ķàńďēv ōńĺŷ śēĺēćţś à ţàśķ ŵōŕķśƥàćē àƒţēŕ ĩńvēńţōŕŷ ćōńƒĩŕḿś ţĥàţ ńō àćţĩvē ţàśķ, ēńvĩŕōńḿēńţ, śēśśĩōń, ōŕ ƥŕōţēćţēď ŵōŕķţŕēē ũśēś ĩţ. Ţĥē ŵōŕķśƥàćē ĩś ḿōvēď ţō qũàŕàńţĩńē ƒĩŕśţ, ŵĥēŕē ĩţ ćàń ƀē ŕēśţōŕēď ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń.", - "storageCleanOrphanWorkspacesAria": "Ćĺēàń ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś", - "storageOrphanGraceLabel": "Ŵàĩţ ƀēƒōŕē ōŕƥĥàńĩńĝ (ĥōũŕś)", - "storageOrphanGraceHelp": "À ŵōŕķśƥàćē ḿũśţ ƀē ũńũśēď ƒōŕ àţ ĺēàśţ ţĥĩś ĺōńĝ ƀēƒōŕē ĩţ ćàń ƀē ćĺàśśĩƒĩēď àś àń ōŕƥĥàń. Ĩńćŕēàśĩńĝ ţĥĩś vàĺũē ķēēƥś ōĺď ŵōŕķśƥàćēś ĺōńĝēŕ ƀēƒōŕē ţĥēŷ ēńţēŕ qũàŕàńţĩńē.", + "storageClearEligible": "Ćĺēàŕ ēĺĩĝĩƀĺē", + "storageClearEligibleDialogTitle": "Ćĺēàŕ ēĺĩĝĩƀĺē qũàŕàńţĩńē", "storageContainersDescription": "Ŕēḿōvē śţōƥƥēď, ũńũśēď ćōńţàĩńēŕś ćŕēàţēď àńď ĺàƀēĺēď ƀŷ Ķàńďēv.", "storageContainersHelp": "Ōńĺŷ śţōƥƥēď ćōńţàĩńēŕś ĺàƀēĺēď àś Ķàńďēv-ḿàńàĝēď àŕē ćōńśĩďēŕēď, àńď ĩńvēńţōŕŷ ḿũśţ ćōńƒĩŕḿ ţĥēŷ àŕē ńō ĺōńĝēŕ ńēēďēď. Ŕũńńĩńĝ ćōńţàĩńēŕś àńď ũńŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕś àŕē ńēvēŕ ŕēḿōvēď ƀŷ ţĥĩś ōƥţĩōń.", - "storageCleanContainersAria": "Ćĺēàń Ķàńďēv ćōńţàĩńēŕś", - "storageGoCacheSectionDescription": "Ũśē àńď ţŕĩḿ à Ķàńďēv-ōŵńēď ćàćĥē ƒōŕ ńēŵ ĥōśţ-ĺōćàĺ ēxēćũţĩōńś.", - "storageManagedGoCache": "Ḿàńàĝēď Ĝō ćàćĥē", - "storageManagedGoCacheDescription": "Ńēŵ ĥōśţ-ĺōćàĺ ēxēćũţĩōńś ũśē {{path}}.", - "storageManagedGoCacheHelp": "Ŵĥēń ēńàƀĺēď, Ķàńďēv ĝĩvēś ńēŵ ĺōćàĺ ţàśķ ƥŕōćēśśēś à ďēďĩćàţēď Ĝō ƀũĩĺď ćàćĥē àńď ḿàŷ ŕōţàţē ĩţ ďũŕĩńĝ ḿàĩńţēńàńćē ŵĥēń ĩţ ēxćēēďś ţĥē ḿàxĩḿũḿ. Ţũŕńĩńĝ ţĥĩś ōƒƒ śţōƥś ũśĩńĝ ţĥē ḿàńàĝēď ćàćĥē ƒōŕ ńēŵ ēxēćũţĩōńś ƀũţ ďōēś ńōţ ďēĺēţē ĩţ.", - "storageEnableManagedGoCacheAria": "Ēńàƀĺē ḿàńàĝēď Ĝō ćàćĥē", - "storageGoCacheMaxLabel": "Ḿàxĩḿũḿ ćàćĥē śĩźē (ĜƁ)", - "storageGoCacheMaxHelp": "Ţĥĩś ĩś à ćĺēàńũƥ ţŕĩĝĝēŕ, ńōţ à ĥàŕď qũōţà. Ţĥē ćàćĥē ḿàŷ ĝŕōŵ ƥàśţ ţĥĩś śĩźē ŵĥĩĺē ţàśķś àŕē àćţĩvē. Ḿàĩńţēńàńćē ŕōţàţēś ţĥē ōŵńēď ćàćĥē ĩńţō qũàŕàńţĩńē àńď ŕēćŕēàţēś àń ēḿƥţŷ ćàćĥē àƒţēŕ ţĥē ĺĩḿĩţ ĩś ēxćēēďēď.", - "storageExternalGoCache": "Ēxţēŕńàĺ Ĝō ćàćĥē", - "storageExternalGoCacheHelp": "Àďōƥţĩōń ĝĩvēś Ķàńďēv ēxƥĺĩćĩţ ƥēŕḿĩśśĩōń ţō ŕōţàţē ţĥĩś ēxĩśţĩńĝ ćàćĥē. Ķàńďēv ńēvēŕ ćĺēàńś à ďēƒàũĺţ ũśēŕ ćàćĥē ōŕ àńōţĥēŕ ƥàţĥ ũńĺēśś ŷōũ àďōƥţ ţĥàţ ēxàćţ àƀśōĺũţē ƥàţĥ àńď ćōńƒĩŕḿ ţĥē ďēśţŕũćţĩvē àććēśś.", - "storageExternalGoCacheDescription": "Ōƥţĩōńàĺĺŷ àĺĺōŵ Ķàńďēv ţō ḿàĩńţàĩń àń ēxĩśţĩńĝ ĥōśţ ćàćĥē ōũţśĩďē ĩţś ḿàńàĝēď ƥàţĥ.", - "storageEnableGoCacheFirst": "Ēńàƀĺē ţĥē ḿàńàĝēď Ĝō ćàćĥē ƒĩŕśţ.", - "storageEnterCachePathFirst": "Ēńţēŕ àń àƀśōĺũţē ćàćĥē ƥàţĥ ƒĩŕśţ.", - "storageAdoptCache": "Àďōƥţ ćàćĥē", - "storageDockerTitle": "Ďōćķēŕ ćĺēàńũƥ", - "storageDockerDescription": "Ōƥţĩōńàĺ ďàēḿōń-ŵĩďē ćĺēàńũƥ. Ēńàƀĺē ĩţ ōńĺŷ ŵĥēń ţĥĩś Ďōćķēŕ ďàēḿōń ĩś ďēďĩćàţēď ţō Ķàńďēv.", - "storageDockerUnavailable": "Ďōćķēŕ ĩś ũńàvàĩĺàƀĺē ōń ţĥē ćōńƒĩĝũŕēď ĥōśţ.", - "storageDockerUnavailableNotice": "Ďōćķēŕ ĩś ũńàvàĩĺàƀĺē; Ďōćķēŕ ćĺēàńũƥ ōƥţĩōńś ćàńńōţ ŕũń ōń ţĥĩś ĥōśţ.", - "storageAcknowledgeDedicatedFirst": "Àćķńōŵĺēďĝē à ďēďĩćàţēď Ďōćķēŕ ďàēḿōń ƒĩŕśţ.", "storageDedicatedDaemon": "Ďēďĩćàţēď Ďōćķēŕ ďàēḿōń", "storageDedicatedDaemonDescription": "Ćōńƒĩŕḿ ţĥàţ ũńŕēĺàţēď ŵōŕķĺōàďś ďō ńōţ śĥàŕē ţĥĩś Ďōćķēŕ ďàēḿōń.", "storageDedicatedDaemonHelp": "Ɓũĩĺď ćàćĥē àńď ĩḿàĝē ōŵńēŕśĥĩƥ ćàńńōţ ƀē àţţŕĩƀũţēď ŕēĺĩàƀĺŷ ţō Ķàńďēv. Ţĥĩś àćķńōŵĺēďĝḿēńţ ũńĺōćķś ďàēḿōń-ŵĩďē ćĺēàńũƥ àńď śĥōũĺď ōńĺŷ ƀē ēńàƀĺēď ŵĥēń ţĥē ćōńƒĩĝũŕēď Ďōćķēŕ ďàēḿōń ĩś ũśēď ēxćĺũśĩvēĺŷ ƀŷ Ķàńďēv. Ķàńďēv ńēvēŕ ƥēŕƒōŕḿś à vōĺũḿē-ŵĩďē ƥŕũńē.", - "storageDockerBuildCacheDescription": "Ŕēḿōvē ōĺď ƀũĩĺď ćàćĥē ŵĥĩĺē ŕēţàĩńĩńĝ ţĥē ćōńƒĩĝũŕēď àḿōũńţ.", - "storageDockerBuildCacheHelp": "Ũśēś Ďōćķēŕ'ś àĝē àńď śţōŕàĝē ƒĩĺţēŕś ţō ŕēḿōvē ōĺď ƀũĩĺď ćàćĥē. Ĩţ ďōēś ńōţ ŕũń ďōćķēŕ śŷśţēḿ ƥŕũńē, ďōēś ńōţ ŕēḿōvē vōĺũḿēś ĝĺōƀàĺĺŷ, àńď ŕēḿàĩńś ďĩśàƀĺēď ũńţĩĺ ţĥē ďēďĩćàţēď-ďàēḿōń àćķńōŵĺēďĝḿēńţ ĩś ćōńƒĩŕḿēď.", - "storageCleanDockerBuildCacheAria": "Ćĺēàń Ďōćķēŕ ƀũĩĺď ćàćĥē", - "storageBuildCacheKeepLabel": "Ɓũĩĺď ćàćĥē ţō ŕēţàĩń (ĜƁ)", - "storageBuildCacheKeepHelp": "Ďōćķēŕ ķēēƥś àƥƥŕōxĩḿàţēĺŷ ţĥĩś ḿũćĥ ƀũĩĺď ćàćĥē ŵĥēń ƥŕũńĩńĝ ēĺĩĝĩƀĺē ŕēćōŕďś. À ĺàŕĝēŕ vàĺũē ƥŕēśēŕvēś ḿōŕē ŕēũśàƀĺē ƀũĩĺď ĺàŷēŕś ƀũţ ŕēćĺàĩḿś ĺēśś ďĩśķ śƥàćē.", - "storageBuildCacheUnusedLabel": "Ɓũĩĺď ćàćĥē ḿũśţ ƀē ũńũśēď ƒōŕ (ĥōũŕś)", - "storageBuildCacheUnusedHelp": "Ōńĺŷ ƀũĩĺď ćàćĥē ŕēćōŕďś ōĺďēŕ ţĥàń ţĥĩś ũńũśēď-àĝē ţĥŕēśĥōĺď àŕē ēĺĩĝĩƀĺē. Ĩńćŕēàśĩńĝ ĩţ ƥŕōţēćţś ŕēćēńţ ƀũĩĺď ĺàŷēŕś ƒōŕ ĺōńĝēŕ.", - "storageUnusedImagesDescription": "Ŕēḿōvē ōĺď ĩḿàĝēś ţĥàţ ńō ćōńţàĩńēŕ ũśēś.", - "storageUnusedImagesHelp": "Ŕēḿōvēś àń ĩḿàĝē ōńĺŷ ŵĥēń ńō ŕũńńĩńĝ ōŕ śţōƥƥēď ćōńţàĩńēŕ ŕēƒēŕēńćēś ĩţ àńď ĩţ ĩś ōĺďēŕ ţĥàń ţĥē ćōńƒĩĝũŕēď àĝē. Ţĥĩś ĩś ďàēḿōń-ŵĩďē àńď ţĥēŕēƒōŕē ŕēqũĩŕēś ţĥē ďēďĩćàţēď-ďàēḿōń àćķńōŵĺēďĝḿēńţ.", - "storageCleanUnusedImagesAria": "Ćĺēàń ũńũśēď Ďōćķēŕ ĩḿàĝēś", - "storageUnusedImagesHoursLabel": "Ĩḿàĝē ḿũśţ ƀē ũńũśēď ƒōŕ (ĥōũŕś)", - "storageUnusedImagesHoursHelp": "Àń ĩḿàĝē ḿũśţ ƀē ũńũśēď ƀŷ ēvēŕŷ ćōńţàĩńēŕ àńď ōĺďēŕ ţĥàń ţĥĩś àĝē ƀēƒōŕē Ķàńďēv ćàń ŕēḿōvē ĩţ. Ĩńćŕēàśĩńĝ ţĥē vàĺũē ķēēƥś ōĺď ĩḿàĝēś àvàĩĺàƀĺē ƒōŕ ĺōńĝēŕ.", - "storageQuarantineSafetyTitle": "Qũàŕàńţĩńē śàƒēţŷ", - "storageQuarantineSafetyDescription": "Ķēēƥ ŕēćōvēŕàƀĺē ŕēśōũŕćēś ƒōŕ à ĝŕàćē ƥēŕĩōď ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń.", - "storageQuarantineRetentionLabel": "Ķēēƥ qũàŕàńţĩńēď ĩţēḿś ƒōŕ (ĥōũŕś)", - "storageQuarantineRetentionHelp": "Ćĺēàńũƥ ƒĩŕśţ ḿōvēś ōŕƥĥàń ŵōŕķśƥàćēś àńď ŕōţàţēď Ĝō ćàćĥēś ĩńţō Ķàńďēv'ś ţŕàśĥ àŕēà ĩńśţēàď ōƒ ďēĺēţĩńĝ ţĥēḿ ĩḿḿēďĩàţēĺŷ. Ďũŕĩńĝ ţĥĩś ŕēţēńţĩōń ƥēŕĩōď ŷōũ ćàń ŕēśţōŕē àń ĩţēḿ. Àƒţēŕ ţĥē ďēàďĺĩńē, à ĺàţēŕ ḿàĩńţēńàńćē ŕũń ḿàŷ ƥēŕḿàńēńţĺŷ ďēĺēţē ĩţ.", - "storageTypeToConfirm": "Ţŷƥē <1>{{phrase}} ţō ćōńţĩńũē.", - "storageTypeToConfirmAria": "Ţŷƥē {{phrase}} ţō ćōńƒĩŕḿ", - "storageDedicatedDialogTitle": "Ũśē ţĥĩś ďēďĩćàţēď Ďōćķēŕ ďàēḿōń", "storageDedicatedDialogDescription": "Ɓũĩĺď-ćàćĥē àńď ũńũśēď-ĩḿàĝē ćĺēàńũƥ àƒƒēćţ ţĥē ēńţĩŕē ćōńƒĩĝũŕēď ďàēḿōń, ĩńćĺũďĩńĝ ŕēśōũŕćēś ćŕēàţēď ōũţśĩďē Ķàńďēv. Ōńĺŷ àćķńōŵĺēďĝē à ďàēḿōń ďēďĩćàţēď ţō ţĥĩś ĩńśţàĺĺàţĩōń.", - "storageAcknowledgeDaemon": "Àćķńōŵĺēďĝē ďàēḿōń", - "storageAdoptDialogTitle": "Àďōƥţ àń ēxţēŕńàĺ Ĝō ƀũĩĺď ćàćĥē", - "storageAdoptDialogDescription": "Ķàńďēv ŵĩĺĺ ƀē àĺĺōŵēď ţō ŕōţàţē àńď qũàŕàńţĩńē ţĥē ēxĩśţĩńĝ ćàćĥē àţ {{path}}. Ţĥĩś ƥàţĥ ḿũśţ ƀē àƀśōĺũţē àńď ōń ţĥē śàḿē ƒĩĺēśŷśţēḿ àś Ķàńďēv ţŕàśĥ.", - "storageAdoptDialogFallbackPath": "ţĥē śēĺēćţēď ƥàţĥ", - "storageDeleteDialogTitle": "Ƥēŕḿàńēńţĺŷ ďēĺēţē qũàŕàńţĩńēď ďàţà", + "storageDedicatedDialogTitle": "Ũśē ţĥĩś ďēďĩćàţēď Ďōćķēŕ ďàēḿōń", + "storageDefaultDockerHost": "Ďēƒàũĺţ Ďōćķēŕ ĥōśţ", + "storageDelete": "Ďēĺēţē", "storageDeleteDialogDescription": "Ţĥĩś ćàńńōţ ƀē ũńďōńē. Ķàńďēv ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē {{path}}.", "storageDeleteDialogFallbackPath": "ţĥē śēĺēćţēď qũàŕàńţĩńē ēńţŕŷ", + "storageDeleteDialogTitle": "Ƥēŕḿàńēńţĺŷ ďēĺēţē qũàŕàńţĩńēď ďàţà", "storageDeletePermanently": "Ďēĺēţē ƥēŕḿàńēńţĺŷ", - "storageClearEligibleDialogTitle": "Ćĺēàŕ ēĺĩĝĩƀĺē qũàŕàńţĩńē", + "storageDeletionComplete": "Ďēĺēţĩōń ćōḿƥĺēţē", + "storageDescription": "Ŕēvĩēŵ ďĩśķ ũśē àńď ŕēćĺàĩḿ śƥàćē ƒŕōḿ Ķàńďēv-ōŵńēď ŵōŕķśƥàćēś, ćàćĥēś, àńď Ďōćķēŕ ŕēśōũŕćēś ŵĥēńēvēŕ ŷōũŕ ĩńśţàĺĺàţĩōń ńēēďś ĩţ.", + "storageDockerBuildCache": "Ďōćķēŕ ƀũĩĺď ćàćĥē", + "storageDockerBuildCacheDescription": "Ŕēḿōvē ōĺď ƀũĩĺď ćàćĥē ŵĥĩĺē ŕēţàĩńĩńĝ ţĥē ćōńƒĩĝũŕēď àḿōũńţ.", + "storageDockerBuildCacheHelp": "Ũśēś Ďōćķēŕ'ś àĝē àńď śţōŕàĝē ƒĩĺţēŕś ţō ŕēḿōvē ōĺď ƀũĩĺď ćàćĥē. Ĩţ ďōēś ńōţ ŕũń ďōćķēŕ śŷśţēḿ ƥŕũńē, ďōēś ńōţ ŕēḿōvē vōĺũḿēś ĝĺōƀàĺĺŷ, àńď ŕēḿàĩńś ďĩśàƀĺēď ũńţĩĺ ţĥē ďēďĩćàţēď-ďàēḿōń àćķńōŵĺēďĝḿēńţ ĩś ćōńƒĩŕḿēď.", + "storageDockerDescription": "Ōƥţĩōńàĺ ďàēḿōń-ŵĩďē ćĺēàńũƥ. Ēńàƀĺē ĩţ ōńĺŷ ŵĥēń ţĥĩś Ďōćķēŕ ďàēḿōń ĩś ďēďĩćàţēď ţō Ķàńďēv.", + "storageDockerImageLayers": "Ďōćķēŕ ĩḿàĝē ĺàŷēŕś", + "storageDockerTitle": "Ďōćķēŕ ćĺēàńũƥ", + "storageDockerUnavailable": "Ďōćķēŕ ĩś ũńàvàĩĺàƀĺē ōń ţĥē ćōńƒĩĝũŕēď ĥōśţ.", + "storageDockerUnavailableBadge": "Ďōćķēŕ ũńàvàĩĺàƀĺē", + "storageDockerUnavailableNotice": "Ďōćķēŕ ĩś ũńàvàĩĺàƀĺē; Ďōćķēŕ ćĺēàńũƥ ōƥţĩōńś ćàńńōţ ŕũń ōń ţĥĩś ĥōśţ.", + "storageDockerUnmeasured": "Ďōćķēŕ ũśàĝē ćōũĺď ńōţ ƀē ḿēàśũŕēď", + "storageEligibleNow": "Ēĺĩĝĩƀĺē ńōŵ", + "storageEnableGoCacheFirst": "Ēńàƀĺē ţĥē ḿàńàĝēď Ĝō ćàćĥē ƒĩŕśţ.", + "storageEnableManagedGoCacheAria": "Ēńàƀĺē ḿàńàĝēď Ĝō ćàćĥē", + "storageEnterCachePathFirst": "Ēńţēŕ àń àƀśōĺũţē ćàćĥē ƥàţĥ ƒĩŕśţ.", + "storageExternalGoCache": "Ēxţēŕńàĺ Ĝō ćàćĥē", + "storageExternalGoCacheDescription": "Ōƥţĩōńàĺĺŷ àĺĺōŵ Ķàńďēv ţō ḿàĩńţàĩń àń ēxĩśţĩńĝ ĥōśţ ćàćĥē ōũţśĩďē ĩţś ḿàńàĝēď ƥàţĥ.", + "storageExternalGoCacheHelp": "Àďōƥţĩōń ĝĩvēś Ķàńďēv ēxƥĺĩćĩţ ƥēŕḿĩśśĩōń ţō ŕōţàţē ţĥĩś ēxĩśţĩńĝ ćàćĥē. Ķàńďēv ńēvēŕ ćĺēàńś à ďēƒàũĺţ ũśēŕ ćàćĥē ōŕ àńōţĥēŕ ƥàţĥ ũńĺēśś ŷōũ àďōƥţ ţĥàţ ēxàćţ àƀśōĺũţē ƥàţĥ àńď ćōńƒĩŕḿ ţĥē ďēśţŕũćţĩvē àććēśś.", + "storageForceClearAll": "Ƒōŕćē ćĺēàŕ àĺĺ", + "storageForceClearDescription": "Ţĥĩś àţţēḿƥţś ţō ƥēŕḿàńēńţĺŷ ŕēḿōvē ēvēŕŷ qũàŕàńţĩńēď ĩţēḿ ĩḿḿēďĩàţēĺŷ àńď ćàńńōţ ƀē ũńďōńē ƒōŕ ēńţŕĩēś ţĥàţ śũććēēď. Ƒàĩĺēď ēńţŕĩēś ḿàŷ ŕēḿàĩń vĩśĩƀĺē àńď ŕēţŕŷàƀĺē.", "storageForceClearDialogTitle": "Ƒōŕćē ćĺēàŕ àĺĺ qũàŕàńţĩńē", + "storageGoBuildCache": "Ĝō ƀũĩĺď ćàćĥē", + "storageGoCacheBelowLimit": "Ţĥē Ĝō ƀũĩĺď ćàćĥē ĩś ƀēĺōŵ ĩţś ćōńƒĩĝũŕēď śĩźē ĺĩḿĩţ.", + "storageGoCacheMaxHelp": "Ţĥĩś ĩś à ćĺēàńũƥ ţŕĩĝĝēŕ, ńōţ à ĥàŕď qũōţà. Ţĥē ćàćĥē ḿàŷ ĝŕōŵ ƥàśţ ţĥĩś śĩźē ŵĥĩĺē ţàśķś àŕē àćţĩvē. Ḿàĩńţēńàńćē ŕōţàţēś ţĥē ōŵńēď ćàćĥē ĩńţō qũàŕàńţĩńē àńď ŕēćŕēàţēś àń ēḿƥţŷ ćàćĥē àƒţēŕ ţĥē ĺĩḿĩţ ĩś ēxćēēďēď.", + "storageGoCacheMaxLabel": "Ḿàxĩḿũḿ ćàćĥē śĩźē (ĜƁ)", + "storageGoCacheNotOwned": "Ōńĺŷ à Ķàńďēv-ōŵńēď Ĝō ƀũĩĺď ćàćĥē ćàń ƀē ćĺēàńēď.", + "storageGoCacheSectionDescription": "Ũśē àńď ţŕĩḿ à Ķàńďēv-ōŵńēď ćàćĥē ƒōŕ ńēŵ ĥōśţ-ĺōćàĺ ēxēćũţĩōńś.", + "storageHistoryEmpty": "Ńō śţōŕàĝē ḿàĩńţēńàńćē ŕũńś ŷēţ.", + "storageHistoryLoading": "Ĺōàďĩńĝ ḿàĩńţēńàńćē ĥĩśţōŕŷ…", + "storageIdleHelp": "Śćĥēďũĺēď ćĺēàńũƥ śţàŕţś ōńĺŷ àƒţēŕ ńō ţàśķ, śĥēĺĺ ćōḿḿàńď, ţēśţ, śēţũƥ, ćĺēàńũƥ, ōŕ ĩḿàĝē ƀũĩĺď ĥàś ũśēď ḿàńàĝēď ŕēśōũŕćēś ƒōŕ ţĥĩś ĺōńĝ. Ŕũń ńōŵ ďōēś ńōţ ŵàĩţ ƒōŕ ţĥĩś ţĩḿēŕ, ƀũţ ĩţ śţĩĺĺ ŕēƒũśēś ţō ŕũń ŵĥĩĺē ŕēśōũŕćēś àŕē àćţĩvē.", + "storageIdleLabel": "Ŕēqũĩŕē ĩďĺē ƒōŕ (ḿĩńũţēś)", + "storageKandevContainers": "Ķàńďēv ćōńţàĩńēŕś", + "storageLastAnalyzed": "Ĺàśţ àńàĺŷźēď {{time}}", + "storageLoadingData": "Ĺōàďĩńĝ śţōŕàĝē ďàţà…", + "storageMaintenanceHistory": "Ḿàĩńţēńàńćē ĥĩśţōŕŷ", + "storageManagedContainerCount_one": "{{count}} ḿàńàĝēď ćōńţàĩńēŕ", + "storageManagedContainerCount_other": "{{count}} ḿàńàĝēď ćōńţàĩńēŕś", + "storageManagedGoCache": "Ḿàńàĝēď Ĝō ćàćĥē", + "storageManagedGoCacheDescription": "Ńēŵ ĥōśţ-ĺōćàĺ ēxēćũţĩōńś ũśē {{path}}.", + "storageManagedGoCacheHelp": "Ŵĥēń ēńàƀĺēď, Ķàńďēv ĝĩvēś ńēŵ ĺōćàĺ ţàśķ ƥŕōćēśśēś à ďēďĩćàţēď Ĝō ƀũĩĺď ćàćĥē àńď ḿàŷ ŕōţàţē ĩţ ďũŕĩńĝ ḿàĩńţēńàńćē ŵĥēń ĩţ ēxćēēďś ţĥē ḿàxĩḿũḿ. Ţũŕńĩńĝ ţĥĩś ōƒƒ śţōƥś ũśĩńĝ ţĥē ḿàńàĝēď ćàćĥē ƒōŕ ńēŵ ēxēćũţĩōńś ƀũţ ďōēś ńōţ ďēĺēţē ĩţ.", + "storageMoreInformationAbout": "Ḿōŕē ĩńƒōŕḿàţĩōń àƀōũţ {{label}}", + "storageNoEligibleEntries": "Ńō qũàŕàńţĩńē ēńţŕĩēś àŕē ēĺĩĝĩƀĺē ŷēţ.", + "storageOrphanGraceHelp": "À ŵōŕķśƥàćē ḿũśţ ƀē ũńũśēď ƒōŕ àţ ĺēàśţ ţĥĩś ĺōńĝ ƀēƒōŕē ĩţ ćàń ƀē ćĺàśśĩƒĩēď àś àń ōŕƥĥàń. Ĩńćŕēàśĩńĝ ţĥĩś vàĺũē ķēēƥś ōĺď ŵōŕķśƥàćēś ĺōńĝēŕ ƀēƒōŕē ţĥēŷ ēńţēŕ qũàŕàńţĩńē.", + "storageOrphanGraceLabel": "Ŵàĩţ ƀēƒōŕē ōŕƥĥàńĩńĝ (ĥōũŕś)", + "storageOrphanWorkspaces": "Ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś", + "storageOrphanWorkspacesDescription": "Ḿōvē ćōńƒĩŕḿēď ōŕƥĥàń ŵōŕķśƥàćēś ţō qũàŕàńţĩńē.", + "storageOrphanWorkspacesHelp": "Ķàńďēv ōńĺŷ śēĺēćţś à ţàśķ ŵōŕķśƥàćē àƒţēŕ ĩńvēńţōŕŷ ćōńƒĩŕḿś ţĥàţ ńō àćţĩvē ţàśķ, ēńvĩŕōńḿēńţ, śēśśĩōń, ōŕ ƥŕōţēćţēď ŵōŕķţŕēē ũśēś ĩţ. Ţĥē ŵōŕķśƥàćē ĩś ḿōvēď ţō qũàŕàńţĩńē ƒĩŕśţ, ŵĥēŕē ĩţ ćàń ƀē ŕēśţōŕēď ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń.", + "storagePolicyDescription": "Ćĥōōśē ŵĥàţ Ķàńďēv ḿàŷ ŕēćĺàĩḿ àũţōḿàţĩćàĺĺŷ àńď ţĥē śàƒēţŷ ĺĩḿĩţś ĩţ ḿũśţ ƒōĺĺōŵ.", + "storagePolicyLoadingBlock": "Ŵàĩţ ƒōŕ śţōŕàĝē śēţţĩńĝś ţō ƒĩńĩśĥ ĺōàďĩńĝ.", + "storagePolicyTitle": "Ḿàĩńţēńàńćē ƥōĺĩćŷ", + "storageProtectedUntil": "Ƥŕōţēćţēď ũńţĩĺ <1>{{deadline}}", "storagePurgeEligibleCount_one": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē {{count}} ēĺĩĝĩƀĺē ĩţēḿ.", "storagePurgeEligibleCount_other": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē {{count}} ēĺĩĝĩƀĺē ĩţēḿś.", "storagePurgeProtectedCount_one": "{{count}} ƥŕōţēćţēď ĩţēḿ ŕēḿàĩńś ũńţĩĺ ĩţś ŕēţēńţĩōń ďēàďĺĩńē.", "storagePurgeProtectedCount_other": "{{count}} ƥŕōţēćţēď ĩţēḿś ŕēḿàĩń ũńţĩĺ ţĥēĩŕ ŕēţēńţĩōń ďēàďĺĩńēś.", - "storageForceClearDescription": "Ţĥĩś àţţēḿƥţś ţō ƥēŕḿàńēńţĺŷ ŕēḿōvē ēvēŕŷ qũàŕàńţĩńēď ĩţēḿ ĩḿḿēďĩàţēĺŷ àńď ćàńńōţ ƀē ũńďōńē ƒōŕ ēńţŕĩēś ţĥàţ śũććēēď. Ƒàĩĺēď ēńţŕĩēś ḿàŷ ŕēḿàĩń vĩśĩƀĺē àńď ŕēţŕŷàƀĺē.", - "storageQuarantineHeading": "Qũàŕàńţĩńē", - "storageQuarantineHelp": "Qũàŕàńţĩńē ĩś Ķàńďēv'ś ŕēćōvēŕàƀĺē ĥōĺďĩńĝ àŕēà. Ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś àńď ŕōţàţēď Ĝō ćàćĥēś àŕē ḿōvēď ĥēŕē ƀēƒōŕē ďēĺēţĩōń. Ŷōũ ćàń ŕēśţōŕē àń ĩţēḿ ďũŕĩńĝ ĩţś ŕēţēńţĩōń ƥēŕĩōď; àƒţēŕ ţĥē ďēàďĺĩńē, à ĺàţēŕ ḿàĩńţēńàńćē ŕũń ḿàŷ ƥēŕḿàńēńţĺŷ ďēĺēţē ĩţ.", "storageQuarantineCardDescription": "Ćĺēàńũƥ ḿōvēś ŕēćōvēŕàƀĺē ďàţà ĥēŕē ƒĩŕśţ ĩńśţēàď ōƒ ďēĺēţĩńĝ ĩţ ĩḿḿēďĩàţēĺŷ. Ŕēśţōŕē àń ĩţēḿ ĩƒ ŷōũ śţĩĺĺ ńēēď ĩţ, ōŕ ďēĺēţē ĩţ ƥēŕḿàńēńţĺŷ ŵĥēń ŷōũ àŕē ćēŕţàĩń.", + "storageQuarantineCleanupRunning": "À qũàŕàńţĩńē ćĺēàńũƥ ĩś śţĩĺĺ ŕũńńĩńĝ.", + "storageQuarantinedResources": "Qũàŕàńţĩńēď ŕēśōũŕćēś", "storageQuarantineEligibleCount_one": "{{count}} ēĺĩĝĩƀĺē ńōŵ", "storageQuarantineEligibleCount_other": "{{count}} ēĺĩĝĩƀĺē ńōŵ", + "storageQuarantineEmpty": "Ńō ŕēśţōŕàƀĺē qũàŕàńţĩńēď ŕēśōũŕćēś.", + "storageQuarantineHeading": "Qũàŕàńţĩńē", + "storageQuarantineHelp": "Qũàŕàńţĩńē ĩś Ķàńďēv'ś ŕēćōvēŕàƀĺē ĥōĺďĩńĝ àŕēà. Ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś àńď ŕōţàţēď Ĝō ćàćĥēś àŕē ḿōvēď ĥēŕē ƀēƒōŕē ďēĺēţĩōń. Ŷōũ ćàń ŕēśţōŕē àń ĩţēḿ ďũŕĩńĝ ĩţś ŕēţēńţĩōń ƥēŕĩōď; àƒţēŕ ţĥē ďēàďĺĩńē, à ĺàţēŕ ḿàĩńţēńàńćē ŕũń ḿàŷ ƥēŕḿàńēńţĺŷ ďēĺēţē ĩţ.", + "storageQuarantineLoading": "Ĺōàďĩńĝ qũàŕàńţĩńē…", + "storageQuarantineMovedAside_one": "{{count}} ĩţēḿ ḿōvēď àśĩďē ƒōŕ ŕēćōvēŕŷ ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń", + "storageQuarantineMovedAside_other": "{{count}} ĩţēḿś ḿōvēď àśĩďē ƒōŕ ŕēćōvēŕŷ ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń", "storageQuarantineProtectedCount_one": "{{count}} ƥŕōţēćţēď", "storageQuarantineProtectedCount_other": "{{count}} ƥŕōţēćţēď", + "storageQuarantineRetentionHelp": "Ćĺēàńũƥ ƒĩŕśţ ḿōvēś ōŕƥĥàń ŵōŕķśƥàćēś àńď ŕōţàţēď Ĝō ćàćĥēś ĩńţō Ķàńďēv'ś ţŕàśĥ àŕēà ĩńśţēàď ōƒ ďēĺēţĩńĝ ţĥēḿ ĩḿḿēďĩàţēĺŷ. Ďũŕĩńĝ ţĥĩś ŕēţēńţĩōń ƥēŕĩōď ŷōũ ćàń ŕēśţōŕē àń ĩţēḿ. Àƒţēŕ ţĥē ďēàďĺĩńē, à ĺàţēŕ ḿàĩńţēńàńćē ŕũń ḿàŷ ƥēŕḿàńēńţĺŷ ďēĺēţē ĩţ.", + "storageQuarantineRetentionLabel": "Ķēēƥ qũàŕàńţĩńēď ĩţēḿś ƒōŕ (ĥōũŕś)", + "storageQuarantineSafetyDescription": "Ķēēƥ ŕēćōvēŕàƀĺē ŕēśōũŕćēś ƒōŕ à ĝŕàćē ƥēŕĩōď ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń.", + "storageQuarantineSafetyTitle": "Qũàŕàńţĩńē śàƒēţŷ", + "storageQuarantineScheduleOff": "Àũţōḿàţĩć qũàŕàńţĩńē ćĺēàńũƥ ĩś ōƒƒ. Ŕũń ḿàĩńţēńàńćē ḿàńũàĺĺŷ ōŕ ũśē à qũàŕàńţĩńē àćţĩōń ŵĥēń ŷōũ àŕē ŕēàďŷ.", "storageQuarantineScheduleOn_one": "Ēĺĩĝĩƀĺē ēńţŕĩēś àŕē ŕēḿōvēď ƀŷ ţĥē ƒĩŕśţ śũććēśśƒũĺ ḿàĩńţēńàńćē ŕũń àƒţēŕ ţĥēĩŕ ďēàďĺĩńē (ēvēŕŷ {{count}} ĥōũŕ ŵĥēń ţĥē ĩďĺē ĝàţē àĺĺōŵś ĩţ).", "storageQuarantineScheduleOn_other": "Ēĺĩĝĩƀĺē ēńţŕĩēś àŕē ŕēḿōvēď ƀŷ ţĥē ƒĩŕśţ śũććēśśƒũĺ ḿàĩńţēńàńćē ŕũń àƒţēŕ ţĥēĩŕ ďēàďĺĩńē (ēvēŕŷ {{count}} ĥōũŕś ŵĥēń ţĥē ĩďĺē ĝàţē àĺĺōŵś ĩţ).", - "storageQuarantineScheduleOff": "Àũţōḿàţĩć qũàŕàńţĩńē ćĺēàńũƥ ĩś ōƒƒ. Ŕũń ḿàĩńţēńàńćē ḿàńũàĺĺŷ ōŕ ũśē à qũàŕàńţĩńē àćţĩōń ŵĥēń ŷōũ àŕē ŕēàďŷ.", "storageQuarantineTotal": "Ţōţàĺ qũàŕàńţĩńēď: {{size}}", - "storageQuarantineLoading": "Ĺōàďĩńĝ qũàŕàńţĩńē…", - "storageQuarantineEmpty": "Ńō ŕēśţōŕàƀĺē qũàŕàńţĩńēď ŕēśōũŕćēś.", - "storageNoEligibleEntries": "Ńō qũàŕàńţĩńē ēńţŕĩēś àŕē ēĺĩĝĩƀĺē ŷēţ.", - "storageQuarantineCleanupRunning": "À qũàŕàńţĩńē ćĺēàńũƥ ĩś śţĩĺĺ ŕũńńĩńĝ.", - "storageClearEligible": "Ćĺēàŕ ēĺĩĝĩƀĺē", - "storageForceClearAll": "Ƒōŕćē ćĺēàŕ àĺĺ", - "storageDeletionComplete": "Ďēĺēţĩōń ćōḿƥĺēţē", - "storageResourceTypeTaskWorkspace": "ţàśķ ŵōŕķśƥàćē", + "storageQuarantineUnmeasured": "Qũàŕàńţĩńē ũśàĝē ćōũĺď ńōţ ƀē ḿēàśũŕēď", + "storageRefreshFailed": "Ŕēƒŕēśĥ śţōŕàĝē ďàţà: {{message}}", "storageResourceTypeGoCache": "ĝō ćàćĥē", - "storageTrashPath": "Ţŕàśĥ: {{path}}", - "storageEligibleNow": "Ēĺĩĝĩƀĺē ńōŵ", - "storageProtectedUntil": "Ƥŕōţēćţēď ũńţĩĺ <1>{{deadline}}", + "storageResourceTypeTaskWorkspace": "ţàśķ ŵōŕķśƥàćē", "storageRestore": "Ŕēśţōŕē", - "storageDelete": "Ďēĺēţē", "storageRetentionEnds": "Ŕēţēńţĩōń ēńďś {{deadline}}.", - "storageMaintenanceHistory": "Ḿàĩńţēńàńćē ĥĩśţōŕŷ", - "storageHistoryLoading": "Ĺōàďĩńĝ ḿàĩńţēńàńćē ĥĩśţōŕŷ…", - "storageHistoryEmpty": "Ńō śţōŕàĝē ḿàĩńţēńàńćē ŕũńś ŷēţ.", - "storageToastPolicySaved": "Śţōŕàĝē ƥōĺĩćŷ śàvēď", + "storageRunAnyway": "Ŕũń àńŷŵàŷ", + "storageRunNow": "Ŕũń ńōŵ", + "storageScheduleDescription": "Ćōńţŕōĺś ŵĥēń àũţōḿàţĩć ḿàĩńţēńàńćē ĩś àĺĺōŵēď ţō śţàŕţ. Ḿàńũàĺ àćţĩōńś ŕēḿàĩń àvàĩĺàƀĺē ŵĥēń śćĥēďũĺĩńĝ ĩś ōƒƒ.", + "storageScheduledMaintenance": "Śćĥēďũĺēď ḿàĩńţēńàńćē", + "storageScheduledMaintenanceDescription": "Ƥēŕĩōďĩćàĺĺŷ ŕēćĺàĩḿ ďĩśķ śƥàćē ũśĩńĝ ţĥē ēńàƀĺēď ŕēśōũŕćē ŕũĺēś.", + "storageScheduledMaintenanceHelp": "Ŵĥēń ēńàƀĺēď, Ķàńďēv ćĥēćķś ţĥĩś ƥōĺĩćŷ àţ ţĥē ćōńƒĩĝũŕēď ĩńţēŕvàĺ àńď śţàŕţś ōńĺŷ àƒţēŕ Ķàńďēv ĥàś ƀēēń ĩďĺē ƒōŕ ţĥē ŕēqũĩŕēď ƥēŕĩōď. Ţũŕńĩńĝ ĩţ ōƒƒ ďōēś ńōţ ďĩśàƀĺē Àńàĺŷźē ōŕ Ŕũń ńōŵ.", + "storageScheduleTitle": "Śćĥēďũĺē", + "storageSectionUnavailable": "Ţĥĩś śţōŕàĝē śēćţĩōń ćōũĺď ńōţ ƀē ĺōàďēď", + "storageTaskWorkspaces": "Ţàśķ ŵōŕķśƥàćēś", + "storageTitle": "Śţōŕàĝē", + "storageToastAnalysisStarted": "Śţōŕàĝē àńàĺŷśĩś śţàŕţēď", "storageToastCacheAdopted": "Ĝō ƀũĩĺď ćàćĥē àďōƥţēď", "storageToastDeletionStarted": "Ƥēŕḿàńēńţ ďēĺēţĩōń śţàŕţēď", "storageToastEligiblePurgeStarted": "Ēĺĩĝĩƀĺē qũàŕàńţĩńē ćĺēàńũƥ śţàŕţēď", "storageToastForcedPurgeStarted": "Ƒōŕćēď qũàŕàńţĩńē ćĺēàńũƥ śţàŕţēď", - "storageToastAnalysisStarted": "Śţōŕàĝē àńàĺŷśĩś śţàŕţēď", "storageToastMaintenanceStarted": "Śţōŕàĝē ḿàĩńţēńàńćē śţàŕţēď", + "storageToastPolicySaved": "Śţōŕàĝē ƥōĺĩćŷ śàvēď", "storageToastQuarantineRestored": "Qũàŕàńţĩńēď ŕēśōũŕćē ŕēśţōŕēď", - "storageRefreshFailed": "Ŕēƒŕēśĥ śţōŕàĝē ďàţà: {{message}}" + "storageTotalCounted": "Ţōţàĺ ćōũńţēď: {{size}}", + "storageTotalPartial": "Ƥàŕţĩàĺ ḿēàśũŕēḿēńţ", + "storageTrashPath": "Ţŕàśĥ: {{path}}", + "storageTypeToConfirm": "Ţŷƥē <1>{{phrase}} ţō ćōńţĩńũē.", + "storageTypeToConfirmAria": "Ţŷƥē {{phrase}} ţō ćōńƒĩŕḿ", + "storageUnavailableValue": "Ũńàvàĩĺàƀĺē", + "storageUnusedDockerImages": "Ũńũśēď Ďōćķēŕ ĩḿàĝēś", + "storageUnusedImagesDescription": "Ŕēḿōvē ōĺď ĩḿàĝēś ţĥàţ ńō ćōńţàĩńēŕ ũśēś.", + "storageUnusedImagesDetail": "Ũńũśēď ƀŷ ēvēŕŷ ćōńţàĩńēŕ àńď ōĺďēŕ ţĥàń ţĥē ćōńƒĩĝũŕēď àĝē", + "storageUnusedImagesHelp": "Ŕēḿōvēś àń ĩḿàĝē ōńĺŷ ŵĥēń ńō ŕũńńĩńĝ ōŕ śţōƥƥēď ćōńţàĩńēŕ ŕēƒēŕēńćēś ĩţ àńď ĩţ ĩś ōĺďēŕ ţĥàń ţĥē ćōńƒĩĝũŕēď àĝē. Ţĥĩś ĩś ďàēḿōń-ŵĩďē àńď ţĥēŕēƒōŕē ŕēqũĩŕēś ţĥē ďēďĩćàţēď-ďàēḿōń àćķńōŵĺēďĝḿēńţ.", + "storageUnusedImagesHoursHelp": "Àń ĩḿàĝē ḿũśţ ƀē ũńũśēď ƀŷ ēvēŕŷ ćōńţàĩńēŕ àńď ōĺďēŕ ţĥàń ţĥĩś àĝē ƀēƒōŕē Ķàńďēv ćàń ŕēḿōvē ĩţ. Ĩńćŕēàśĩńĝ ţĥē vàĺũē ķēēƥś ōĺď ĩḿàĝēś àvàĩĺàƀĺē ƒōŕ ĺōńĝēŕ.", + "storageUnusedImagesHoursLabel": "Ĩḿàĝē ḿũśţ ƀē ũńũśēď ƒōŕ (ĥōũŕś)", + "storageUserGoBuildCache": "Ũśēŕ Ĝō ƀũĩĺď ćàćĥē", + "storageWorkspacesDescription": "Ŕēćĺàĩḿ ŕēśōũŕćēś ţĥàţ Ķàńďēv ćàń ƥōśĩţĩvēĺŷ ĩďēńţĩƒŷ àś ńō ĺōńĝēŕ ĩń ũśē.", + "storageWorkspacesDetail": "{{reclaimable}} ŕēćĺàĩḿàƀĺē àƒţēŕ ţĥē ĝŕàćē ƥēŕĩōď · {{active}} àćţĩvē", + "storageWorkspacesTitle": "Ŵōŕķśƥàćēś àńď ćōńţàĩńēŕś", + "uiStateHelp": "Ćĺēàŕś ĺàŷōũţ àńď ŨĨ ƥŕēƒēŕēńćēś śàvēď ĩń ŷōũŕ ƀŕōŵśēŕ (ƥàńēĺ śĩźēś, ƥĩńńēď ţàśķś, ēxƥàńďēď ĝŕōũƥś, ēţć.). Ũśē ţĥĩś ĩƒ ţĥē àƥƥ ĺōōķś ƀŕōķēń, ƥàńēĺś àŕē śţũćķ, ōŕ ţĥē ĺàŷōũţ ƒēēĺś ŵŕōńĝ. Ŷōũŕ ţàśķś, śēśśĩōńś, àńď śēŕvēŕ ďàţà àŕē ńōţ àƒƒēćţēď - ōńĺŷ ţĥē ƥēŕ-ƀŕōŵśēŕ ŨĨ śţàţē. Ţĥē ƥàĝē ŕēĺōàďś àƒţēŕ ţĥē ŕēśēţ.", + "uiStateReset": "Ŕēśēţ", + "uiStateResetLabel": "Ŕēśēţ ƀŕōŵśēŕ ĺàŷōũţ", + "uiStateTitle": "ŨĨ śţàţē", + "updateAvailable": "Ũƥďàţē àvàĩĺàƀĺē", + "updatesApply": "Àƥƥĺŷ ũƥďàţē", + "updatesApplyConfirmDesktop": "Ķàńďēv ŵĩĺĺ ĩńśţàĺĺ {{version}} àńď ŕēśţàŕţ ţĥē ďēśķţōƥ àƥƥ.", + "updatesApplyConfirmService": "Ķàńďēv ŵĩĺĺ ũƥďàţē ţō {{version}}, ŕēĩńśţàĺĺ ţĥē ũśēŕ śēŕvĩćē, àńď ŕēśţàŕţ ĩţ.", + "updatesApplyConfirmTitle": "Àƥƥĺŷ ũƥďàţē?", + "updatesCheckFailed": "Ũƥďàţē ćĥēćķ ƒàĩĺēď", + "updatesCheckNow": "Ćĥēćķ ńōŵ", + "updatesCurrentVersion": "Ćũŕŕēńţ vēŕśĩōń", + "updatesDownloading": "Ďōŵńĺōàďĩńĝ ũƥďàţē ({{downloaded}} ƀŷţēś)...", + "updatesDownloadingOfTotal": "Ďōŵńĺōàďĩńĝ ũƥďàţē ({{downloaded}} ōƒ {{total}} ƀŷţēś)...", + "updatesInstalling": "Ĩńśţàĺĺĩńĝ ũƥďàţē...", + "updatesLastChecked": "Ĺàśţ ćĥēćķēď {{at}}", + "updatesLatestRelease": "Ĺàţēśţ ŕēĺēàśē", + "updatesNever": "ńēvēŕ", + "updatesNotificationsHint": "Ńōţĩƒĩćàţĩōń ƥŕēƒēŕēńćēś àŕē ḿàńàĝēď ĩń <2>Ńōţĩƒĩćàţĩōńś.", + "updatesPageDescription": "Ćũŕŕēńţ vś ĺàţēśţ ŕēĺēàśē ƥĺũś ţĥē ƒũĺĺ ķàńďēv ćĥàńĝēĺōĝ.", + "updatesReleaseNotes": "Ŕēĺēàśē ńōţēś", + "updatesRetryAfter_one": "Àĺŕēàďŷ ćĥēćķēď. Ţŕŷ àĝàĩń ĩń {{count}}ś.", + "updatesRetryAfter_other": "Àĺŕēàďŷ ćĥēćķēď. Ţŕŷ àĝàĩń ĩń {{count}}ś.", + "updatesTitle": "Ũƥďàţēś", + "updatesUpToDate": "Ķàńďēv ĩś ũƥ ţō ďàţē.", + "usersAddUser": "Àďď ũśēŕ", + "usersColumnActions": "Àćţĩōńś", + "usersColumnEmail": "Ēḿàĩĺ", + "usersColumnName": "Ńàḿē", + "usersColumnRole": "Ŕōĺē", + "usersColumnStatus": "Śţàţũś", + "usersConfirm": "Ćōńƒĩŕḿ", + "usersConfirmDisable": "Ďĩśàƀĺē {{email}}? Ţĥēŷ ŵĩĺĺ ƀē śĩĝńēď ōũţ ēvēŕŷŵĥēŕē.", + "usersConfirmEnable": "Ŕē-ēńàƀĺē {{email}}?", + "usersConfirmRoleChange": "Ćĥàńĝē {{email}} ţō {{role}}?", + "usersDisable": "Ďĩśàƀĺē", + "usersEmpty": "Ńō ũśēŕś ŷēţ.", + "usersEnable": "Ēńàƀĺē", + "usersInviteLink": "Ĩńvĩţē ĺĩńķ", + "usersLastAdminGuard": "Ţĥĩś ďēƥĺōŷḿēńţ ḿũśţ ķēēƥ àţ ĺēàśţ ōńē àćţĩvē àďḿĩń.", + "usersLoadFailed": "Ƒàĩĺēď ţō ĺōàď ũśēŕś.", + "usersLoading": "Ĺōàďĩńĝ ũśēŕś...", + "usersMakeAdmin": "Ḿàķē àďḿĩń", + "usersMakeMember": "Ḿàķē ḿēḿƀēŕ", + "usersPageDescription": "Ḿàńàĝē àććōũńţś, ŕōĺēś, àńď ĩńvĩţē ĺĩńķś ƒōŕ ţĥĩś ĩńśţàńćē.", + "usersRoleAdmin": "àďḿĩń", + "usersRoleMember": "ḿēḿƀēŕ", + "usersStatusActive": "àćţĩvē", + "usersStatusDisabled": "ďĩśàƀĺēď", + "usersTakesEffectImmediately": "{{email}} — ţĥĩś ţàķēś ēƒƒēćţ ĩḿḿēďĩàţēĺŷ.", + "usersTitle": "Ũśēŕś", + "usersUpdateFailed": "Ćōũĺď ńōţ ũƥďàţē ũśēŕ", + "versionCurrent": "Ćũŕŕēńţ", + "versionLatest": "Ĺàţēśţ", + "versionTitle": "Vēŕśĩōń", + "versionViewUpdates": "Vĩēŵ ũƥďàţēś" } diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 972d8b978b..37c3ce4710 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Trans } from "react-i18next"; import AgentsSettingsPage from "@/app/settings/agents/page"; import AgentSetupPage from "@/app/settings/agents/[agentId]/page"; @@ -58,6 +59,11 @@ import { HealthIssuesCard } from "@/components/settings/system/health-issues-car import { LicensesList } from "@/components/settings/system/licenses-list"; import { LogViewer } from "@/components/settings/system/log-viewer"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; +import { + BACKUP_DIR, + BACKUP_SQL_COMMAND, + SystemRouteShell, +} from "@/components/settings/system/system-route-shell"; import { UIStateCard } from "@/components/settings/system/ui-state-card"; import { UpdatesCard } from "@/components/settings/system/updates-card"; import { VersionSummaryCard } from "@/components/settings/system/version-summary-card"; @@ -164,12 +170,9 @@ const SETTINGS_ROUTES: Record = { "/settings/integrations/slack": () => renderIntegrationSettingsRoute("slack"), "/settings/system": () => , "/settings/system/users": () => ( - + - + ), "/settings/account/security": () => ( = { ), "/settings/system/about": () => ( - + - + ), "/settings/system/backups": () => ( - - + ), "/settings/system/database": () => ( - + - + ), "/settings/system/feature-toggles": () => ( - - + ), "/settings/system/licenses": () => ( - + - + ), "/settings/system/logs": () => ( - - + ), "/settings/system/status": () => ( - +
-
+ ), "/settings/system/storage": () => , "/settings/system/updates": renderUpdatesRoute, @@ -418,20 +416,23 @@ function renderIntegrationSettingsRoute(section: string | null, workspaceId?: st } function renderUpdatesRoute() { + return ; +} + +function UpdatesRoute() { return ( - +

- Notification preferences are managed in{" "} - - Notifications - - . + + Notification preferences are managed in{" "} + + Notifications + + . +

-
+ ); } @@ -627,8 +628,11 @@ async function loadWorkspaceWorkflowsRoute( function SettingsRouteFallback({ pathname }: { pathname: string }) { return (
- This settings route is handled by the SPA shell, but its dedicated client page is still being - ported: {pathname} + {/* `pathname` is a route string, never translated. */} + + This settings route is handled by the SPA shell, but its dedicated client page is still + being ported: {pathname} +
); }