Merge pull request #330 from Vaso73/i18n/sk-vm-lxc-complete

i18n: translate Slovak VM and LXC status and update scheduling
This commit is contained in:
MacRimi
2026-09-03 22:20:27 +02:00
committed by GitHub
5 changed files with 103 additions and 40 deletions
+3 -2
View File
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
@@ -173,6 +173,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
export function SystemOverview() {
const t = useT()
const { language } = useI18n()
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -511,7 +512,7 @@ export function SystemOverview() {
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.runningCount", { count: vmStats.running })}
{t(getCountFormKey(language, "overview.runningCount", vmStats.running), { count: vmStats.running })}
</Badge>
</div>
<div className="mt-3 flex gap-1 flex-wrap">
+24 -24
View File
@@ -27,7 +27,7 @@ import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import DOMPurify from "dompurify"
import { marked } from "marked"
import { useT } from "@/lib/i18n/provider"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
// Sent by /api/vms only for LXC rows, only when the user has enabled
// `lxc_updates_available` notifications. The Monitor populates this
@@ -753,6 +753,7 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
export function VirtualMachines() {
const t = useT()
const { language } = useI18n()
const {
data: vmData,
error,
@@ -1929,7 +1930,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
applySchedulePayload(s)
}
} catch (e: any) {
setScheduleError(e?.message || "Could not load schedule")
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
} finally {
setScheduleLoaded(vmid)
}
@@ -1953,7 +1954,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setBulkConfigured(false)
setBulkTargets(["os"])
setBulkPersistedTargets(["os"])
setBulkError(e?.message || "Could not load bulk update")
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
} finally {
setBulkLoaded(vmid)
}
@@ -2073,7 +2074,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
})
if (cronToSave.trim()) setScheduleConfigured(true)
} catch (e: any) {
setScheduleError(e?.message || "Save failed")
setScheduleError(e?.message || t("vmLxc.scheduled.saveFailed"))
} finally {
setScheduleSaving(false)
}
@@ -2132,13 +2133,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setScheduleLastRunRebootPackages([])
setScheduleReleaseDelayDays(0)
} catch (e: any) {
setScheduleError(e?.message || "Delete failed")
setScheduleError(e?.message || t("vmLxc.scheduled.deleteFailed"))
} finally {
setScheduleSaving(false)
}
}
// Turn a 5-field cron into a plain-English label — mirrors the
// Turn a 5-field cron into a localized label — mirrors the
// backend's _humanise_cron so view mode matches the picker's
// preset labels.
const humanCron = (expr: string): string => {
@@ -2151,15 +2152,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (isNaN(hn) || isNaN(mn)) return `${h}:${m}`
return `${String(hn).padStart(2, "0")}:${String(mn).padStart(2, "0")}`
}
if (d === "*" && mo === "*" && w === "*" && /^\d+$/.test(m) && /^\d+$/.test(h)) return `Daily at ${hhmm()}`
if (d === "*" && mo === "*" && w === "*" && /^\d+$/.test(m) && /^\d+$/.test(h)) return t("vmLxc.scheduled.humanDaily", { time: hhmm() })
if (d === "*" && mo === "*" && /^\d+$/.test(w) && /^\d+$/.test(m) && /^\d+$/.test(h)) {
const wdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
const wn = parseInt(w, 10)
const wname = (wn >= 0 && wn <= 6) ? wdays[wn] : w
return `Weekly (${wname} ${hhmm()})`
const wname = wn >= 0 && wn <= 6 ? t(`vmLxc.scheduled.weekdays.${wn}`) : w
return t("vmLxc.scheduled.humanWeekly", { day: wname, time: hhmm() })
}
if (mo === "*" && w === "*" && /^\d+$/.test(d) && /^\d+$/.test(m) && /^\d+$/.test(h)) return `Monthly (day ${parseInt(d, 10)} at ${hhmm()})`
if (h === "*" && d === "*" && mo === "*" && w === "*" && m === "0") return "Hourly"
if (mo === "*" && w === "*" && /^\d+$/.test(d) && /^\d+$/.test(m) && /^\d+$/.test(h)) return t("vmLxc.scheduled.humanMonthly", { day: parseInt(d, 10), time: hhmm() })
if (h === "*" && d === "*" && mo === "*" && w === "*" && m === "0") return t("vmLxc.scheduled.humanHourly")
return expr
}
@@ -2296,7 +2296,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// record and would drop any field we omitted.
const full: any = await fetchApi(`/api/vms/${vmid}/apps`)
const current = (full?.apps || []).find((a: any) => a.id === app.id)
if (!current) throw new Error("app not found in sidecar")
if (!current) throw new Error(t("vmLxc.errors.appNotFound"))
const { id: _id, state: _state, created_at: _created, ...rest } = current
const payload = { ...rest, ...patch }
const updated: any = await fetchApi(`/api/vms/${vmid}/apps/${app.id}`, {
@@ -2321,19 +2321,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
})
closeCustomCmdEditor()
} catch (e) {
alert(`Could not save custom command: ${(e as any)?.message || e}`)
alert(t("vmLxc.errors.saveCustomCommandFailed", { message: (e as any)?.message || String(e) }))
} finally {
setCustomCmdSaving(false)
}
}
const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => {
if (!confirm(`Remove the custom update command for "${app.name}"?`)) return
if (!confirm(t("vmLxc.errors.removeCustomCommandConfirm", { name: app.name }))) return
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, { update_command: "" })
closeCustomCmdEditor()
} catch (e) {
alert(`Could not remove custom command: ${(e as any)?.message || e}`)
alert(t("vmLxc.errors.removeCustomCommandFailed", { message: (e as any)?.message || String(e) }))
} finally {
setCustomCmdSaving(false)
}
@@ -2342,7 +2342,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
try {
await patchAppWatch(vmid, app, { hide_no_updater_notice: true })
} catch (e) {
alert(`Could not hide notice: ${(e as any)?.message || e}`)
alert(t("vmLxc.errors.hideNoticeFailed", { message: (e as any)?.message || String(e) }))
}
}
@@ -2363,10 +2363,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? ["docker-images"]
: ["apps"]
const fallbackLabels = target === "os"
? ["OS"]
? [t("vmLxc.bulkUpdate.osTarget")]
: target === "both"
? ["OS", "Applications"]
: [opts?.appName || (opts?.dockerEngine ? "Docker Engine" : "Application")]
? [t("vmLxc.bulkUpdate.osTarget"), t("vmLxc.scheduled.targetApp")]
: [opts?.appName || (opts?.dockerEngine ? "Docker Engine" : t("vmLxc.scheduled.targetApp"))]
const runId = typeof window !== "undefined" && typeof window.crypto?.randomUUID === "function"
? window.crypto.randomUUID()
: `manual-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
@@ -2817,7 +2817,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {total}</span>
</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.runningCount", { count: running })}
{t(getCountFormKey(language, "overview.runningCount", running), { count: running })}
</Badge>
</div>
<div className="mt-3 flex gap-1 flex-wrap">
@@ -2987,12 +2987,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</CardTitle>
<div
role="tablist"
aria-label="Filter by status"
aria-label={t("vmLxc.statusFilter.ariaLabel")}
className="inline-flex w-full sm:w-auto rounded-lg border border-border bg-muted/40 p-1 gap-1"
>
{(["all", "running", "stopped"] as const).map((key) => {
const active = statusFilter === key
const label = key === "all" ? "All" : key === "running" ? "Running" : "Stopped"
const label = t(`vmLxc.statusFilter.${key}`)
// Icon color: white when the tab is active (over the blue fill);
// green / red on inactive tabs so the state mapping stays legible
// before selection. The black-text variant was tested and dropped
@@ -3031,7 +3031,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="text-center py-8 text-muted-foreground">{t("vmLxc.empty")}</div>
) : filteredVMs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No {statusFilter === "running" ? "running" : "stopped"} virtual machines
{t("vmLxc.statusFilter.empty", { status: t(`vmLxc.statusFilter.${statusFilter}`) })}
</div>
) : (
<div className="space-y-3">
+14
View File
@@ -132,4 +132,18 @@ export function useT() {
return useI18n().t
}
export function getCountFormKey(language: LanguageCode, key: string, count: number): string {
if (language !== "sk") return key
const absoluteCount = Math.abs(count)
const lastDigit = absoluteCount % 10
const lastTwoDigits = absoluteCount % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${key}One`
if (lastDigit >= 2 && lastDigit <= 4 && !(lastTwoDigits >= 12 && lastTwoDigits <= 14)) {
return `${key}Few`
}
return `${key}Many`
}
export { SUPPORTED_LANGUAGES }
+27 -3
View File
@@ -97,6 +97,9 @@
"free": "Free",
"activeVmLxc": "Active VM & LXC",
"runningCount": "{count} running",
"runningCountOne": "{count} running",
"runningCountFew": "{count} running",
"runningCountMany": "{count} running",
"vmsCount": "{count} VMs",
"stoppedCount": "{count} stopped",
"temperature": "Temperature",
@@ -913,6 +916,13 @@
"idle": "Idle",
"listTitle": "Virtual Machines & Containers",
"empty": "No virtual machines found",
"statusFilter": {
"ariaLabel": "Filter virtual machines and containers by status",
"all": "All",
"running": "Running",
"stopped": "Stopped",
"empty": "No virtual machines or containers with status \"{status}\""
},
"uptime": "Uptime: {uptime}",
"cpuUsage": "CPU Usage",
"memory": "Memory",
@@ -1092,7 +1102,12 @@
"unknown": "Unknown error",
"backupStartFailed": "Failed to start backup: {message}",
"controlFailed": "Failed to {action} VM {vmid}: {message}",
"saveNotesFailed": "Error saving notes. Please try again."
"saveNotesFailed": "Error saving notes. Please try again.",
"appNotFound": "This application is no longer available. Refresh the page and try again.",
"saveCustomCommandFailed": "Could not save the custom update command: {message}",
"removeCustomCommandConfirm": "Remove the custom update command for \"{name}\"?",
"removeCustomCommandFailed": "Could not remove the custom update command: {message}",
"hideNoticeFailed": "Could not hide this notice: {message}"
},
"backupModal": {
"title": "Backup {type} {vmid} ({name})",
@@ -1197,7 +1212,15 @@
"noTargets": "No targets selected",
"selectAtLeastOne": "Select at least one update target.",
"deleteButton": "Delete schedule",
"deleteConfirm": "Remove the scheduled updates for this container? Apply defaults (backup + restart) are kept."
"deleteConfirm": "Remove the scheduled updates for this container? Apply defaults (backup + restart) are kept.",
"loadFailed": "Could not load the scheduled updates.",
"saveFailed": "Could not save the scheduled updates.",
"deleteFailed": "Could not remove the scheduled updates.",
"humanDaily": "Daily at {time}",
"humanWeekly": "Weekly ({day} {time})",
"humanMonthly": "Monthly (day {day} at {time})",
"humanHourly": "Hourly",
"weekdays": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
},
"cronChip": {
"detected": "host cron detected",
@@ -1343,7 +1366,8 @@
"deleteConfirm": "Remove this bulk update configuration?",
"saveFailed": "Could not save the bulk update configuration.",
"deleteFailed": "Could not remove the bulk update configuration.",
"planFailed": "Could not prepare the bulk update. Edit the configuration and review unavailable methods."
"planFailed": "Could not prepare the bulk update. Edit the configuration and review unavailable methods.",
"loadFailed": "Could not load the bulk update configuration."
},
"appEditor": {
"closePanel": "Close panel",
+35 -11
View File
@@ -96,7 +96,10 @@
"total": "Spolu",
"free": "Voľné",
"activeVmLxc": "Aktívne VM a LXC",
"runningCount": "{count} beží",
"runningCount": "{count} spustené",
"runningCountOne": "{count} spustený",
"runningCountFew": "{count} spustené",
"runningCountMany": "{count} spustených",
"vmsCount": "{count} VM",
"stoppedCount": "{count} vypnuté",
"temperature": "Teplota",
@@ -901,7 +904,7 @@
"totalCpuAllocated": "Pridelené CPU spolu",
"totalMemory": "Pamäť spolu",
"totalDisk": "Disk spolu",
"running": "beží",
"running": "spustené",
"stopped": "vypnuté",
"vms": "VM",
"used": "Použité",
@@ -913,6 +916,13 @@
"idle": "Voľné",
"listTitle": "Virtuálne stroje a kontajnery",
"empty": "Nenašli sa žiadne virtuálne stroje",
"statusFilter": {
"ariaLabel": "Filtrovať virtuálne stroje a kontajnery podľa stavu",
"all": "Všetky",
"running": "Spustené",
"stopped": "Vypnuté",
"empty": "Žiadne virtuálne stroje ani kontajnery so stavom „{status}“"
},
"uptime": "Beží: {uptime}",
"cpuUsage": "Využitie CPU",
"memory": "Pamäť",
@@ -1092,7 +1102,12 @@
"unknown": "Neznáma chyba",
"backupStartFailed": "Zálohu sa nepodarilo spustiť: {message}",
"controlFailed": "Akcia {action} pre VM {vmid} zlyhala: {message}",
"saveNotesFailed": "Poznámky sa nepodarilo uložiť. Skúste to znova."
"saveNotesFailed": "Poznámky sa nepodarilo uložiť. Skúste to znova.",
"appNotFound": "Táto aplikácia už nie je dostupná. Obnovte stránku a skúste to znova.",
"saveCustomCommandFailed": "Vlastný príkaz na aktualizáciu sa nepodarilo uložiť: {message}",
"removeCustomCommandConfirm": "Odstrániť vlastný príkaz na aktualizáciu pre „{name}“?",
"removeCustomCommandFailed": "Vlastný príkaz na aktualizáciu sa nepodarilo odstrániť: {message}",
"hideNoticeFailed": "Toto upozornenie sa nepodarilo skryť: {message}"
},
"backupModal": {
"title": "Záloha {type} {vmid} ({name})",
@@ -1197,7 +1212,15 @@
"noTargets": "Nie sú vybrané žiadne ciele",
"selectAtLeastOne": "Vyberte aspoň jeden cieľ aktualizácie.",
"deleteButton": "Odstrániť plán",
"deleteConfirm": "Odstrániť plánované aktualizácie tohto kontajnera? Predvolené nastavenia (záloha a reštart) zostanú zachované."
"deleteConfirm": "Odstrániť plánované aktualizácie tohto kontajnera? Predvolené nastavenia (záloha a reštart) zostanú zachované.",
"loadFailed": "Plánované aktualizácie sa nepodarilo načítať.",
"saveFailed": "Plánované aktualizácie sa nepodarilo uložiť.",
"deleteFailed": "Plánované aktualizácie sa nepodarilo odstrániť.",
"humanDaily": "Denne o {time}",
"humanWeekly": "Týždenne ({day} o {time})",
"humanMonthly": "Mesačne ({day}. deň o {time})",
"humanHourly": "Každú hodinu",
"weekdays": ["nedeľa", "pondelok", "utorok", "streda", "štvrtok", "piatok", "sobota"]
},
"cronChip": {
"detected": "zistený cron na serveri",
@@ -1343,7 +1366,8 @@
"deleteConfirm": "Odstrániť túto konfiguráciu hromadnej aktualizácie?",
"saveFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo uložiť.",
"deleteFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo odstrániť.",
"planFailed": "Hromadnú aktualizáciu sa nepodarilo pripraviť. Skontrolujte nedostupné metódy."
"planFailed": "Hromadnú aktualizáciu sa nepodarilo pripraviť. Skontrolujte nedostupné metódy.",
"loadFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo načítať."
},
"appEditor": {
"closePanel": "Zavrieť panel",
@@ -1441,16 +1465,16 @@
"upToDateBadge": "Aktuálne",
"updateAvailableBadge": "Dostupná aktualizácia",
"portDescriptionPlaceholder": "Popis (napr. Web UI, go2rtc, admin)",
"portPortPlaceholder": "prístav",
"portPortPlaceholder": "port",
"portHttp": "http",
"portHttps": "https",
"portLogoLabel": "URL loga pre tento odkaz (voliteľné)",
"portLogoPlaceholder": "napr. https://example.com/logo.webp",
"portCategoryPlaceholder": "Category for the Apps dashboard (optional)",
"portCategoryNone": "No category",
"portCategoryAddNew": "+ Add new category…",
"portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)",
"portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port",
"portCategoryPlaceholder": "Kategória pre prehľad aplikácií (voliteľné)",
"portCategoryNone": "Bez kategórie",
"portCategoryAddNew": "+ Pridať novú kategóriu…",
"portCategoryCustomPlaceholder": "Napíšte názov kategórie a stlačte Enter (Esc zruší)",
"portCustomUrlPlaceholder": "Vlastná URL (napr. https://vault.example.com) — nahradí IP:port",
"removePortTooltip": "Odstrániť port",
"detectMethodDpkg": "dpkg ·",
"detectMethodApk": "apk ·",