diff --git a/web/src/lib/components/app/copy-button.svelte b/web/src/lib/components/app/copy-button.svelte
index 4a8d1c7..ba27306 100644
--- a/web/src/lib/components/app/copy-button.svelte
+++ b/web/src/lib/components/app/copy-button.svelte
@@ -1,5 +1,6 @@
diff --git a/web/src/lib/components/app/service-file-tree.svelte b/web/src/lib/components/app/service-file-tree.svelte
index 24a1c7b..159b310 100644
--- a/web/src/lib/components/app/service-file-tree.svelte
+++ b/web/src/lib/components/app/service-file-tree.svelte
@@ -11,8 +11,11 @@
isEncryptedFilePath,
type ServiceFileNode,
} from '$lib/service-workspace';
+ import { getMessages } from '$lib/i18n';
import ServiceFileTree from './service-file-tree.svelte';
+ const messages = getMessages();
+
interface Props {
nodes?: ServiceFileNode[];
activePath?: string;
@@ -111,6 +114,7 @@
{/if}
{:else}
{@const iconName = nodeIconName(node)}
+ {@const encrypted = isEncryptedFilePath(node.path)}
- {#if isEncryptedFilePath(node.path)}
+ {#if encrypted}
@@ -143,9 +148,10 @@
{:else}
- {#if isEncryptedFilePath(node.path)}
+ {#if encrypted}
@@ -153,6 +159,9 @@
{/if}
{node.name}
+ {#if encrypted}
+ {$messages.services.files.encryptedFile}
+ {/if}
{/if}
diff --git a/web/src/lib/i18n/messages/de.ts b/web/src/lib/i18n/messages/de.ts
index cd12e46..ce71ba3 100644
--- a/web/src/lib/i18n/messages/de.ts
+++ b/web/src/lib/i18n/messages/de.ts
@@ -82,6 +82,7 @@ export const de = {
previous: "Vorherige",
submit: "Absenden",
copy: "Kopieren",
+ clipboardFailed: "Kopieren in die Zwischenablage fehlgeschlagen.",
copied: "Kopiert!",
download: "Herunterladen",
upload: "Hochladen",
@@ -108,6 +109,7 @@ export const de = {
options: "Optionen",
loadingWithDots: "Laden...",
for: "für",
+ to: "bis",
on: "auf",
na: "N/V",
skipToContent: "Zum Hauptinhalt springen",
@@ -312,6 +314,7 @@ export const de = {
newFolderPathPlaceholder: "config/snippets",
newFilePlaceholder: "neuer/pfad.yaml",
newServiceFolderPlaceholder: "neuer-dienst-ordner",
+ encryptedFile: "Verschlüsselte Datei",
encryptedUnavailableTitle: "Verschlüsselte Datei nicht verfügbar",
encryptedUnavailableMissingSecrets:
"Secrets-Unterstützung konfigurieren, bevor verschlüsselte Dateien im Editor geöffnet werden.",
diff --git a/web/src/lib/i18n/messages/en-us.ts b/web/src/lib/i18n/messages/en-us.ts
index bc32c96..e269737 100644
--- a/web/src/lib/i18n/messages/en-us.ts
+++ b/web/src/lib/i18n/messages/en-us.ts
@@ -88,6 +88,7 @@ export const enUS = {
previous: "Previous",
submit: "Submit",
copy: "Copy",
+ clipboardFailed: "Failed to copy to clipboard.",
copied: "Copied!",
download: "Download",
upload: "Upload",
@@ -114,6 +115,7 @@ export const enUS = {
options: "Options",
loadingWithDots: "Loading...",
for: "for",
+ to: "to",
on: "on",
na: "N/A",
skipToContent: "Skip to main content",
@@ -312,6 +314,7 @@ export const enUS = {
newFolderPathPlaceholder: "config/snippets",
newFilePlaceholder: "new/path.yaml",
newServiceFolderPlaceholder: "new-service-folder",
+ encryptedFile: "Encrypted file",
encryptedUnavailableTitle: "Encrypted file is unavailable",
encryptedUnavailableMissingSecrets:
"Configure secrets support before opening encrypted files in the editor.",
diff --git a/web/src/lib/i18n/messages/fr.ts b/web/src/lib/i18n/messages/fr.ts
index ef1c5da..69a2bef 100644
--- a/web/src/lib/i18n/messages/fr.ts
+++ b/web/src/lib/i18n/messages/fr.ts
@@ -83,6 +83,7 @@ export const fr = {
previous: "Précédent",
submit: "Envoyer",
copy: "Copier",
+ clipboardFailed: "Échec de la copie dans le presse-papiers.",
copied: "Copié !",
download: "Télécharger",
upload: "Téléverser",
@@ -109,6 +110,7 @@ export const fr = {
options: "Options",
loadingWithDots: "Chargement...",
for: "pour",
+ to: "à",
on: "sur",
na: "N/D",
skipToContent: "Aller au contenu principal",
@@ -313,6 +315,7 @@ export const fr = {
newFolderPathPlaceholder: "config/snippets",
newFilePlaceholder: "nouveau/chemin.yaml",
newServiceFolderPlaceholder: "nouveau-dossier-service",
+ encryptedFile: "Fichier chiffré",
encryptedUnavailableTitle: "Fichier chiffré indisponible",
encryptedUnavailableMissingSecrets:
"Configurez le support des secrets avant d'ouvrir des fichiers chiffrés dans l'éditeur.",
diff --git a/web/src/lib/i18n/messages/ja.ts b/web/src/lib/i18n/messages/ja.ts
index 1f663e6..c63bbf2 100644
--- a/web/src/lib/i18n/messages/ja.ts
+++ b/web/src/lib/i18n/messages/ja.ts
@@ -83,6 +83,7 @@ export const ja = {
previous: "前へ",
submit: "送信",
copy: "コピー",
+ clipboardFailed: "クリップボードにコピーできませんでした。",
copied: "コピーしました!",
download: "ダウンロード",
upload: "アップロード",
@@ -109,6 +110,7 @@ export const ja = {
options: "オプション",
loadingWithDots: "読み込み中...",
for: "対象",
+ to: "から",
on: "対象ノード",
na: "N/A",
skipToContent: "メインコンテンツへスキップ",
@@ -311,6 +313,7 @@ export const ja = {
newFolderPathPlaceholder: "config/snippets",
newFilePlaceholder: "new/path.yaml",
newServiceFolderPlaceholder: "new-service-folder",
+ encryptedFile: "暗号化ファイル",
encryptedUnavailableTitle: "暗号化ファイルは利用できません",
encryptedUnavailableMissingSecrets:
"エディターで暗号化ファイルを開く前に、Secrets サポートを設定してください。",
diff --git a/web/src/lib/i18n/messages/zh-hans.ts b/web/src/lib/i18n/messages/zh-hans.ts
index 377dbaa..cdf0c52 100644
--- a/web/src/lib/i18n/messages/zh-hans.ts
+++ b/web/src/lib/i18n/messages/zh-hans.ts
@@ -81,6 +81,7 @@ export const zhHans = {
previous: "上一步",
submit: "提交",
copy: "复制",
+ clipboardFailed: "复制到剪贴板失败。",
copied: "已复制!",
download: "下载",
upload: "上传",
@@ -107,6 +108,7 @@ export const zhHans = {
options: "选项",
loadingWithDots: "加载中...",
for: "用于",
+ to: "到",
on: "在",
na: "无",
skipToContent: "跳到主内容",
@@ -306,6 +308,7 @@ export const zhHans = {
newFolderPathPlaceholder: "config/片段",
newFilePlaceholder: "新/路径.yaml",
newServiceFolderPlaceholder: "新服务文件夹",
+ encryptedFile: "加密文件",
},
operations: {
title: "操作",
diff --git a/web/src/lib/i18n/messages/zh-hant.ts b/web/src/lib/i18n/messages/zh-hant.ts
index 5cd279d..eebe936 100644
--- a/web/src/lib/i18n/messages/zh-hant.ts
+++ b/web/src/lib/i18n/messages/zh-hant.ts
@@ -81,6 +81,7 @@ export const zhHant = {
previous: "上一步",
submit: "提交",
copy: "複製",
+ clipboardFailed: "複製到剪貼簿失敗。",
copied: "已複製!",
download: "下載",
upload: "上傳",
@@ -107,6 +108,7 @@ export const zhHant = {
options: "選項",
loadingWithDots: "載入中...",
for: "用於",
+ to: "到",
on: "在",
na: "無",
skipToContent: "跳到主內容",
@@ -306,6 +308,7 @@ export const zhHant = {
newFolderPathPlaceholder: "config/片段",
newFilePlaceholder: "新/路徑.yaml",
newServiceFolderPlaceholder: "新服務資料夾",
+ encryptedFile: "加密檔案",
},
operations: {
title: "操作",
diff --git a/web/src/lib/presenters.test.mjs b/web/src/lib/presenters.test.mjs
index 55efa43..25c8c3b 100644
--- a/web/src/lib/presenters.test.mjs
+++ b/web/src/lib/presenters.test.mjs
@@ -1,13 +1,23 @@
import { assertEquals } from "jsr:@std/assert@1.0.19/equals";
-import { formatBytes, formatDuration } from "./presenters.ts";
+import {
+ formatBytes,
+ formatDockerTimestamp,
+ formatDuration,
+} from "./presenters.ts";
Deno.test("formats binary byte units", () => {
- assertEquals(formatBytes(0), "0 B");
- assertEquals(formatBytes(1536), "1.5 KiB");
+ assertEquals(formatBytes(0, "en-US"), "0 B");
+ assertEquals(formatBytes(1536, "en-US"), "1.5 KiB");
+ assertEquals(formatBytes(1536, "de-DE"), "1,5 KiB");
});
-Deno.test("formats elapsed time", () => {
+Deno.test("formats elapsed time with locale-aware units", () => {
const twoMinutesAgo = new Date(Date.now() - 120_000).toISOString();
- assertEquals(formatDuration(twoMinutesAgo), "2 minutes");
+ assertEquals(formatDuration(twoMinutesAgo, "en-US"), "2 minutes");
+});
+
+Deno.test("formats docker timestamps as localized relative time", () => {
+ const twoMinutesAgo = new Date(Date.now() - 120_000).toISOString();
+ assertEquals(formatDockerTimestamp(twoMinutesAgo, "en-US"), "2 minutes ago");
});
diff --git a/web/src/lib/presenters.ts b/web/src/lib/presenters.ts
index 57b35c5..1dc714a 100644
--- a/web/src/lib/presenters.ts
+++ b/web/src/lib/presenters.ts
@@ -2,12 +2,14 @@ import type { BadgeVariant } from "$lib/components/ui/badge";
import type { Dictionary } from "$lib/i18n";
-export function formatTimestamp(value: string) {
+export function formatTimestamp(value: string, locale = currentLocale()) {
if (!value) {
return "-";
}
const parsed = parseTimestamp(value);
- return parsed ? parsed.toLocaleString() : value;
+ return parsed
+ ? new Intl.DateTimeFormat(locale, dateTimeFormat).format(parsed)
+ : value;
}
export function taskStatusTone(status: string): BadgeVariant {
@@ -158,13 +160,16 @@ export function isTaskRecent(createdAt: string): boolean {
return parsed ? parsed.getTime() > Date.now() - 86_400_000 : false;
}
-export function formatDuration(startedAt: string): string {
+export function formatDuration(
+ startedAt: string,
+ locale = currentLocale(),
+): string {
if (!startedAt) return "-";
const start = parseTimestamp(startedAt);
- return start ? formatDistance(start, new Date()) : "-";
+ return start ? formatDistance(start, new Date(), locale) : "-";
}
-export function formatBytes(bytes: number) {
+export function formatBytes(bytes: number, locale = currentLocale()) {
const value = Math.max(0, bytes || 0);
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
const unitIndex = Math.min(
@@ -172,7 +177,7 @@ export function formatBytes(bytes: number) {
units.length - 1,
);
const amount = value / 1024 ** unitIndex;
- return `${new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(amount)} ${units[unitIndex]}`;
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(amount)} ${units[unitIndex]}`;
}
export function formatShortId(value: string, length = 12) {
@@ -192,7 +197,10 @@ export function parseJsonList(rawJson: string) {
return Array.isArray(parsed) ? parsed[0] : parsed;
}
-export function formatDockerTimestamp(timestamp: string) {
+export function formatDockerTimestamp(
+ timestamp: string,
+ locale = currentLocale(),
+) {
if (!timestamp) {
return "-";
}
@@ -202,9 +210,22 @@ export function formatDockerTimestamp(timestamp: string) {
return timestamp;
}
- return parsed.getTime() > Date.now()
- ? "just now"
- : `${formatDistance(parsed, new Date())} ago`;
+ return formatRelativeTime(parsed, new Date(), locale);
+}
+
+const dateTimeFormat: Intl.DateTimeFormatOptions = {
+ dateStyle: "medium",
+ timeStyle: "medium",
+};
+
+function currentLocale() {
+ if (typeof document !== "undefined" && document.documentElement.lang) {
+ return document.documentElement.lang;
+ }
+ if (typeof navigator !== "undefined") {
+ return navigator.languages?.[0] ?? navigator.language;
+ }
+ return "en-US";
}
function parseTimestamp(value: string): Date | null {
@@ -217,12 +238,34 @@ function parseDockerTimestamp(value: string): Date | null {
return parseTimestamp(cleaned.replace(" ", "T"));
}
-function formatDistance(from: Date, to: Date) {
+function formatDistance(from: Date, to: Date, locale: string) {
+ const [amount, unit] = elapsedUnit(from, to);
+ return new Intl.NumberFormat(locale, {
+ style: "unit",
+ unit,
+ unitDisplay: "long",
+ maximumFractionDigits: 0,
+ }).format(amount);
+}
+
+function formatRelativeTime(from: Date, to: Date, locale: string) {
+ const [amount, unit] = elapsedUnit(from, to);
+ const sign = from.getTime() > to.getTime() ? amount : -amount;
+ return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
+ sign,
+ unit,
+ );
+}
+
+function elapsedUnit(
+ from: Date,
+ to: Date,
+): [number, Intl.RelativeTimeFormatUnit] {
const seconds = Math.max(
0,
- Math.floor((to.getTime() - from.getTime()) / 1000),
+ Math.floor(Math.abs(to.getTime() - from.getTime()) / 1000),
);
- const units: Array<[number, string]> = [
+ const units: Array<[number, Intl.RelativeTimeFormatUnit]> = [
[31_536_000, "year"],
[2_592_000, "month"],
[604_800, "week"],
@@ -232,6 +275,5 @@ function formatDistance(from: Date, to: Date) {
[1, "second"],
];
const [size, unit] = units.find(([size]) => seconds >= size) ?? units.at(-1)!;
- const amount = Math.floor(seconds / size);
- return `${amount} ${unit}${amount === 1 ? "" : "s"}`;
+ return [Math.floor(seconds / size), unit];
}
diff --git a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte
index f3a2688..3e3b436 100644
--- a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte
+++ b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte
@@ -1,5 +1,7 @@