diff --git a/apps/web/app/settings/system/storage/page.tsx b/apps/web/app/settings/system/storage/page.tsx index 7d5acc5ce5..80a6a06d5c 100644 --- a/apps/web/app/settings/system/storage/page.tsx +++ b/apps/web/app/settings/system/storage/page.tsx @@ -1,12 +1,13 @@ +"use client"; + +import { useTranslation } from "react-i18next"; import { StorageMaintenanceSettings } from "@/components/settings/system/storage/storage-maintenance-settings"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; export default function StoragePage() { + const { t } = useTranslation(); return ( - + ); diff --git a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx new file mode 100644 index 0000000000..1c3367e1a6 --- /dev/null +++ b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx @@ -0,0 +1,146 @@ +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 { + DedicatedDockerDialog, + ExternalGoCacheDialog, + PermanentDeleteDialog, + QuarantinePurgeDialog, +} from "./storage-confirmation-dialogs"; + +afterEach(cleanup); + +describe("storage confirmation dialogs", () => { + // The confirm gate is `confirmation !== phrase`, so the phrase must reach the + // user exactly as the comparison expects it. Translating it — or letting the + // tag index drift off its child — would leave a dialog the + // user cannot satisfy, and nothing else in the suite would fail. + it("shows the untranslated phrase and only enables the action on an exact match", () => { + const onConfirm = vi.fn(); + render( + , + ); + + const description = screen.getByText(/This cannot be undone/); + expect(description.textContent).toBe( + "This cannot be undone. Kandev will permanently remove the selected quarantine entry. Type DELETE to continue.", + ); + + const input = screen.getByLabelText("Type DELETE to confirm"); + const action = screen.getByTestId("storage-quarantine-delete-confirm") as HTMLButtonElement; + expect(action.disabled).toBe(true); + + fireEvent.change(input, { target: { value: "delete" } }); + expect(action.disabled).toBe(true); + + fireEvent.change(input, { target: { value: "DELETE" } }); + expect(action.disabled).toBe(false); + }); + + // Two independent counts cannot share one i18next `count`, so this sentence is + // two plural messages joined. Both forms are asserted whole. + it("agrees both quarantine counts with their own number", () => { + const purge = (eligibleCount: number, protectedCount: number) => + render( + , + ); + + purge(1, 1); + expect(screen.getByText(/This will permanently remove/).textContent).toBe( + "This will permanently remove 1 eligible item. 1 protected item remains until its retention deadline. Type DELETE ELIGIBLE to continue.", + ); + + cleanup(); + purge(3, 2); + expect(screen.getByText(/This will permanently remove/).textContent).toBe( + "This will permanently remove 3 eligible items. 2 protected items remain until their retention deadlines. Type DELETE ELIGIBLE to continue.", + ); + }); +}); + +/** + * The pseudo-locale oracle, at unit scale. + * + * `i18next/no-literal-string` runs in `mode: "jsx-only"`, so a string this file + * hands to `` through a helper or a template would be + * invisible to lint, and a clean lint is not proof the file is done. Under + * `pseudo` every catalog message is accented, so any word-like ASCII left in a + * dialog is a literal that never reached the catalog — except the confirmation + * phrase, which must stay verbatim for `confirmation !== phrase` to be + * satisfiable. These four dialogs carry the page's destructive actions, so they + * are the ones worth pinning. + * + * Accessibility copy is scanned too. A screen-reader-only string is exactly the + * kind that survives a migration untranslated, because nothing on screen looks + * wrong — and the input's `aria-label` is the only place the confirmation phrase + * is announced. + */ +describe("storage confirmation 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"]; + + /** Text and accessibility copy in the open dialog that is not accented. */ + function unlocalizedText(): string[] { + const dialog = document.querySelector("[role=alertdialog]"); + 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"); + }); + + const dialogProps = { open: true, onOpenChange: vi.fn(), onConfirm: vi.fn() }; + + it.each([ + ["DEDICATED", ], + ["ADOPT", ], + ["DELETE", ], + [ + "DELETE ALL NOW", + , + ], + ])("leaves only the %s phrase un-accented", (phrase, element) => { + render(element); + // The phrase appears twice — in the sentence and in the input's aria-label — + // and both must be the raw token, so the deduped set is exactly one entry. + expect(unlocalizedText()).toEqual([phrase]); + const input = screen.getByRole("textbox"); + const label = input.getAttribute("aria-label") ?? ""; + expect(label).toContain(phrase); + expect(label.replace(phrase, "")).toMatch(ACCENTED); + }); +}); diff --git a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx index 64e6688375..5953985774 100644 --- a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx +++ b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx @@ -12,14 +12,24 @@ import { AlertDialogTitle, } from "@kandev/ui/alert-dialog"; import { Input } from "@kandev/ui/input"; +import { Trans, useTranslation } from "react-i18next"; import type { StorageQuarantineEntry, StorageQuarantinePurgeScope } from "@/lib/types/system"; +/** + * `phrase` is a sentinel, never copy: the user must type it verbatim and the + * confirm button gates on `confirmation !== props.phrase`. Translating one of + * these would make the dialog impossible to satisfy in that locale — so the + * union stays English and travels as an interpolated value everywhere it is + * shown (see docs/i18n.md, "Do not translate"). + */ +type ConfirmationPhrase = "DEDICATED" | "ADOPT" | "DELETE" | "DELETE ELIGIBLE" | "DELETE ALL NOW"; + type ConfirmationDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; title: string; description: string; - phrase: "DEDICATED" | "ADOPT" | "DELETE" | "DELETE ELIGIBLE" | "DELETE ALL NOW"; + phrase: ConfirmationPhrase; actionLabel: string; actionTestId: string; destructive?: boolean; @@ -27,6 +37,7 @@ type ConfirmationDialogProps = { }; function ConfirmationDialog(props: ConfirmationDialogProps) { + const { t } = useTranslation(); const [confirmation, setConfirmation] = useState(""); useEffect(() => { if (!props.open) setConfirmation(""); @@ -37,18 +48,23 @@ function ConfirmationDialog(props: ConfirmationDialogProps) { {props.title} - {props.description} Type {props.phrase} to continue. + {props.description}{" "} + + Type {props.phrase} to continue. + setConfirmation(event.target.value)} className="h-11" - aria-label={`Type ${props.phrase} to confirm`} + aria-label={t("system:storageTypeToConfirmAria", { phrase: props.phrase })} data-testid={`${props.actionTestId}-confirmation`} /> - Cancel + + {t("common:cancel")} + , ) { + const { t } = useTranslation(); return ( ); @@ -83,13 +100,17 @@ export function ExternalGoCacheDialog({ path, ...props }: Pick & { path: string }) { + const { t } = useTranslation(); return ( ); @@ -101,13 +122,17 @@ export function PermanentDeleteDialog({ }: Pick & { entry: StorageQuarantineEntry | null; }) { + const { t } = useTranslation(); return ( @@ -124,18 +149,22 @@ export function QuarantinePurgeDialog({ eligibleCount: number; protectedCount: number; }) { + const { t } = useTranslation(); const eligible = scope === "eligible"; + // Two independent counts cannot share one `count`, so this is two plural + // messages joined — not one message with a hand-written English `s`. + const eligibleDescription = `${t("system:storagePurgeEligibleCount", { count: eligibleCount })} ${t("system:storagePurgeProtectedCount", { count: protectedCount })}`; return ( ; disabledReason?: string; }) { + const { t } = useTranslation(); const analysisActive = controller.analysisJob?.state === "queued" || controller.analysisJob?.state === "running"; const cleanupActive = @@ -75,11 +76,8 @@ function StorageActions({ return (
-

Reclaim disk space safely

-

- Analyze for a read-only snapshot, or run the enabled cleanup rules when you want to - recover space immediately. -

+

{t("system:storageActionsTitle")}

+

{t("system:storageActionsDescription")}

@@ -87,7 +85,7 @@ function StorageActions({ variant="outline" className="w-full sm:w-44" disabledReason={ - disabledReason ?? (analysisActive ? "Storage analysis is still running." : undefined) + disabledReason ?? (analysisActive ? t("system:storageAnalysisRunning") : undefined) } onClick={() => void controller.analyze()} data-testid="storage-analyze" @@ -95,10 +93,10 @@ function StorageActions({ > } /> @@ -107,7 +105,7 @@ function StorageActions({ void controller.runNow()} data-testid="storage-run-now" @@ -115,10 +113,10 @@ function StorageActions({ > } /> @@ -133,6 +131,7 @@ function StorageActionFeedback({ }: { controller: ReturnType; }) { + const { t } = useTranslation(); if (controller.busy) { return ( void controller.runAnyway()} /> @@ -142,7 +141,7 @@ function StorageActionFeedback({ return ( - Storage action failed + {t("system:storageActionFailed")} {controller.error} ); @@ -155,27 +154,29 @@ function StorageBusyFeedback({ busy: StorageBusyState; onRunAnyway: () => void; }) { + const { t } = useTranslation(); return ( - Storage cleanup found active Kandev work + {t("system:storageBusyTitle")} -

Cleanup may disrupt the following work:

+

{t("system:storageBusyIntro")}

    + {/* Resource labels are rendered by the API, not the catalog. */} {busy.resources.map((resource) => (
  • {resource.label}
  • ))}
{busy.forceAvailable && ( <> -

Running cleanup anyway may disrupt this active work.

+

{t("system:storageBusyForceHint")}

- Run anyway + {t("system:storageRunAnyway")} )} @@ -197,8 +198,8 @@ function policyBlockedReason( action: ReturnType["pendingAction"], loading: boolean, ) { - if (action === "adopt") return t("settings:storageAdoptionPending"); - if (loading) return t("settings:storagePolicyLoadingBlock"); + if (action === "adopt") return t("system:storageAdoptionPending"); + if (loading) return t("system:storagePolicyLoadingBlock"); return undefined; } @@ -206,7 +207,7 @@ function storageActionDisabledReason( t: (key: string) => string, action: ReturnType["pendingAction"], ) { - if (action) return t("settings:storageActionPending"); + if (action) return t("system:storageActionPending"); return undefined; } @@ -265,7 +266,7 @@ function StoragePolicyState({ loading, error }: { loading: boolean; error?: stri {loading && } - {loading ? t("settings:loading") : t("settings:storageSectionUnavailable")} + {loading ? t("settings:loading") : t("system:storageSectionUnavailable")} {error && {error}} diff --git a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx index 3aa6ddbaad..a5add91926 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatDateTime } from "@/lib/i18n/formats"; import type { StorageOverviewResponse } from "@/lib/types/system"; import { StorageOverviewCard } from "./storage-overview-card"; @@ -64,7 +65,11 @@ describe("StorageOverviewCard", () => { const timestamp = screen.getByText("Last analyzed 2m ago"); expect(timestamp.tagName).toBe("TIME"); expect(timestamp.getAttribute("dateTime")).toBe(analyzedAt); - expect(timestamp.getAttribute("title")).toBe(new Date(analyzedAt).toLocaleString()); + // The absolute time follows the active i18next locale, not the browser's. + expect(timestamp.getAttribute("title")).toBe(formatDateTime(analyzedAt)); + expect(timestamp.getAttribute("aria-label")).toBe( + `Last analyzed ${formatDateTime(analyzedAt)}`, + ); vi.useRealTimers(); }); diff --git a/apps/web/components/settings/system/storage/storage-overview-card.tsx b/apps/web/components/settings/system/storage/storage-overview-card.tsx index 5104a2becc..477fb383ef 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.tsx @@ -4,16 +4,24 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kand import { Spinner } from "@kandev/ui/spinner"; import { IconChartPie, IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { formatDateTime, formatRelative } from "@/lib/i18n/formats"; import type { StorageMaintenanceSettings, StorageOverviewResponse, StorageQuarantineSummary, } from "@/lib/types/system"; -import { formatRelativeTime } from "@/lib/utils"; import { StorageActionButton } from "./storage-action-button"; import { formatGigabytes } from "./storage-units"; import { storageAnalysisTotal } from "./storage-totals"; +/** + * Copy for the resource rows is built in plain functions rather than JSX, so + * `i18next/no-literal-string` (mode `jsx-only`) never inspected any of it. Each + * takes `t` from the caller's `useTranslation` instead of importing the + * module-level one, so the rows re-render on a locale switch. + */ +type Translate = (key: string, options?: Record) => string; + interface Props { overview: StorageOverviewResponse | null; settings?: StorageMaintenanceSettings; @@ -32,78 +40,92 @@ interface StorageResource { } function goCacheDisabledReason( + t: Translate, overview: StorageOverviewResponse, pendingReason?: string, settings?: StorageMaintenanceSettings, ) { if (pendingReason) return pendingReason; if (overview.summary.go_cache.owned !== true) { - return "Only a Kandev-owned Go build cache can be cleaned."; + return t("system:storageGoCacheNotOwned"); } if ( (overview.summary.go_cache.size_bytes ?? 0) <= (settings ?? overview.settings).go_cache.max_bytes ) { - return "The Go build cache is below its configured size limit."; + return t("system:storageGoCacheBelowLimit"); } return undefined; } -function quarantineResource(summary: StorageQuarantineSummary): StorageResource { +function quarantineResource(t: Translate, summary: StorageQuarantineSummary): StorageResource { if (summary.available === false) { return { id: "quarantine", - label: "Quarantined resources", - value: "Unavailable", - detail: "Quarantine usage could not be measured", + label: t("system:storageQuarantinedResources"), + value: t("system:storageUnavailableValue"), + detail: t("system:storageQuarantineUnmeasured"), warning: summary.warning, }; } return { id: "quarantine", - label: "Quarantined resources", + label: t("system:storageQuarantinedResources"), value: formatGigabytes(summary.size_bytes), - detail: `${summary.count} items moved aside for recovery before permanent deletion`, + detail: t("system:storageQuarantineMovedAside", { count: summary.count }), }; } function dockerMeasurement( + t: Translate, available: boolean, value: string, detail: string, ): Pick { if (!available) { - return { value: "Unavailable", detail: "Docker usage could not be measured" }; + return { + value: t("system:storageUnavailableValue"), + detail: t("system:storageDockerUnmeasured"), + }; } return { value, detail }; } -function storageResources(overview: StorageOverviewResponse): StorageResource[] { +function storageResources(t: Translate, overview: StorageOverviewResponse): StorageResource[] { const { summary } = overview; const dockerWarning = summary.docker.warnings?.join(" · "); + // The Docker host is an address, not copy; the fallback naming the default is. + const dockerHost = overview.capabilities.docker_host || t("system:storageDefaultDockerHost"); return [ { id: "workspaces", - label: "Task workspaces", + label: t("system:storageTaskWorkspaces"), value: formatGigabytes(summary.workspaces.total_bytes ?? 0), - detail: `${formatGigabytes(summary.workspaces.candidate_bytes ?? 0)} reclaimable after the grace period · ${formatGigabytes(summary.workspaces.active_bytes ?? 0)} active`, + detail: t("system:storageWorkspacesDetail", { + reclaimable: formatGigabytes(summary.workspaces.candidate_bytes ?? 0), + active: formatGigabytes(summary.workspaces.active_bytes ?? 0), + }), warning: summary.workspaces.warning, }, - quarantineResource(summary.quarantine), + quarantineResource(t, summary.quarantine), { id: "managed-containers", - label: "Kandev containers", + label: t("system:storageKandevContainers"), ...dockerMeasurement( + t, summary.docker.available, formatGigabytes(summary.docker.managed_container_bytes ?? 0), - `${summary.docker.managed_container_count ?? 0} managed containers`, + t("system:storageManagedContainerCount", { + count: summary.docker.managed_container_count ?? 0, + }), ), warning: dockerWarning, }, { id: "go-cache", - label: "Go build cache", + label: t("system:storageGoBuildCache"), value: formatGigabytes(summary.go_cache.size_bytes ?? 0), + // A filesystem path from the API — never routed through the catalog. detail: summary.go_cache.path ?? overview.capabilities.managed_go_cache_path, warning: summary.go_cache.warning, }, @@ -111,7 +133,7 @@ function storageResources(overview: StorageOverviewResponse): StorageResource[] ? [ { id: "unmanaged-go-cache", - label: "User Go build cache", + label: t("system:storageUserGoBuildCache"), value: formatGigabytes(summary.go_cache.unmanaged_size_bytes ?? 0), detail: summary.go_cache.unmanaged_path, }, @@ -119,31 +141,34 @@ function storageResources(overview: StorageOverviewResponse): StorageResource[] : []), { id: "docker-image-layers", - label: "Docker image layers", + label: t("system:storageDockerImageLayers"), ...dockerMeasurement( + t, summary.docker.available, formatGigabytes(summary.docker.image_layer_bytes ?? 0), - overview.capabilities.docker_host || "Default Docker host", + dockerHost, ), warning: dockerWarning, }, { id: "docker-build-cache", - label: "Docker build cache", + label: t("system:storageDockerBuildCache"), ...dockerMeasurement( + t, summary.docker.available, formatGigabytes(summary.docker.build_cache_bytes), - overview.capabilities.docker_host || "Default Docker host", + dockerHost, ), warning: dockerWarning, }, { id: "docker-unused-images", - label: "Unused Docker images", + label: t("system:storageUnusedDockerImages"), ...dockerMeasurement( + t, summary.docker.available, formatGigabytes(summary.docker.unused_image_bytes), - "Unused by every container and older than the configured age", + t("system:storageUnusedImagesDetail"), ), warning: dockerWarning, }, @@ -157,6 +182,7 @@ interface ResourceRowProps { } function ResourceRow({ resource, goCacheCleanupDisabledReason, onRunGoCache }: ResourceRowProps) { + const { t } = useTranslation(); return ( - Clean Go cache + {t("system:storageCleanGoCache")} )} @@ -203,7 +229,7 @@ export function StorageOverviewCard({ {isLoading && } - {isLoading ? t("settings:storageLoadingData") : t("settings:storageSectionUnavailable")} + {isLoading ? t("system:storageLoadingData") : t("system:storageSectionUnavailable")} {error && {error}} @@ -211,46 +237,45 @@ export function StorageOverviewCard({ ); } const { summary } = overview; - const analyzedAt = new Date(overview.analyzed_at).toLocaleString(); - const cleanupDisabledReason = goCacheDisabledReason(overview, disabledReason, settings); - const resources = storageResources(overview); + const analyzedAt = formatDateTime(overview.analyzed_at); + const cleanupDisabledReason = goCacheDisabledReason(t, overview, disabledReason, settings); + const resources = storageResources(t, overview); const total = storageAnalysisTotal(summary); return ( - Storage analysis + {t("system:storageAnalysisTitle")} {isLoading && } - {!summary.docker.available && Docker unavailable} + {!summary.docker.available && ( + {t("system:storageDockerUnavailableBadge")} + )} - - A read-only breakdown of current usage and reclaimable space. Run Analyze occasionally to - refresh these estimates; it never deletes or moves anything. - + {t("system:storageAnalysisDescription")}
- {t("settings:storageTotalCounted", { size: formatGigabytes(total.bytes) })} + {t("system:storageTotalCounted", { size: formatGigabytes(total.bytes) })} {total.partial && ( - {t("settings:storageTotalPartial")} + {t("system:storageTotalPartial")} )}
{error && (

- {t("settings:storageSectionUnavailable")}: {error} + {t("system:storageSectionUnavailable")}: {error}

)}
diff --git a/apps/web/components/settings/system/storage/storage-policy-card.test.tsx b/apps/web/components/settings/system/storage/storage-policy-card.test.tsx index d83ac98d1a..deb3e1b55c 100644 --- a/apps/web/components/settings/system/storage/storage-policy-card.test.tsx +++ b/apps/web/components/settings/system/storage/storage-policy-card.test.tsx @@ -38,6 +38,7 @@ const testIds = { dockerImagesUnused: "storage-docker-unused-images-hours", }; const ADOPTION_PATH_TEST_ID = "storage-go-cache-adopt-path"; +const ADOPT_BUTTON_TEST_ID = "storage-go-cache-adopt"; afterEach(cleanup); @@ -213,7 +214,7 @@ describe("StoragePolicyCard interactions", () => { "storage-quarantine-retention", testIds.goCacheMax, "storage-go-cache-adopt-path", - "storage-go-cache-adopt", + ADOPT_BUTTON_TEST_ID, "storage-docker-dedicated", "storage-docker-build-cache", testIds.dockerBuildCacheKeep, @@ -306,3 +307,51 @@ describe("StoragePolicyCard interactions", () => { expect(screen.getAllByLabelText(/^More information about /)).toHaveLength(16); }); }); + +// The adoption button picks one of three reasons and the managed-cache row +// interpolates a path. Both are now catalog lookups rather than literals, so a +// missing key or a renamed placeholder degrades to a raw key or a dropped path +// rather than failing anything else. +describe("Go cache section copy", () => { + const enabledGoCache = { ...settings, go_cache: { ...settings.go_cache, enabled: true } }; + + /** + * The reason renders inside a Radix tooltip, which jsdom will not open from a + * synthetic mouse event — focus the wrapper span the disabled button sits in, + * as apps/web/CLAUDE.md prescribes for this shape. + */ + async function adoptionDisabledReason(): Promise { + const trigger = screen.getByTestId(ADOPT_BUTTON_TEST_ID).parentElement; + if (!trigger) throw new Error("adopt button has no tooltip trigger"); + fireEvent.focus(trigger); + const tooltip = await screen.findAllByRole("tooltip"); + return tooltip[0]?.textContent ?? ""; + } + + it("explains each reason adoption is unavailable", async () => { + renderCard(true, vi.fn(), enabledGoCache, enabledGoCache); + expect(await adoptionDisabledReason()).toBe("Wait for the current storage action to finish."); + + cleanup(); + renderCard(); + expect(await adoptionDisabledReason()).toBe("Enable the managed Go cache first."); + + cleanup(); + renderCard(false, vi.fn(), enabledGoCache, enabledGoCache); + expect(await adoptionDisabledReason()).toBe("Enter an absolute cache path first."); + + // A whitespace-only path is still no path. + fireEvent.change(screen.getByTestId(ADOPTION_PATH_TEST_ID), { target: { value: " " } }); + expect(await adoptionDisabledReason()).toBe("Enter an absolute cache path first."); + + fireEvent.change(screen.getByTestId(ADOPTION_PATH_TEST_ID), { target: { value: "/tmp/go" } }); + expect((screen.getByTestId(ADOPT_BUTTON_TEST_ID) as HTMLButtonElement).disabled).toBe(false); + }); + + it("interpolates the managed cache path rather than concatenating it", () => { + renderCard(); + expect( + screen.getByText(`New host-local executions use ${capabilities.managed_go_cache_path}.`), + ).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/system/storage/storage-policy-card.tsx b/apps/web/components/settings/system/storage/storage-policy-card.tsx index 2ddb8ebb62..4d182c55aa 100644 --- a/apps/web/components/settings/system/storage/storage-policy-card.tsx +++ b/apps/web/components/settings/system/storage/storage-policy-card.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; +import { useTranslation } from "react-i18next"; import { settingsWithDockerAcknowledgement } from "@/hooks/domains/system/use-storage-maintenance"; import type { StorageCapabilities, StorageMaintenanceSettings } from "@/lib/types/system"; import { DedicatedDockerDialog, ExternalGoCacheDialog } from "./storage-confirmation-dialogs"; @@ -35,6 +36,7 @@ function settingIsDirty( } function ScheduleSection({ settings, savedSettings, pending, onChange }: PolicySectionProps) { + const { t } = useTranslation(); const enabledDirty = settingIsDirty(settings, savedSettings, (value) => value.enabled); const intervalDirty = settingIsDirty( settings, @@ -45,20 +47,20 @@ function ScheduleSection({ settings, savedSettings, pending, onChange }: PolicyS return ( onChange({ ...settings, enabled })} - aria-label="Scheduled maintenance" + aria-label={t("system:storageScheduledMaintenance")} data-testid="storage-scheduling-enabled" data-settings-dirty={enabledDirty} /> @@ -66,8 +68,8 @@ function ScheduleSection({ settings, savedSettings, pending, onChange }: PolicyS />
onChange({ ...settings, workspaces: { enabled } })} - aria-label="Clean orphan task workspaces" + aria-label={t("system:storageCleanOrphanWorkspacesAria")} data-settings-dirty={workspacesDirty} /> } />
onChange({ ...settings, kandev_containers: { enabled } })} - aria-label="Clean Kandev containers" + aria-label={t("system:storageCleanContainersAria")} data-settings-dirty={containersDirty} /> } @@ -169,22 +172,22 @@ function AdoptionField({ pending: boolean; enabled: boolean; }) { + const { t } = useTranslation(); let disabledReason: string | undefined; - if (pending) disabledReason = "Wait for the current storage action to finish."; - else if (!enabled) disabledReason = "Enable the managed Go cache first."; - else if (!path.trim()) disabledReason = "Enter an absolute cache path first."; + if (pending) disabledReason = t("system:storageActionPending"); + else if (!enabled) disabledReason = t("system:storageEnableGoCacheFirst"); + else if (!path.trim()) disabledReason = t("system:storageEnterCachePathFirst"); + const fieldLabel = t("system:storageExternalGoCache"); return (
- - - 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. + + + {t("system:storageExternalGoCacheHelp")}

- Optionally allow Kandev to maintain an existing host cache outside its managed path. + {t("system:storageExternalGoCacheDescription")}

- Adopt cache + {t("system:storageAdoptCache")}
@@ -223,6 +226,7 @@ function GoCacheSection({ setAdoptionPath: (path: string) => void; onOpenAdoption: () => void; }) { + const { t } = useTranslation(); const enabledDirty = settingIsDirty(settings, savedSettings, (value) => value.go_cache.enabled); const maxBytesDirty = settingIsDirty( settings, @@ -232,14 +236,16 @@ function GoCacheSection({ return ( onChange({ ...settings, go_cache: { ...settings.go_cache, enabled } }) } - aria-label="Enable managed Go cache" + aria-label={t("system:storageEnableManagedGoCacheAria")} data-testid="storage-go-cache-enabled" data-settings-dirty={enabledDirty} /> @@ -255,8 +261,8 @@ function GoCacheSection({ />
void; }) { + const { t } = useTranslation(); const enabledDirty = docker.build_cache_enabled !== savedDocker.build_cache_enabled; const keepBytesDirty = docker.build_cache_keep_bytes !== savedDocker.build_cache_keep_bytes; const unusedHoursDirty = docker.build_cache_unused_hours !== savedDocker.build_cache_unused_hours; return ( <> updateDocker({ ...docker, build_cache_enabled }) } - aria-label="Clean Docker build cache" + aria-label={t("system:storageCleanDockerBuildCacheAria")} data-testid="storage-docker-build-cache" data-settings-dirty={enabledDirty} /> @@ -320,8 +327,8 @@ function DockerBuildCacheSettings({ />
void; }) { + const { t } = useTranslation(); const enabledDirty = docker.unused_images_enabled !== savedDocker.unused_images_enabled; const hoursDirty = docker.unused_images_hours !== savedDocker.unused_images_hours; return ( <> updateDocker({ ...docker, unused_images_enabled }) } - aria-label="Clean unused Docker images" + aria-label={t("system:storageCleanUnusedImagesAria")} data-testid="storage-docker-unused-images" data-settings-dirty={enabledDirty} /> @@ -386,8 +394,8 @@ function DockerImageSettings({ />
void }) { + const { t } = useTranslation(); const dockerDirty = JSON.stringify(settings.docker) !== JSON.stringify(savedSettings.docker); const dedicatedDirty = settings.docker.dedicated_daemon_acknowledged !== savedSettings.docker.dedicated_daemon_acknowledged; const unavailable = capabilities.docker_available ? undefined - : "Docker is unavailable on the configured host."; + : t("system:storageDockerUnavailable"); const disabledReason = - (pending ? "Wait for the current storage action to finish." : undefined) ?? + (pending ? t("system:storageActionPending") : undefined) ?? unavailable ?? (!settings.docker.dedicated_daemon_acknowledged - ? "Acknowledge a dedicated Docker daemon first." + ? t("system:storageAcknowledgeDedicatedFirst") : undefined); const updateDocker = (docker: StorageMaintenanceSettings["docker"]) => onChange({ ...settings, docker }); return ( } /> {unavailable && ( -

- Docker is unavailable; Docker cleanup options cannot run on this host. -

+

{t("system:storageDockerUnavailableNotice")}

)}
-

Maintenance policy

-

- Choose what Kandev may reclaim automatically and the safety limits it must follow. -

+

{t("system:storagePolicyTitle")}

+

{t("system:storagePolicyDescription")}

({ useSystemJobs: () => [], })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + // Unconditional, so a failing assertion inside a fake-timer test cannot leak + // frozen time into the rest of the worker. + vi.useRealTimers(); +}); + +const QUARANTINED_AT = "2026-07-23T12:00:00Z"; +const DELETE_AFTER = "2026-08-23T12:00:00Z"; function Providers({ children }: { children: ReactNode }) { return {children}; } function renderCard( - props: { entries?: StorageQuarantineEntry[]; loading?: boolean; error?: string | null } = {}, + props: { + entries?: StorageQuarantineEntry[]; + loading?: boolean; + error?: string | null; + schedulingEnabled?: boolean; + checkIntervalHours?: number; + } = {}, ) { return render( { quarantine_path: "/var/trash/cache-1", size_bytes: 2 * 1024 ** 3, state: "quarantined", - quarantined_at: "2026-07-23T12:00:00Z", - delete_after: "2026-08-23T12:00:00Z", + quarantined_at: QUARANTINED_AT, + delete_after: DELETE_AFTER, last_error: "", metadata: {}, }, @@ -75,8 +90,8 @@ describe("StorageQuarantineCard", () => { quarantine_path: "/var/trash/workspace-1", size_bytes: 1024 ** 3, state: "quarantined", - quarantined_at: "2026-07-23T12:00:00Z", - delete_after: "2026-08-23T12:00:00Z", + quarantined_at: QUARANTINED_AT, + delete_after: DELETE_AFTER, last_error: "", metadata: {}, }, @@ -88,4 +103,90 @@ describe("StorageQuarantineCard", () => { "Total quarantined: 3 GB", ); }); + + // The eligible/protected counts are two separate plural messages joined, and + // the protected row is a whose <1> addresses its
- 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. {counts.eligible}{" "} - eligible now · {counts.protected} protected + {t("system:storageQuarantineCardDescription")}{" "} + {t("system:storageQuarantineEligibleCount", { count: counts.eligible })} ·{" "} + {t("system:storageQuarantineProtectedCount", { count: counts.protected })} {showTotal && (

- {t("settings:storageQuarantineTotal", { size: formatGigabytes(totalBytes) })} + {t("system:storageQuarantineTotal", { size: formatGigabytes(totalBytes) })}

)}

{schedulingEnabled - ? `Eligible entries are removed by the first successful maintenance run after their deadline (every ${checkIntervalHours} hours when the idle gate allows it).` - : "Automatic quarantine cleanup is off. Run maintenance manually or use a quarantine action when you are ready."} + ? t("system:storageQuarantineScheduleOn", { count: checkIntervalHours }) + : t("system:storageQuarantineScheduleOff")}

); @@ -211,19 +233,19 @@ function QuarantineContent({ return (
- {t("settings:storageQuarantineLoading")} + {t("system:storageQuarantineLoading")}
); } if (error) { return (

- {t("settings:storageSectionUnavailable")}: {error} + {t("system:storageSectionUnavailable")}: {error}

); } if (entries.length === 0) { - return

{t("settings:storageQuarantineEmpty")}

; + return

{t("system:storageQuarantineEmpty")}

; } return entries.map((entry) => ( (null); const [purgeScope, setPurgeScope] = useState(null); const [now, setNow] = useState(() => new Date()); @@ -268,7 +291,7 @@ export function StorageQuarantineCard({ const counts = quarantineCounts(entries, now); const totalBytes = quarantineTotalBytes(entries); const bulkDisabledReason = - disabledReason ?? (deleteJobActive ? "A quarantine cleanup is still running." : undefined); + disabledReason ?? (deleteJobActive ? t("system:storageQuarantineCleanupRunning") : undefined); return ( ; return ( - Maintenance history + {t("system:storageMaintenanceHistory")} {content} @@ -44,19 +46,19 @@ function StorageRunHistoryContent({ return (
- {t("settings:storageHistoryLoading")} + {t("system:storageHistoryLoading")}
); } if (error) { return (

- {t("settings:storageSectionUnavailable")}: {error} + {t("system:storageSectionUnavailable")}: {error}

); } if (runs.length === 0) { - return

{t("settings:storageHistoryEmpty")}

; + return

{t("system:storageHistoryEmpty")}

; } return ( diff --git a/apps/web/components/settings/system/storage/storage-setting-help.tsx b/apps/web/components/settings/system/storage/storage-setting-help.tsx index 35ca4992e6..9b93d96997 100644 --- a/apps/web/components/settings/system/storage/storage-setting-help.tsx +++ b/apps/web/components/settings/system/storage/storage-setting-help.tsx @@ -4,8 +4,10 @@ import { useState } from "react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconInfoCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; export function StorageSettingHelp({ label, children }: { label: string; children: string }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); return ( @@ -15,7 +17,7 @@ export function StorageSettingHelp({ label, children }: { label: string; childre variant="ghost" size="icon-sm" className="size-11 shrink-0 cursor-help text-muted-foreground sm:size-7" - aria-label={`More information about ${label}`} + aria-label={t("system:storageMoreInformationAbout", { label })} onClick={() => setOpen((current) => !current)} > diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index 532833e8a9..84d886c8c3 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -520,4 +520,45 @@ export const i18nGuardFiles = [ "components/model-config-selector.tsx", "components/settings/host-shell-dialog.tsx", "components/settings/pty-terminal-dialog.tsx", + // Settings → System → Storage: the route and the whole + // `components/settings/system/storage` directory, plus the page's own hook. + // The other eight System routes and the cards they render from + // `components/settings/system/*.tsx` are NOT migrated, which is why this stops + // at the `storage/` subdirectory. + // + // Four `.ts` entries hold no JSX, so `mode: "jsx-only"` never inspects them; + // the entries record that they are migrated and only the pseudo-locale can + // prove it stays that way. `use-storage-maintenance.ts` is the load-bearing + // one — it owns nine toast titles and the refresh-error message, none of which + // lint can ever see. It imports the module-level `t` rather than the hook so + // each string resolves when the callback fires. `storage-quarantine.ts` and + // `storage-totals.ts` carry no copy (the first now formats its deadline + // through `lib/i18n/formats` instead of the browser locale). + // + // Deliberately left in English, none of it copy: + // - The type-to-confirm phrases `DEDICATED` / `ADOPT` / `DELETE` / + // `DELETE ELIGIBLE` / `DELETE ALL NOW` in `storage-confirmation-dialogs`. + // They are a string-literal union compared with `===` to enable the + // confirm button, so translating one makes that dialog impossible to + // satisfy. They travel as an interpolated value into both the visible + // sentence and the input's aria-label. + // - `storage-units.ts` in full. `B` / `GB`, `-` and `<0.01 GB` are units and + // numeric formatting, not prose, which is why it is absent from this list + // rather than listed with a note. + // - `run.state` and `run.trigger` in `storage-run-history.tsx`. They are raw + // API enum tokens rendered verbatim beside the run's raw JSON result — + // `skipped_busy` is the proof — so they are values, not copy. + // - Every filesystem path: the adopted/managed Go cache paths, the quarantine + // `original_path` / `quarantine_path`, the Docker host, and the + // `/root/.cache/go-build` placeholder. All are interpolated as values so + // the pseudo-locale cannot turn them into dead pointers. + // - `StorageBusyResource.label`, which the backend renders. + "app/settings/system/storage/**/*.{ts,tsx}", + "components/settings/system/storage/**/*.{ts,tsx}", + "hooks/domains/system/use-storage-maintenance.ts", + // The shell every System route renders. It takes title/description/actions as + // props and holds no literals of its own, so listing it costs nothing today + // and pins that contract for the sibling migration of the other eight routes, + // which will land their copy on top of it. + "components/settings/system/system-page-shell.tsx", ]; diff --git a/apps/web/hooks/domains/system/use-storage-maintenance.ts b/apps/web/hooks/domains/system/use-storage-maintenance.ts index f4d3000efe..9fbdc5a000 100644 --- a/apps/web/hooks/domains/system/use-storage-maintenance.ts +++ b/apps/web/hooks/domains/system/use-storage-maintenance.ts @@ -12,6 +12,12 @@ import { import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { ApiError } from "@/lib/api/client"; +// The module-level `t`, not `useTranslation`: every string below is produced +// inside an imperative callback (a toast, a refresh error), so resolving at call +// time is both correct and keeps `t` out of the callbacks' dependency arrays — +// a `t` dep on `useTerminalJobRefresh` would re-issue its network reload on a +// locale switch. Never call it at module scope; that would freeze the boot locale. +import { t } from "@/lib/i18n"; import { adoptStorageGoCache, analyzeStorage, @@ -126,7 +132,7 @@ function useStorageActionRunner() { } catch (requestError) { const message = messageFromError(requestError); setError(message); - toast({ title: "Storage action failed", description: message, variant: "error" }); + toast({ title: t("system:storageActionFailed"), description: message, variant: "error" }); if (rethrow) throw requestError; } finally { setPendingActions((current) => current.filter(({ id }) => id !== pendingActionId)); @@ -152,7 +158,7 @@ function useStoragePolicyActions( async () => { await saveStorageSettings(settings, confirmation); await reload(["policy"]); - toast({ title: "Storage policy saved", variant: "success" }); + toast({ title: t("system:storageToastPolicySaved"), variant: "success" }); }, true, ); @@ -166,7 +172,7 @@ function useStoragePolicyActions( return perform("adopt", async () => { const response = await adoptStorageGoCache(path); setPolicy(response); - toast({ title: "Go build cache adopted", variant: "success" }); + toast({ title: t("system:storageToastCacheAdopted"), variant: "success" }); }); }, [clearBusy, perform, setPolicy, toast], @@ -187,7 +193,7 @@ function useStorageDeleteAction( return perform("delete", async () => { const accepted = await deleteStorageQuarantine(id); setDeleteJobId(accepted.job_id); - toast({ title: "Permanent deletion started", variant: "success" }); + toast({ title: t("system:storageToastDeletionStarted"), variant: "success" }); }); }, [clearBusy, perform, setDeleteJobId, toast], @@ -209,8 +215,8 @@ function useStorageBulkDeleteAction( toast({ title: scope === "eligible" - ? "Eligible quarantine cleanup started" - : "Forced quarantine cleanup started", + ? t("system:storageToastEligiblePurgeStarted") + : t("system:storageToastForcedPurgeStarted"), variant: "success", }); }); @@ -237,7 +243,7 @@ function useStorageActions(reload: Reload, setPolicy: (policy: StoragePolicyResp return perform("analyze", async () => { const accepted = await analyzeStorage(); setAnalysisJobId(accepted.job_id); - toast({ title: "Storage analysis started", variant: "success" }); + toast({ title: t("system:storageToastAnalysisStarted"), variant: "success" }); }); }, [clearBusy, perform, toast]); @@ -249,7 +255,7 @@ function useStorageActions(reload: Reload, setPolicy: (policy: StoragePolicyResp try { const accepted = await runStorageMaintenance(resources); setCleanupJobId(accepted.job_id); - toast({ title: "Storage maintenance started", variant: "success" }); + toast({ title: t("system:storageToastMaintenanceStarted"), variant: "success" }); } catch (error) { const nextBusy = busyStateFromError(error, resources); if (nextBusy) { @@ -271,7 +277,7 @@ function useStorageActions(reload: Reload, setPolicy: (policy: StoragePolicyResp try { const accepted = await runStorageMaintenance(resources, true); setCleanupJobId(accepted.job_id); - toast({ title: "Storage maintenance started", variant: "success" }); + toast({ title: t("system:storageToastMaintenanceStarted"), variant: "success" }); } catch (error) { const nextBusy = busyStateFromError(error, resources); if (nextBusy) { @@ -289,7 +295,7 @@ function useStorageActions(reload: Reload, setPolicy: (policy: StoragePolicyResp return perform("restore", async () => { await restoreStorageQuarantine(id); await reload(["quarantine"]); - toast({ title: "Quarantined resource restored", variant: "success" }); + toast({ title: t("system:storageToastQuarantineRestored"), variant: "success" }); }); }, [clearBusy, perform, reload, toast], @@ -333,7 +339,9 @@ function useTerminalJobRefresh(reload: Reload, setError: SetStorageError, job?: setError((current) => (current === resolvedError ? null : current)); } catch (requestError) { if (cancelled) return; - refreshError = `Refresh storage data: ${messageFromError(requestError)}`; + refreshError = t("system:storageRefreshFailed", { + message: messageFromError(requestError), + }); setError(refreshError); attempts += 1; if (attempts >= MAX_TERMINAL_REFRESH_ATTEMPTS) return; diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 730ac32b49..3aa0aa00dc 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -331,18 +331,6 @@ "supportedEditors": "Supported Editors", "supportsPlaceholders": "Supports placeholders: {{placeholders}}", "systemDefault": "System default", - "storageLoadingData": "Loading storage data…", - "storageAdoptionPending": "Wait for Go cache adoption to finish.", - "storagePolicyLoadingBlock": "Wait for storage settings to finish loading.", - "storageActionPending": "Wait for the current storage action to finish.", - "storageHistoryLoading": "Loading maintenance history…", - "storageHistoryEmpty": "No storage maintenance runs yet.", - "storageQuarantineLoading": "Loading quarantine…", - "storageQuarantineEmpty": "No restorable quarantined resources.", - "storageQuarantineTotal": "Total quarantined: {{size}}", - "storageSectionUnavailable": "This storage section could not be loaded", - "storageTotalCounted": "Total counted: {{size}}", - "storageTotalPartial": "Partial measurement", "systemLoad1Min": "System load (1 min)", "taskActions": "Task Actions", "taskOverview": "Task overview", diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json new file mode 100644 index 0000000000..a38f938df9 --- /dev/null +++ b/apps/web/src/locales/en/system.json @@ -0,0 +1,183 @@ +{ + "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.", + "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...", + "storageAnalysisComplete": "Analysis complete", + "storageAnalysisFailed": "Analysis failed", + "storageAnalysisRunning": "Storage analysis is still running.", + "storageRunNow": "Run now", + "storageCleaning": "Cleaning...", + "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.", + "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", + "storageDeleteDialogDescription": "This cannot be undone. Kandev will permanently remove {{path}}.", + "storageDeleteDialogFallbackPath": "the selected quarantine entry", + "storageDeletePermanently": "Delete permanently", + "storageClearEligibleDialogTitle": "Clear eligible quarantine", + "storageForceClearDialogTitle": "Force clear all quarantine", + "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.", + "storageQuarantineEligibleCount_one": "{{count}} eligible now", + "storageQuarantineEligibleCount_other": "{{count}} eligible now", + "storageQuarantineProtectedCount_one": "{{count}} protected", + "storageQuarantineProtectedCount_other": "{{count}} protected", + "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", + "storageResourceTypeGoCache": "go cache", + "storageTrashPath": "Trash: {{path}}", + "storageEligibleNow": "Eligible now", + "storageProtectedUntil": "Protected until <1>{{deadline}}", + "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", + "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", + "storageToastQuarantineRestored": "Quarantined resource restored", + "storageRefreshFailed": "Refresh storage data: {{message}}" +} diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 5be437bf6e..f66fc1984a 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -331,18 +331,6 @@ "supportedEditors": "Śũƥƥōŕţēď Ēďĩţōŕś", "supportsPlaceholders": "Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś: {{placeholders}}", "systemDefault": "Śŷśţēḿ ďēƒàũĺţ", - "storageLoadingData": "Ĺōàďĩńĝ śţōŕàĝē ďàţà…", - "storageAdoptionPending": "Ŵàĩţ ƒōŕ Ĝō ćàćĥē àďōƥţĩōń ţō ƒĩńĩśĥ.", - "storagePolicyLoadingBlock": "Ŵàĩţ ƒōŕ śţōŕàĝē śēţţĩńĝś ţō ƒĩńĩśĥ ĺōàďĩńĝ.", - "storageActionPending": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ śţōŕàĝē àćţĩōń ţō ƒĩńĩśĥ.", - "storageHistoryLoading": "Ĺōàďĩńĝ ḿàĩńţēńàńćē ĥĩśţōŕŷ…", - "storageHistoryEmpty": "Ńō śţōŕàĝē ḿàĩńţēńàńćē ŕũńś ŷēţ.", - "storageQuarantineLoading": "Ĺōàďĩńĝ qũàŕàńţĩńē…", - "storageQuarantineEmpty": "Ńō ŕēśţōŕàƀĺē qũàŕàńţĩńēď ŕēśōũŕćēś.", - "storageQuarantineTotal": "Ţōţàĺ qũàŕàńţĩńēď: {{size}}", - "storageSectionUnavailable": "Ţĥĩś śţōŕàĝē śēćţĩōń ćōũĺď ńōţ ƀē ĺōàďēď", - "storageTotalCounted": "Ţōţàĺ ćōũńţēď: {{size}}", - "storageTotalPartial": "Ƥàŕţĩàĺ ḿēàśũŕēḿēńţ", "systemLoad1Min": "Śŷśţēḿ ĺōàď (1 ḿĩń)", "taskActions": "Ţàśķ Àćţĩōńś", "taskOverview": "Ţàśķ ōvēŕvĩēŵ", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json new file mode 100644 index 0000000000..5487b1aad4 --- /dev/null +++ b/apps/web/src/locales/pseudo/system.json @@ -0,0 +1,170 @@ +{ + "storageTitle": "Śţōŕàĝē", + "storageDescription": "Ŕēvĩēŵ ďĩśķ ũśē àńď ŕēćĺàĩḿ śƥàćē ƒŕōḿ Ķàńďēv-ōŵńēď ŵōŕķśƥàćēś, ćàćĥēś, àńď Ďōćķēŕ ŕēśōũŕćēś ŵĥēńēvēŕ ŷōũŕ ĩńśţàĺĺàţĩōń ńēēďś ĩţ.", + "storageLoadingData": "Ĺōàďĩńĝ śţōŕàĝē ďàţà…", + "storageSectionUnavailable": "Ţĥĩś śţōŕàĝē śēćţĩōń ćōũĺď ńōţ ƀē ĺōàďēď", + "storageAdoptionPending": "Ŵàĩţ ƒōŕ Ĝō ćàćĥē àďōƥţĩōń ţō ƒĩńĩśĥ.", + "storagePolicyLoadingBlock": "Ŵàĩţ ƒōŕ śţōŕàĝē śēţţĩńĝś ţō ƒĩńĩśĥ ĺōàďĩńĝ.", + "storageActionPending": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ śţōŕàĝē àćţĩōń ţō ƒĩńĩśĥ.", + "storageActionsTitle": "Ŕēćĺàĩḿ ďĩśķ śƥàćē śàƒēĺŷ", + "storageActionsDescription": "Àńàĺŷźē ƒōŕ à ŕēàď-ōńĺŷ śńàƥśĥōţ, ōŕ ŕũń ţĥē ēńàƀĺēď ćĺēàńũƥ ŕũĺēś ŵĥēń ŷōũ ŵàńţ ţō ŕēćōvēŕ śƥàćē ĩḿḿēďĩàţēĺŷ.", + "storageAnalyze": "Àńàĺŷźē", + "storageAnalyzing": "Àńàĺŷźĩńĝ...", + "storageAnalysisComplete": "Àńàĺŷśĩś ćōḿƥĺēţē", + "storageAnalysisFailed": "Àńàĺŷśĩś ƒàĩĺēď", + "storageAnalysisRunning": "Śţōŕàĝē àńàĺŷśĩś ĩś śţĩĺĺ ŕũńńĩńĝ.", + "storageRunNow": "Ŕũń ńōŵ", + "storageCleaning": "Ćĺēàńĩńĝ...", + "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ũàŕàńţĩńē.", + "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ũàŕàńţĩńēď ďàţà", + "storageDeleteDialogDescription": "Ţĥĩś ćàńńōţ ƀē ũńďōńē. Ķàńďēv ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē {{path}}.", + "storageDeleteDialogFallbackPath": "ţĥē śēĺēćţēď qũàŕàńţĩńē ēńţŕŷ", + "storageDeletePermanently": "Ďēĺēţē ƥēŕḿàńēńţĺŷ", + "storageClearEligibleDialogTitle": "Ćĺēàŕ ēĺĩĝĩƀĺē qũàŕàńţĩńē", + "storageForceClearDialogTitle": "Ƒōŕćē ćĺēàŕ àĺĺ qũàŕàńţĩńē", + "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ēŕàƀĺē ďàţà ĥēŕē ƒĩŕśţ ĩńśţēàď ōƒ ďēĺēţĩńĝ ĩţ ĩḿḿēďĩàţēĺŷ. Ŕēśţōŕē àń ĩţēḿ ĩƒ ŷōũ śţĩĺĺ ńēēď ĩţ, ōŕ ďēĺēţē ĩţ ƥēŕḿàńēńţĺŷ ŵĥēń ŷōũ àŕē ćēŕţàĩń.", + "storageQuarantineEligibleCount_one": "{{count}} ēĺĩĝĩƀĺē ńōŵ", + "storageQuarantineEligibleCount_other": "{{count}} ēĺĩĝĩƀĺē ńōŵ", + "storageQuarantineProtectedCount_one": "{{count}} ƥŕōţēćţēď", + "storageQuarantineProtectedCount_other": "{{count}} ƥŕōţēćţēď", + "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": "ţàśķ ŵōŕķśƥàćē", + "storageResourceTypeGoCache": "ĝō ćàćĥē", + "storageTrashPath": "Ţŕàśĥ: {{path}}", + "storageEligibleNow": "Ēĺĩĝĩƀĺē ńōŵ", + "storageProtectedUntil": "Ƥŕōţēćţēď ũńţĩĺ <1>{{deadline}}", + "storageRestore": "Ŕēśţōŕē", + "storageDelete": "Ďēĺēţē", + "storageRetentionEnds": "Ŕēţēńţĩōń ēńďś {{deadline}}.", + "storageMaintenanceHistory": "Ḿàĩńţēńàńćē ĥĩśţōŕŷ", + "storageHistoryLoading": "Ĺōàďĩńĝ ḿàĩńţēńàńćē ĥĩśţōŕŷ…", + "storageHistoryEmpty": "Ńō śţōŕàĝē ḿàĩńţēńàńćē ŕũńś ŷēţ.", + "storageToastPolicySaved": "Śţōŕàĝē ƥōĺĩćŷ śàvēď", + "storageToastCacheAdopted": "Ĝō ƀũĩĺď ćàćĥē àďōƥţēď", + "storageToastDeletionStarted": "Ƥēŕḿàńēńţ ďēĺēţĩōń śţàŕţēď", + "storageToastEligiblePurgeStarted": "Ēĺĩĝĩƀĺē qũàŕàńţĩńē ćĺēàńũƥ śţàŕţēď", + "storageToastForcedPurgeStarted": "Ƒōŕćēď qũàŕàńţĩńē ćĺēàńũƥ śţàŕţēď", + "storageToastAnalysisStarted": "Śţōŕàĝē àńàĺŷśĩś śţàŕţēď", + "storageToastMaintenanceStarted": "Śţōŕàĝē ḿàĩńţēńàńćē śţàŕţēď", + "storageToastQuarantineRestored": "Qũàŕàńţĩńēď ŕēśōũŕćē ŕēśţōŕēď", + "storageRefreshFailed": "Ŕēƒŕēśĥ śţōŕàĝē ďàţà: {{message}}" +}