i18n(vmLxc): extract new LXC Updates + Options + App editor strings (EN only)

Extracts 212 hardcoded English strings from virtual-machines.tsx and
lxc-app-panel.tsx into AppImage/messages/en/common.json, matching the
i18n pattern established by PR #277.

Added namespaces under vmLxc:
- options (14 keys): Snapshot / Restart / Backup storage / Edit / Cancel / Save
- scheduled (22 keys): Scheduled updates, Frequency, Cron, target, Delete
- cronChip (6 keys): variants + scope for external cron detection
- cronPresets (6 keys): Hourly / Daily / Weekly / Monthly picker labels
- updates (58 keys): OS packages, Helper info, Apply buttons, custom
  command editor, footer combined buttons
- tabs (6 keys): tab labels
- appEditor (105 keys): app registration form — installed_via methods,
  upstream source selectors, ports, form field labels + placeholders

Other locales (sk/de/fr/it/pt/es) fall back to English via the provider's
built-in fallback until translations are contributed.

Follow-up: i18n coverage issue for translation work in the other locales.
This commit is contained in:
MacRimi
2026-08-09 00:49:44 +02:00
parent 06f41f5792
commit 05476b99c6
3 changed files with 997 additions and 282 deletions
+90 -90
View File
@@ -33,6 +33,7 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { useT } from "@/lib/i18n/provider"
// installed_via is optional now — an empty value means "register only, // installed_via is optional now — an empty value means "register only,
// no version tracking, no warnings, just a clickable link". Docker // no version tracking, no warnings, just a clickable link". Docker
@@ -216,6 +217,7 @@ function suggestPackageName(name: string) {
} }
export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
const t = useT()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [sidecar, setSidecar] = useState<SidecarResponse | null>(null) const [sidecar, setSidecar] = useState<SidecarResponse | null>(null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(null) const [suggestions, setSuggestions] = useState<Suggestions | null>(null)
@@ -474,7 +476,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
setEditing(null) setEditing(null)
onChange?.() onChange?.()
} catch (e: any) { } catch (e: any) {
setError(e?.message || "Save failed") setError(e?.message || t("vmLxc.appEditor.saveFailed"))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -517,7 +519,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
// waiting for the round-trip, then persist to the server. If the // waiting for the round-trip, then persist to the server. If the
// POST fails, reload from server to resync. // POST fails, reload from server to resync.
const dismissDetection = async (slug: string, name: string) => { const dismissDetection = async (slug: string, name: string) => {
if (!confirm(`Hide the "${name}" detection permanently on this container?\n\nTo bring it back later, click "Register a different app" — the hidden list appears there with a Restore option.`)) return if (!confirm(t("vmLxc.appEditor.confirmHide", { name }))) return
setSidecar((prev) => prev setSidecar((prev) => prev
? { ...prev, dismissed_slugs: [...(prev.dismissed_slugs || []), slug] } ? { ...prev, dismissed_slugs: [...(prev.dismissed_slugs || []), slug] }
: prev, : prev,
@@ -679,11 +681,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<div className={"mt-3 grid gap-3 " + (repo ? "grid-cols-2" : "grid-cols-1")}> <div className={"mt-3 grid gap-3 " + (repo ? "grid-cols-2" : "grid-cols-1")}>
{managed.installed_version && ( {managed.installed_version && (
<div className="p-3 rounded-md bg-muted/40"> <div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Installed</div> <div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2"> <div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
{managed.installed_version} {managed.installed_version}
{upToDate && ( {upToDate && (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label="Up to date" /> <CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
)} )}
</div> </div>
</div> </div>
@@ -694,9 +696,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
Latest upstream Latest upstream
</div> </div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}> <div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{managed.latest_version || <span className="text-muted-foreground text-base font-normal">checking</span>} {managed.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && managed.latest_version && ( {hasUpdate && managed.latest_version && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label="Update available" /> <ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)} )}
</div> </div>
</div> </div>
@@ -715,7 +717,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
CT's lifecycle is owned by ProxMenux (not user CRUD). */} CT's lifecycle is owned by ProxMenux (not user CRUD). */}
<div className="mt-4 p-2.5 rounded-md bg-green-500/10 border border-green-500/30 flex items-center gap-2 text-sm"> <div className="mt-4 p-2.5 rounded-md bg-green-500/10 border border-green-500/30 flex items-center gap-2 text-sm">
<ShieldCheck className="h-4 w-4 text-green-400 flex-shrink-0" /> <ShieldCheck className="h-4 w-4 text-green-400 flex-shrink-0" />
<span className="text-green-300">Installed and managed by ProxMenux</span> <span className="text-green-300">{t("vmLxc.appEditor.installedManaged")}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -789,7 +791,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<Card className="border border-border bg-card"> <Card className="border border-border bg-card">
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
<div className="relative"> <div className="relative">
<Label htmlFor="app-name">Name</Label> <Label htmlFor="app-name">{t("vmLxc.appEditor.nameLabel")}</Label>
<Input <Input
id="app-name" id="app-name"
value={draft.name} value={draft.name}
@@ -815,7 +817,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
return ( return (
<div className="absolute z-20 left-0 right-0 mt-1 max-h-72 overflow-y-auto rounded-md border border-border bg-popover shadow-lg"> <div className="absolute z-20 left-0 right-0 mt-1 max-h-72 overflow-y-auto rounded-md border border-border bg-popover shadow-lg">
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border/60"> <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border/60">
{matches.length === 20 ? "Top 20 matches — narrow the query for more" : `${matches.length} match${matches.length === 1 ? "" : "es"}`} {matches.length === 20 ? t("vmLxc.appEditor.top20Matches") : `${matches.length} match${matches.length === 1 ? "" : "es"}`}
</div> </div>
{matches.map((c) => ( {matches.map((c) => (
<button <button
@@ -899,7 +901,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
type="url" type="url"
value={draft.logo_url || ""} value={draft.logo_url || ""}
onChange={(e) => setField({ logo_url: e.target.value })} onChange={(e) => setField({ logo_url: e.target.value })}
placeholder="e.g., https://example.com/logo.webp" placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
maxLength={512} maxLength={512}
className="text-sm mt-2 font-mono" className="text-sm mt-2 font-mono"
/> />
@@ -968,7 +970,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
type="number" type="number"
value={entry.port} value={entry.port}
onChange={(e) => setPort(i, { port: e.target.value ? Number(e.target.value) : "" })} onChange={(e) => setPort(i, { port: e.target.value ? Number(e.target.value) : "" })}
placeholder="port" placeholder={t("vmLxc.appEditor.portPortPlaceholder")}
min={1} min={1}
max={65535} max={65535}
className="text-sm" className="text-sm"
@@ -976,7 +978,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<Input <Input
value={entry.description || ""} value={entry.description || ""}
onChange={(e) => setPort(i, { description: e.target.value })} onChange={(e) => setPort(i, { description: e.target.value })}
placeholder="Description (e.g. Web UI, go2rtc, admin)" placeholder={t("vmLxc.appEditor.portDescriptionPlaceholder")}
maxLength={64} maxLength={64}
className="text-sm" className="text-sm"
/> />
@@ -986,7 +988,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
size="sm" size="sm"
onClick={() => removePort(i)} onClick={() => removePort(i)}
className="text-red-400 hover:text-red-300" className="text-red-400 hover:text-red-300"
aria-label="Remove port" aria-label={t("vmLxc.appEditor.removePortTooltip")}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
@@ -996,7 +998,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<Input <Input
value={entry.logo_url || ""} value={entry.logo_url || ""}
onChange={(e) => setPort(i, { logo_url: e.target.value })} onChange={(e) => setPort(i, { logo_url: e.target.value })}
placeholder="Logo URL for this link (optional)" placeholder={t("vmLxc.appEditor.portLogoLabel")}
maxLength={512} maxLength={512}
className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100" className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100"
type="url" type="url"
@@ -1048,7 +1050,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="app-method">Installed via</Label> <Label htmlFor="app-method">{t("vmLxc.appEditor.installedViaLabel")}</Label>
<Select <Select
value={method || "none"} value={method || "none"}
onValueChange={(v) => onValueChange={(v) =>
@@ -1057,23 +1059,23 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
> >
<SelectTrigger id="app-method"><SelectValue /></SelectTrigger> <SelectTrigger id="app-method"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">None (link only)</SelectItem> <SelectItem value="none">{t("vmLxc.appEditor.methodNone")}</SelectItem>
<SelectItem value="dpkg">dpkg package (Debian/Ubuntu)</SelectItem> <SelectItem value="dpkg">{t("vmLxc.appEditor.methodDpkg")}</SelectItem>
<SelectItem value="apk">apk package (Alpine)</SelectItem> <SelectItem value="apk">{t("vmLxc.appEditor.methodApk")}</SelectItem>
<SelectItem value="binary">binary --version</SelectItem> <SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
<SelectItem value="file">file + regex</SelectItem> <SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
<SelectItem value="python_dist">python distribution (importlib.metadata)</SelectItem> <SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
<SelectItem value="docker_label">docker inspect (OCI label)</SelectItem> <SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
<SelectItem value="docker_exec">docker exec (binary in container)</SelectItem> <SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
<SelectItem value="command">command (advanced argv)</SelectItem> <SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
<SelectItem value="manual">manual (I type the version)</SelectItem> <SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{isPackaged && ( {isPackaged && (
<div> <div>
<Label htmlFor="app-package">Package identifier</Label> <Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
<Input <Input
id="app-package" id="app-package"
value={draft.package || ""} value={draft.package || ""}
@@ -1092,12 +1094,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "binary" && ( {method === "binary" && (
<div> <div>
<Label htmlFor="app-binary">Binary path</Label> <Label htmlFor="app-binary">{t("vmLxc.appEditor.binaryPathLabel")}</Label>
<Input <Input
id="app-binary" id="app-binary"
value={draft.binary_path || ""} value={draft.binary_path || ""}
onChange={(e) => setField({ binary_path: e.target.value })} onChange={(e) => setField({ binary_path: e.target.value })}
placeholder="e.g., /opt/<your-app>/binary" placeholder={t("vmLxc.appEditor.binaryPathPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
@@ -1112,7 +1114,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "file" && ( {method === "file" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="app-file-path">File path</Label> <Label htmlFor="app-file-path">{t("vmLxc.appEditor.filePathLabel")}</Label>
<Input <Input
id="app-file-path" id="app-file-path"
value={draft.file_path || ""} value={draft.file_path || ""}
@@ -1122,12 +1124,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
/> />
</div> </div>
<div> <div>
<Label htmlFor="app-file-regex">Regex (with capture group)</Label> <Label htmlFor="app-file-regex">{t("vmLxc.appEditor.regexCaptureGroupLabel")}</Label>
<Input <Input
id="app-file-regex" id="app-file-regex"
value={draft.file_regex || ""} value={draft.file_regex || ""}
onChange={(e) => setField({ file_regex: e.target.value })} onChange={(e) => setField({ file_regex: e.target.value })}
placeholder="version:\\s*(\\d+\\.\\d+\\.\\d+)" placeholder={t("vmLxc.appEditor.regexPlaceholderVersion")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
@@ -1137,22 +1139,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "python_dist" && ( {method === "python_dist" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="app-py-path">Python interpreter path</Label> <Label htmlFor="app-py-path">{t("vmLxc.appEditor.pythonInterpreterLabel")}</Label>
<Input <Input
id="app-py-path" id="app-py-path"
value={draft.python_path || ""} value={draft.python_path || ""}
onChange={(e) => setField({ python_path: e.target.value })} onChange={(e) => setField({ python_path: e.target.value })}
placeholder="e.g., /opt/<your-app>/venv/bin/python" placeholder={t("vmLxc.appEditor.pythonInterpreterPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
<div> <div>
<Label htmlFor="app-py-dist">Distribution name (pip package)</Label> <Label htmlFor="app-py-dist">{t("vmLxc.appEditor.pipDistLabel")}</Label>
<Input <Input
id="app-py-dist" id="app-py-dist"
value={draft.distribution || ""} value={draft.distribution || ""}
onChange={(e) => setField({ distribution: e.target.value })} onChange={(e) => setField({ distribution: e.target.value })}
placeholder="e.g., <your-pip-package>" placeholder={t("vmLxc.appEditor.pipDistPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
@@ -1162,22 +1164,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "docker_label" && ( {method === "docker_label" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="app-dl-container">Container name</Label> <Label htmlFor="app-dl-container">{t("vmLxc.appEditor.containerNameLabel")}</Label>
<Input <Input
id="app-dl-container" id="app-dl-container"
value={draft.container_name || ""} value={draft.container_name || ""}
onChange={(e) => setField({ container_name: e.target.value })} onChange={(e) => setField({ container_name: e.target.value })}
placeholder="e.g., <your-container-name>" placeholder={t("vmLxc.appEditor.containerNamePlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
<div> <div>
<Label htmlFor="app-dl-label">OCI label key</Label> <Label htmlFor="app-dl-label">{t("vmLxc.appEditor.ociLabelKeyLabel")}</Label>
<Input <Input
id="app-dl-label" id="app-dl-label"
value={draft.label || ""} value={draft.label || ""}
onChange={(e) => setField({ label: e.target.value })} onChange={(e) => setField({ label: e.target.value })}
placeholder="org.opencontainers.image.version" placeholder={t("vmLxc.appEditor.ociLabelPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
@@ -1187,38 +1189,38 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "docker_exec" && ( {method === "docker_exec" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="app-de-container">Container name</Label> <Label htmlFor="app-de-container">{t("vmLxc.appEditor.containerNameLabel")}</Label>
<Input <Input
id="app-de-container" id="app-de-container"
value={draft.container_name || ""} value={draft.container_name || ""}
onChange={(e) => setField({ container_name: e.target.value })} onChange={(e) => setField({ container_name: e.target.value })}
placeholder="e.g., <your-container-name>" placeholder={t("vmLxc.appEditor.containerNamePlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
<div> <div>
<Label htmlFor="app-de-binary">Binary (absolute path or bare name)</Label> <Label htmlFor="app-de-binary">{t("vmLxc.appEditor.binaryPathBare")}</Label>
<Input <Input
id="app-de-binary" id="app-de-binary"
value={draft.binary_path || ""} value={draft.binary_path || ""}
onChange={(e) => setField({ binary_path: e.target.value })} onChange={(e) => setField({ binary_path: e.target.value })}
placeholder="e.g., myapp (or absolute path)" placeholder={t("vmLxc.appEditor.binaryPathBarePlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label htmlFor="app-de-args">Binary args (comma-separated, optional)</Label> <Label htmlFor="app-de-args">{t("vmLxc.appEditor.binaryArgsLabel")}</Label>
<Input <Input
id="app-de-args" id="app-de-args"
value={(draft.binary_args || []).join(", ")} value={(draft.binary_args || []).join(", ")}
onChange={(e) => setField({ onChange={(e) => setField({
binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean), binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
})} })}
placeholder="e.g., --version (comma-separated)" placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
Left empty defaults to <code className="text-foreground/70">--version</code>. Grafana needs <code className="text-foreground/70">server, -v</code>. {t("vmLxc.appEditor.binaryArgsHintPrefix")} <code className="text-foreground/70">--version</code>. {t("vmLxc.appEditor.binaryArgsHintGrafana")} <code className="text-foreground/70">server, -v</code>.
</div> </div>
</div> </div>
</div> </div>
@@ -1226,26 +1228,26 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "command" && ( {method === "command" && (
<div> <div>
<Label htmlFor="app-cmd-argv">Command (comma-separated argv, no shell)</Label> <Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
<Input <Input
id="app-cmd-argv" id="app-cmd-argv"
value={(draft.command_argv || []).join(", ")} value={(draft.command_argv || []).join(", ")}
onChange={(e) => setField({ onChange={(e) => setField({
command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean), command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
})} })}
placeholder="myapp, version, --json" placeholder={t("vmLxc.appEditor.commandPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
Runs argv-style inside the CT as root never through a shell. You are responsible for what the command does. Use <code className="text-foreground/70">installed_regex</code> below to extract the version from the output. Runs argv-style inside the CT as root never through a shell. You are responsible for what the command does. Use <code className="text-foreground/70">installed_regex</code> below to extract the version from the output.
</div> </div>
<div className="mt-2"> <div className="mt-2">
<Label htmlFor="app-cmd-regex">Installed version regex (capture group)</Label> <Label htmlFor="app-cmd-regex">{t("vmLxc.appEditor.installedVersionRegexLabel")}</Label>
<Input <Input
id="app-cmd-regex" id="app-cmd-regex"
value={draft.installed_regex || ""} value={draft.installed_regex || ""}
onChange={(e) => setField({ installed_regex: e.target.value })} onChange={(e) => setField({ installed_regex: e.target.value })}
placeholder="v?(\\d+\\.\\d+\\.\\d+)" placeholder={t("vmLxc.appEditor.tagRegexBare")}
className="font-mono text-xs mt-1" className="font-mono text-xs mt-1"
/> />
</div> </div>
@@ -1254,7 +1256,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{method === "manual" && ( {method === "manual" && (
<div> <div>
<Label htmlFor="app-manual-ver">Installed version</Label> <Label htmlFor="app-manual-ver">{t("vmLxc.appEditor.installedVersionLabel")}</Label>
<Input <Input
id="app-manual-ver" id="app-manual-ver"
value={draft.installed_version || ""} value={draft.installed_version || ""}
@@ -1298,17 +1300,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
return ( return (
<> <>
<div> <div>
<Label htmlFor="app-upstream-type">Upstream version source (optional)</Label> <Label htmlFor="app-upstream-type">{t("vmLxc.appEditor.upstreamSourceLabel")}</Label>
<Select <Select
value={upstreamType || "none"} value={upstreamType || "none"}
onValueChange={(v) => setUpstream(v === "none" ? "" : (v as any))} onValueChange={(v) => setUpstream(v === "none" ? "" : (v as any))}
> >
<SelectTrigger id="app-upstream-type"><SelectValue /></SelectTrigger> <SelectTrigger id="app-upstream-type"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">none (skip upstream check)</SelectItem> <SelectItem value="none">{t("vmLxc.appEditor.upstreamNone")}</SelectItem>
<SelectItem value="github">GitHub repo</SelectItem> <SelectItem value="github">{t("vmLxc.appEditor.upstreamGithub")}</SelectItem>
<SelectItem value="http_json">HTTP JSON endpoint</SelectItem> <SelectItem value="http_json">{t("vmLxc.appEditor.upstreamHttp")}</SelectItem>
<SelectItem value="docker_hub">Docker Hub image</SelectItem> <SelectItem value="docker_hub">{t("vmLxc.appEditor.upstreamDocker")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
@@ -1319,27 +1321,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{upstreamType === "github" && ( {upstreamType === "github" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="app-repo">GitHub repo</Label> <Label htmlFor="app-repo">{t("vmLxc.appEditor.githubRepoLabel")}</Label>
<Input <Input
id="app-repo" id="app-repo"
value={draft.repo || ""} value={draft.repo || ""}
onChange={(e) => setField({ repo: e.target.value })} onChange={(e) => setField({ repo: e.target.value })}
placeholder="e.g., owner/name" placeholder={t("vmLxc.appEditor.githubRepoPlaceholder")}
/> />
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
Public GitHub repository where releases or tags are published. Public GitHub repository where releases or tags are published.
</div> </div>
</div> </div>
<div> <div>
<Label htmlFor="app-source">Latest from</Label> <Label htmlFor="app-source">{t("vmLxc.appEditor.latestFromLabel")}</Label>
<Select <Select
value={draft.github_source || "releases"} value={draft.github_source || "releases"}
onValueChange={(v) => setField({ github_source: v as GithubSource })} onValueChange={(v) => setField({ github_source: v as GithubSource })}
> >
<SelectTrigger id="app-source"><SelectValue /></SelectTrigger> <SelectTrigger id="app-source"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="releases">releases</SelectItem> <SelectItem value="releases">{t("vmLxc.appEditor.sourceReleases")}</SelectItem>
<SelectItem value="tags">tags</SelectItem> <SelectItem value="tags">{t("vmLxc.appEditor.sourceTags")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1349,13 +1351,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{upstreamType === "http_json" && ( {upstreamType === "http_json" && (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label htmlFor="app-upstream-url">Endpoint URL</Label> <Label htmlFor="app-upstream-url">{t("vmLxc.appEditor.endpointUrlLabel")}</Label>
<Input <Input
id="app-upstream-url" id="app-upstream-url"
type="url" type="url"
value={draft.upstream_url || ""} value={draft.upstream_url || ""}
onChange={(e) => setField({ upstream_url: e.target.value })} onChange={(e) => setField({ upstream_url: e.target.value })}
placeholder="e.g., https://vendor.example.com/api/latest.json" placeholder={t("vmLxc.appEditor.endpointUrlPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
maxLength={512} maxLength={512}
/> />
@@ -1364,12 +1366,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
</div> </div>
</div> </div>
<div> <div>
<Label htmlFor="app-upstream-path">JSON path</Label> <Label htmlFor="app-upstream-path">{t("vmLxc.appEditor.jsonPathLabel")}</Label>
<Input <Input
id="app-upstream-path" id="app-upstream-path"
value={draft.upstream_json_path || ""} value={draft.upstream_json_path || ""}
onChange={(e) => setField({ upstream_json_path: e.target.value })} onChange={(e) => setField({ upstream_json_path: e.target.value })}
placeholder="e.g., data.version or releases[0].tag_name" placeholder={t("vmLxc.appEditor.jsonPathPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
maxLength={128} maxLength={128}
/> />
@@ -1382,12 +1384,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{upstreamType === "docker_hub" && ( {upstreamType === "docker_hub" && (
<div> <div>
<Label htmlFor="app-docker-image">Docker Hub image</Label> <Label htmlFor="app-docker-image">{t("vmLxc.appEditor.dockerImageLabel")}</Label>
<Input <Input
id="app-docker-image" id="app-docker-image"
value={draft.docker_image || ""} value={draft.docker_image || ""}
onChange={(e) => setField({ docker_image: e.target.value })} onChange={(e) => setField({ docker_image: e.target.value })}
placeholder="e.g., linuxserver/plex or nginx" placeholder={t("vmLxc.appEditor.dockerImagePlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
maxLength={255} maxLength={255}
/> />
@@ -1402,13 +1404,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{upstreamType && ( {upstreamType && (
<div> <div>
<Label htmlFor="app-tag-regex"> <Label htmlFor="app-tag-regex">
{upstreamType === "docker_hub" ? "Tag filter regex (optional)" : "Version regex (optional)"} {upstreamType === "docker_hub" ? t("vmLxc.appEditor.tagFilterRegexOptional") : t("vmLxc.appEditor.versionRegexOptional")}
</Label> </Label>
<Input <Input
id="app-tag-regex" id="app-tag-regex"
value={draft.tag_regex || ""} value={draft.tag_regex || ""}
onChange={(e) => setField({ tag_regex: e.target.value })} onChange={(e) => setField({ tag_regex: e.target.value })}
placeholder="e.g., v?(\\d+\\.\\d+\\.\\d+)" placeholder={t("vmLxc.appEditor.tagRegexPlaceholder")}
className="font-mono text-xs" className="font-mono text-xs"
/> />
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
@@ -1433,14 +1435,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
)} )}
<div className="flex flex-wrap gap-2 justify-end pt-2"> <div className="flex flex-wrap gap-2 justify-end pt-2">
<Button variant="ghost" onClick={closeEditor} disabled={saving}>Cancel</Button> <Button variant="ghost" onClick={closeEditor} disabled={saving}>{t("vmLxc.appEditor.cancelButton")}</Button>
<Button <Button
onClick={save} onClick={save}
disabled={saving || !draft.name.trim()} disabled={saving || !draft.name.trim()}
className="bg-blue-500 hover:bg-blue-600 text-white" className="bg-blue-500 hover:bg-blue-600 text-white"
> >
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />} {saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
Save {t("vmLxc.appEditor.saveButton")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -1466,7 +1468,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
)} )}
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div> <div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
<div className="text-xs text-muted-foreground">Currently hidden will re-appear in the detection list</div> <div className="text-xs text-muted-foreground">{t("vmLxc.appEditor.hiddenBadge")}</div>
</div> </div>
</div> </div>
<Button <Button
@@ -1506,7 +1508,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
)} )}
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div> <div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
<div className="text-xs text-emerald-400/90">Detected on this container</div> <div className="text-xs text-emerald-400/90">{t("vmLxc.appEditor.detectedInContainer")}</div>
</div> </div>
</div> </div>
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto"> <div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
@@ -1523,11 +1525,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
variant="ghost" variant="ghost"
onClick={() => dismissDetection(d.slug, d.name)} onClick={() => dismissDetection(d.slug, d.name)}
aria-label={`Hide ${d.name} detection`} aria-label={`Hide ${d.name} detection`}
title="Hide this detection permanently" title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300" className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
> >
<EyeOff className="h-3.5 w-3.5 sm:mr-1" /> <EyeOff className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Hide</span> <span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -1546,19 +1548,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<CardContent className="p-4 space-y-3"> <CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<h3 className="text-sm font-semibold text-foreground">Register a different app</h3> <h3 className="text-sm font-semibold text-foreground">{t("vmLxc.appEditor.registerDifferent")}</h3>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
You have {hiddenDetections.length} hidden detection{hiddenDetections.length === 1 ? "" : "s"}. Restore one to bring it back, or register a custom app manually. {t(hiddenDetections.length === 1 ? "vmLxc.appEditor.hiddenDetectionsHelpSingular" : "vmLxc.appEditor.hiddenDetectionsHelpPlural", { count: hiddenDetections.length })}
</p> </p>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setBrowseOpen(false)} onClick={() => setBrowseOpen(false)}
aria-label="Close panel" aria-label={t("vmLxc.appEditor.closePanel")}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
Cancel {t("vmLxc.appEditor.cancelButton")}
</Button> </Button>
</div> </div>
{hiddenDetections.length > 0 && ( {hiddenDetections.length > 0 && (
@@ -1572,7 +1574,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
className="bg-blue-500 hover:bg-blue-600 text-white" className="bg-blue-500 hover:bg-blue-600 text-white"
> >
<PlusCircle className="h-4 w-4 mr-1.5" /> <PlusCircle className="h-4 w-4 mr-1.5" />
Register a custom app {t("vmLxc.appEditor.registerCustom")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -1590,12 +1592,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<Package className="h-5 w-5 text-emerald-400" /> <Package className="h-5 w-5 text-emerald-400" />
</div> </div>
<h3 className="text-sm font-semibold text-foreground text-center"> <h3 className="text-sm font-semibold text-foreground text-center">
No applications registered {t("vmLxc.appEditor.noAppsTitle")}
</h3> </h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center"> <p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
Register each application running in this container. Get {t("vmLxc.appEditor.noAppsBody")}
clickable web links, and optionally upstream version
tracking + notifications for new releases.
</p> </p>
{visibleDetected.length > 0 && ( {visibleDetected.length > 0 && (
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
@@ -1606,10 +1606,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<Button onClick={openBrowseOrEditor} variant={visibleDetected.length > 0 ? "outline" : "default"} <Button onClick={openBrowseOrEditor} variant={visibleDetected.length > 0 ? "outline" : "default"}
className={visibleDetected.length > 0 ? "" : "bg-blue-500 hover:bg-blue-600 text-white"}> className={visibleDetected.length > 0 ? "" : "bg-blue-500 hover:bg-blue-600 text-white"}>
<PlusCircle className="h-4 w-4 mr-1.5" /> <PlusCircle className="h-4 w-4 mr-1.5" />
{visibleDetected.length > 0 ? "Register a different app" : "Register application"} {visibleDetected.length > 0 ? t("vmLxc.appEditor.registerDifferent") : t("vmLxc.appEditor.registerApplication")}
{hiddenDetections.length > 0 && ( {hiddenDetections.length > 0 && (
<span className="ml-2 text-[10px] opacity-70"> <span className="ml-2 text-[10px] opacity-70">
· {hiddenDetections.length} hidden · {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
</span> </span>
)} )}
</Button> </Button>
@@ -1705,11 +1705,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
<div className={"mb-3 grid gap-3 " + (hasUpstream ? "grid-cols-2" : "grid-cols-1")}> <div className={"mb-3 grid gap-3 " + (hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
{st?.installed_version && ( {st?.installed_version && (
<div className="p-3 rounded-md bg-muted/40"> <div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Installed</div> <div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2"> <div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
{st.installed_version} {st.installed_version}
{upToDate && ( {upToDate && (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label="Up to date" /> <CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
)} )}
</div> </div>
</div> </div>
@@ -1720,9 +1720,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
Latest upstream Latest upstream
</div> </div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}> <div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">checking</span>} {st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && st?.latest_version && ( {hasUpdate && st?.latest_version && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label="Update available" /> <ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)} )}
</div> </div>
</div> </div>
+76 -79
View File
@@ -1229,12 +1229,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// 5-field cron expression the backend parser accepts. Order + slugs // 5-field cron expression the backend parser accepts. Order + slugs
// stable so the Select value round-trips a saved schedule. // stable so the Select value round-trips a saved schedule.
const CRON_PRESETS: { value: string; label: string; cron: string }[] = [ const CRON_PRESETS: { value: string; label: string; cron: string }[] = [
{ value: "hourly", label: "Hourly", cron: "0 * * * *" }, { value: "hourly", label: t("vmLxc.cronPresets.hourly"), cron: "0 * * * *" },
{ value: "daily-3am", label: "Daily at 3:00", cron: "0 3 * * *" }, { value: "daily-3am", label: t("vmLxc.cronPresets.dailyAt3"), cron: "0 3 * * *" },
{ value: "daily-noon", label: "Daily at 12:00", cron: "0 12 * * *" }, { value: "daily-noon", label: t("vmLxc.cronPresets.dailyAtNoon"), cron: "0 12 * * *" },
{ value: "weekly-sun-3am", label: "Weekly (Sunday 3:00)", cron: "0 3 * * 0" }, { value: "weekly-sun-3am", label: t("vmLxc.cronPresets.weeklySun3"), cron: "0 3 * * 0" },
{ value: "monthly-1st-3am", label: "Monthly (1st at 3:00)", cron: "0 3 1 * *" }, { value: "monthly-1st-3am", label: t("vmLxc.cronPresets.monthly1st3"), cron: "0 3 1 * *" },
{ value: "custom", label: "Custom cron…", cron: "" }, { value: "custom", label: t("vmLxc.cronPresets.custom"), cron: "" },
] ]
const loadSchedule = async (vmid: number) => { const loadSchedule = async (vmid: number) => {
@@ -1329,7 +1329,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
} }
const deleteScheduleFromOptions = async () => { const deleteScheduleFromOptions = async () => {
if (!selectedVM) return if (!selectedVM) return
if (!confirm("Remove the scheduled updates for this container? Apply defaults (backup + restart) are kept.")) return if (!confirm(t("vmLxc.scheduled.deleteConfirm"))) return
setScheduleSaving(true) setScheduleSaving(true)
try { try {
await fetchApi(`/api/vms/${selectedVM.vmid}/schedule`, { method: "DELETE" }) await fetchApi(`/api/vms/${selectedVM.vmid}/schedule`, { method: "DELETE" })
@@ -3530,7 +3530,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Last checked: {lastChecked} Last checked: {lastChecked}
{aw?.installed_version && <> · Tailscale v{aw.installed_version}</>} {aw?.installed_version && <> · Tailscale v{aw.installed_version}</>}
{" · "}<span className="text-emerald-400/90">No updates available</span> {" · "}<span className="text-emerald-400/90">{t("vmLxc.updates.noUpdatesAvailableChip")}</span>
</div> </div>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@@ -3640,13 +3640,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="pb-4"> <div className="pb-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-muted-foreground" /> <Package className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">OS packages</h3> <h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
</div> </div>
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
Last checked:{" "} {t("vmLxc.updates.lastCheckedPrefix")}{" "}
{uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"}
{uc?.os_family && ( {uc?.os_family && (
<> · Family: <code className="text-foreground/80">{uc.os_family}</code></> <> · {t("vmLxc.updates.familyLabel")} <code className="text-foreground/80">{uc.os_family}</code></>
)} )}
</div> </div>
{hasOsUpdates ? (() => { {hasOsUpdates ? (() => {
@@ -3661,7 +3661,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div key={p.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm"> <div key={p.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm">
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0"> <span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
{p.security && ( {p.security && (
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" aria-label="Security update" /> <Shield className="h-4 w-4 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
)} )}
<span className="truncate">{p.name}</span> <span className="truncate">{p.name}</span>
</span> </span>
@@ -3679,12 +3679,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="h-4 w-4 text-purple-300 flex-shrink-0" /> <Package className="h-4 w-4 text-purple-300 flex-shrink-0" />
<span><span className="font-semibold">{total}</span> package{total === 1 ? "" : "s"} pending</span> <span><span className="font-semibold">{total}</span> {t(total === 1 ? "vmLxc.updates.packagePendingLabel" : "vmLxc.updates.packagesPendingLabel")}</span>
</div> </div>
{sec > 0 && ( {sec > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" /> <Shield className="h-4 w-4 text-green-500 flex-shrink-0" />
<span><span className="font-semibold">{sec}</span> security update{sec === 1 ? "" : "s"}</span> <span><span className="font-semibold">{sec}</span> {t(sec === 1 ? "vmLxc.updates.securityUpdateLabel" : "vmLxc.updates.securityUpdatesLabel")}</span>
</div> </div>
)} )}
</div> </div>
@@ -3692,7 +3692,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
})() : ( })() : (
<div className="text-sm text-muted-foreground flex items-center gap-2"> <div className="text-sm text-muted-foreground flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
No OS updates pending {t("vmLxc.updates.noOsUpdatesPending")}
</div> </div>
)} )}
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
@@ -3702,7 +3702,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className={hasOsUpdates ? pendingBtnCls : upToDateBtnCls} className={hasOsUpdates ? pendingBtnCls : upToDateBtnCls}
> >
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{hasOsUpdates ? "Apply OS update" : "OS up to date"} {hasOsUpdates ? t("vmLxc.updates.applyOsUpdate") : t("vmLxc.updates.osUpToDate")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -3736,12 +3736,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="flex items-center gap-2 mb-3 min-w-0"> <div className="flex items-center gap-2 mb-3 min-w-0">
<Package className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Package className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h3 className="text-sm font-semibold text-foreground truncate"> <h3 className="text-sm font-semibold text-foreground truncate">
{helperName || "Application"} {helperName || t("vmLxc.updates.applicationDefaultName")}
</h3> </h3>
</div> </div>
{matchApp?.checked_at && ( {matchApp?.checked_at && (
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
Last checked: {new Date(matchApp.checked_at).toLocaleString()} {t("vmLxc.updates.lastCheckedPrefix")} {new Date(matchApp.checked_at).toLocaleString()}
</div> </div>
)} )}
{/* Educational note apps installed via {/* Educational note apps installed via
@@ -3764,8 +3764,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }} onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/> />
<span className="text-xs text-muted-foreground leading-relaxed"> <span className="text-xs text-muted-foreground leading-relaxed">
Installed by <span className="text-foreground/80">Proxmox Helper-Scripts</span> {t("vmLxc.updates.installedByHelperPrefix")} <span className="text-foreground/80">{t("vmLxc.updates.helperScriptsName")}</span>
{" "} updates run the community-scripts helper. {" "}{t("vmLxc.updates.helperUpdatesRun")}
</span> </span>
</div> </div>
)} )}
@@ -3773,7 +3773,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Package className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>installed <code className="text-foreground/80">{matchApp!.installed_version}</code></span> <span>{t("vmLxc.updates.installedLabel")} <code className="text-foreground/80">{matchApp!.installed_version}</code></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" /> <ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" />
@@ -3783,12 +3783,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : upToD ? ( ) : upToD ? (
<div className="text-sm text-muted-foreground flex items-center gap-2"> <div className="text-sm text-muted-foreground flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<span>Up to date at <code className="text-foreground/80">{matchApp!.installed_version}</code></span> <span>{t("vmLxc.updates.upToDateAtLabel")} <code className="text-foreground/80">{matchApp!.installed_version}</code></span>
</div> </div>
) : helperExists ? ( ) : helperExists ? (
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
Version tracking pending the next scheduled check will {t("vmLxc.updates.versionTrackingPending")}
populate installed and upstream numbers.
</p> </p>
) : null} ) : null}
{helperExists && (() => { {helperExists && (() => {
@@ -3806,7 +3805,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const noState = !hasUpd && !upToD const noState = !hasUpd && !upToD
const neutralBtnCls = "bg-muted/40 hover:bg-muted/60 border border-border text-foreground/80 hover:text-foreground" const neutralBtnCls = "bg-muted/40 hover:bg-muted/60 border border-border text-foreground/80 hover:text-foreground"
const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls
const label = hasUpd ? "Apply update" : upToD ? "Up to date" : "Run updater" const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
return ( return (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app")} className={cls}> <Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app")} className={cls}>
@@ -3861,19 +3860,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
{aw.checked_at && !editing && ( {aw.checked_at && !editing && (
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
Last checked: {new Date(aw.checked_at).toLocaleString()} {t("vmLxc.updates.lastCheckedPrefix")} {new Date(aw.checked_at).toLocaleString()}
</div> </div>
)} )}
{editing ? ( {editing ? (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground"> <Label className="text-xs uppercase tracking-wider text-muted-foreground">
Custom update command {t("vmLxc.updates.customCommandLabel")}
</Label> </Label>
<Textarea <Textarea
value={customCmdDraft} value={customCmdDraft}
onChange={(e) => setCustomCmdDraft(e.target.value)} onChange={(e) => setCustomCmdDraft(e.target.value)}
placeholder="e.g., cd /opt/<your-app> && git pull && systemctl restart <your-app>" placeholder={t("vmLxc.updates.customCommandPlaceholder")}
className="font-mono text-xs mt-2 min-h-[100px]" className="font-mono text-xs mt-2 min-h-[100px]"
maxLength={4096} maxLength={4096}
/> />
@@ -3888,7 +3887,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60" className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Remove {t("vmLxc.updates.removeButton")}
</button> </button>
)} )}
</div> </div>
@@ -3899,7 +3898,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
disabled={customCmdSaving} disabled={customCmdSaving}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60" className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
> >
Cancel {t("vmLxc.updates.cancelButton")}
</button> </button>
<button <button
type="button" type="button"
@@ -3908,7 +3907,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5" className="h-8 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
> >
{customCmdSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} {customCmdSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Save {t("vmLxc.updates.saveButton")}
</button> </button>
</div> </div>
</div> </div>
@@ -3919,7 +3918,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Package className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>installed <code className="text-foreground/80">{aw.installed_version}</code></span> <span>{t("vmLxc.updates.installedLabel")} <code className="text-foreground/80">{aw.installed_version}</code></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" /> <ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" />
@@ -3929,11 +3928,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : upToDate ? ( ) : upToDate ? (
<div className="text-sm text-muted-foreground flex items-center gap-2"> <div className="text-sm text-muted-foreground flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /> <CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<span>Up to date at <code className="text-foreground/80">{aw.installed_version}</code></span> <span>{t("vmLxc.updates.upToDateAtLabel")} <code className="text-foreground/80">{aw.installed_version}</code></span>
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Version tracking pending. {t("vmLxc.updates.versionTrackingPendingShort")}
</div> </div>
)} )}
{/* Buttons stacked bottom-right: {/* Buttons stacked bottom-right:
@@ -3954,7 +3953,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className={hasUpdate ? pendingBtnCls : upToDateBtnCls} className={hasUpdate ? pendingBtnCls : upToDateBtnCls}
> >
{hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{hasUpdate ? "Apply update" : "Up to date"} {hasUpdate ? t("vmLxc.updates.applyUpdate") : t("vmLxc.updates.upToDate")}
</Button> </Button>
<button <button
type="button" type="button"
@@ -3962,16 +3961,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5" className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
Edit command {t("vmLxc.updates.editCommandButton")}
</button> </button>
</div> </div>
</> </>
) : ( ) : (
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-xs text-muted-foreground leading-relaxed min-w-0"> <p className="text-xs text-muted-foreground leading-relaxed min-w-0">
No update method available. Update from the app itself, or {t("vmLxc.updates.noMethodBody")}
wire up a custom command that ProxMenux will run inside the
container.
</p> </p>
<button <button
type="button" type="button"
@@ -3979,7 +3976,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0" className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add custom update command {t("vmLxc.updates.wireUpCommandButton")}
</button> </button>
</div> </div>
)} )}
@@ -4024,7 +4021,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className={(hasOsUpdates || anyAppPending) ? pendingBtnCls : upToDateBtnCls} className={(hasOsUpdates || anyAppPending) ? pendingBtnCls : upToDateBtnCls}
> >
{(hasOsUpdates || anyAppPending) && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {(hasOsUpdates || anyAppPending) && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{(hasOsUpdates || anyAppPending) ? `Apply OS + ${combinedApp.name} updates` : `OS + ${combinedApp.name} updates`} {(hasOsUpdates || anyAppPending) ? t("vmLxc.updates.applyOsAppsFooter", { appName: combinedApp.name }) : t("vmLxc.updates.osAppsFooter", { appName: combinedApp.name })}
</Button> </Button>
</div> </div>
) )
@@ -4057,7 +4054,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className={(hasOsUpdates || anyAppPending) ? pendingBtnCls : upToDateBtnCls} className={(hasOsUpdates || anyAppPending) ? pendingBtnCls : upToDateBtnCls}
> >
{(hasOsUpdates || anyAppPending) && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {(hasOsUpdates || anyAppPending) && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{(hasOsUpdates || anyAppPending) ? "Apply OS + Apps updates" : "OS + Apps updates"} {(hasOsUpdates || anyAppPending) ? t("vmLxc.updates.applyOsAppsFooterPlural") : t("vmLxc.updates.osAppsFooterPlural")}
</Button> </Button>
</div> </div>
) )
@@ -4081,7 +4078,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Card className={optionsEditMode ? "border border-border bg-card" : "border border-border bg-card/50"}> <Card className={optionsEditMode ? "border border-border bg-card" : "border border-border bg-card/50"}>
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
<h3 className="text-sm font-semibold text-foreground"> <h3 className="text-sm font-semibold text-foreground">
Options {t("vmLxc.options.title")}
</h3> </h3>
{/* Two-mode rendering VIEW mode is a {/* Two-mode rendering VIEW mode is a
@@ -4106,8 +4103,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)} )}
<span> <span>
{applyBackup {applyBackup
? <>Snapshot before applying <span className="text-muted-foreground"> on <code className="text-foreground/80">{applyBackupStorage || selectedBackupStorage || "auto"}</code></span></> ? <>{t("vmLxc.options.snapshotBefore")} <span className="text-muted-foreground"> on <code className="text-foreground/80">{applyBackupStorage || selectedBackupStorage || t("vmLxc.options.storageAuto")}</code></span></>
: <span className="text-muted-foreground">No pre-apply snapshot</span>} : <span className="text-muted-foreground">{t("vmLxc.options.noSnapshot")}</span>}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -4118,8 +4115,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)} )}
<span> <span>
{applyRestart {applyRestart
? "Restart the container after applying" ? t("vmLxc.options.restartAfter")
: <span className="text-muted-foreground">No post-apply restart</span>} : <span className="text-muted-foreground">{t("vmLxc.options.noRestart")}</span>}
</span> </span>
</div> </div>
</div> </div>
@@ -4133,21 +4130,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="mt-0.5" className="mt-0.5"
/> />
<Label htmlFor="apply-backup" className="leading-tight cursor-pointer"> <Label htmlFor="apply-backup" className="leading-tight cursor-pointer">
<span>Snapshot the container before applying (vzdump)</span> <span>{t("vmLxc.options.snapshotLabel")}</span>
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
Applies to manual + scheduled runs. {t("vmLxc.options.appliesToBoth")}
</div> </div>
</Label> </Label>
</div> </div>
{applyBackup && backupStorages.length > 0 && ( {applyBackup && backupStorages.length > 0 && (
<div className="pl-6"> <div className="pl-6">
<Label className="text-xs text-muted-foreground">Backup storage</Label> <Label className="text-xs text-muted-foreground">{t("vmLxc.options.backupStorage")}</Label>
<Select <Select
value={applyBackupStorage || selectedBackupStorage} value={applyBackupStorage || selectedBackupStorage}
onValueChange={setApplyBackupStorage} onValueChange={setApplyBackupStorage}
> >
<SelectTrigger className="h-8 text-sm mt-1 max-w-xs"> <SelectTrigger className="h-8 text-sm mt-1 max-w-xs">
<SelectValue placeholder="Pick a storage" /> <SelectValue placeholder={t("vmLxc.options.pickStorage")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{backupStorages.map((s) => ( {backupStorages.map((s) => (
@@ -4167,9 +4164,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="mt-0.5" className="mt-0.5"
/> />
<Label htmlFor="apply-restart" className="leading-tight cursor-pointer"> <Label htmlFor="apply-restart" className="leading-tight cursor-pointer">
<span>Restart the container after applying</span> <span>{t("vmLxc.options.restartAfter")}</span>
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
Applies to manual + scheduled runs. {t("vmLxc.options.appliesToBoth")}
</div> </div>
</Label> </Label>
</div> </div>
@@ -4191,13 +4188,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-foreground"> <div className="text-sm font-medium text-foreground">
Scheduled updates {t("vmLxc.scheduled.title")}
</div> </div>
{optionsEditMode && ( {optionsEditMode && (
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
{scheduleEnabled {scheduleEnabled
? "Runs in the background using the apply options above." ? t("vmLxc.scheduled.enabledHelper")
: "Enable to run updates automatically on a cron schedule."} : t("vmLxc.scheduled.disabledHelper")}
</div> </div>
)} )}
</div> </div>
@@ -4223,19 +4220,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className={"h-2 w-2 rounded-full flex-shrink-0 " + (scheduleEnabled ? "bg-green-500" : "bg-muted-foreground/40")} /> <span className={"h-2 w-2 rounded-full flex-shrink-0 " + (scheduleEnabled ? "bg-green-500" : "bg-muted-foreground/40")} />
<span className={scheduleEnabled ? "" : "text-muted-foreground"}> <span className={scheduleEnabled ? "" : "text-muted-foreground"}>
<span className="text-foreground/80">Schedule</span> {humanCron(scheduleCron)} <span className="text-foreground/80">{t("vmLxc.scheduled.chipLabel")}</span> {humanCron(scheduleCron)}
{!scheduleEnabled && <span className="text-muted-foreground"> (disabled)</span>} {!scheduleEnabled && <span className="text-muted-foreground"> {t("vmLxc.scheduled.disabledSuffix")}</span>}
</span> </span>
</div> </div>
<div className="text-xs text-muted-foreground pl-4"> <div className="text-xs text-muted-foreground pl-4">
What: {scheduleTarget === "os" ? "OS packages" : scheduleTarget === "app" ? "Application" : "OS + application"} {t("vmLxc.scheduled.whatLabel")} {scheduleTarget === "os" ? t("vmLxc.scheduled.targetOs") : scheduleTarget === "app" ? t("vmLxc.scheduled.targetApp") : t("vmLxc.scheduled.targetBoth")}
</div> </div>
{scheduleLastRunAt && ( {scheduleLastRunAt && (
<div className="text-xs text-muted-foreground pl-4"> <div className="text-xs text-muted-foreground pl-4">
Last run: {new Date(scheduleLastRunAt).toLocaleString()} {t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
{scheduleLastRunStatus && ( {scheduleLastRunStatus && (
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : "text-red-400"}> <> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : "text-red-400"}>
{scheduleLastRunStatus === "success" ? "✓ success" : "✗ failed"} {scheduleLastRunStatus === "success" ? t("vmLxc.scheduled.runSuccess") : t("vmLxc.scheduled.runFailed")}
</span></> </span></>
)} )}
</div> </div>
@@ -4245,7 +4242,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{!optionsEditMode && !scheduleConfigured && !externalCron && ( {!optionsEditMode && !scheduleConfigured && !externalCron && (
<div className="text-sm flex items-center gap-2 text-muted-foreground"> <div className="text-sm flex items-center gap-2 text-muted-foreground">
<div className="h-4 w-4 rounded-full border border-muted-foreground/40 flex-shrink-0" /> <div className="h-4 w-4 rounded-full border border-muted-foreground/40 flex-shrink-0" />
<span>Not scheduled</span> <span>{t("vmLxc.scheduled.notScheduled")}</span>
</div> </div>
)} )}
@@ -4255,13 +4252,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
scripts logo per the user's ask. */} scripts logo per the user's ask. */}
{externalCron && optionsEditMode && (() => { {externalCron && optionsEditMode && (() => {
const variantLabel = externalCron.variant === "tteck-legacy" const variantLabel = externalCron.variant === "tteck-legacy"
? "tteck (legacy)" ? t("vmLxc.cronChip.variantTteck")
: externalCron.variant === "unknown" : externalCron.variant === "unknown"
? "custom" ? t("vmLxc.cronChip.variantCustom")
: "community-scripts" : t("vmLxc.cronChip.variantCommunityScripts")
const scopeText = externalCron.scope === "os" const scopeText = externalCron.scope === "os"
? "apt/apk on all CTs" ? t("vmLxc.cronChip.scopeAptApk")
: "scope unknown" : t("vmLxc.cronChip.scopeUnknown")
return ( return (
<div className="flex items-start gap-2 text-xs text-muted-foreground p-2 rounded-md bg-muted/40 border border-border/50"> <div className="flex items-start gap-2 text-xs text-muted-foreground p-2 rounded-md bg-muted/40 border border-border/50">
<img <img
@@ -4272,7 +4269,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-foreground"> <div className="text-foreground">
<span className="text-foreground/80">{variantLabel}</span> host cron detected <span className="text-foreground/80">{variantLabel}</span> {t("vmLxc.cronChip.detected")}
</div> </div>
<div className="mt-0.5"> <div className="mt-0.5">
{externalCron.human_schedule} {scopeText} {externalCron.human_schedule} {scopeText}
@@ -4291,7 +4288,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs text-muted-foreground">Frequency</Label> <Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.frequency")}</Label>
<Select <Select
value={schedulePreset} value={schedulePreset}
onValueChange={(v) => { onValueChange={(v) => {
@@ -4314,7 +4311,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</Select> </Select>
</div> </div>
<div> <div>
<Label className="text-xs text-muted-foreground">Cron expression</Label> <Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.cronExpression")}</Label>
<Input <Input
value={scheduleCron} value={scheduleCron}
onChange={(e) => { onChange={(e) => {
@@ -4322,13 +4319,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setSchedulePreset("custom") setSchedulePreset("custom")
}} }}
disabled={!optionsEditMode} disabled={!optionsEditMode}
placeholder="e.g., 0 3 * * *" placeholder={t("vmLxc.scheduled.cronPlaceholder")}
className="h-8 text-sm mt-1 font-mono" className="h-8 text-sm mt-1 font-mono"
/> />
</div> </div>
</div> </div>
<div> <div>
<Label className="text-xs text-muted-foreground">What to update</Label> <Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.whatToUpdate")}</Label>
<Select <Select
value={scheduleTarget} value={scheduleTarget}
onValueChange={(v) => setScheduleTarget(v as any)} onValueChange={(v) => setScheduleTarget(v as any)}
@@ -4338,9 +4335,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="os">OS packages only</SelectItem> <SelectItem value="os">{t("vmLxc.scheduled.targetOptionOs")}</SelectItem>
<SelectItem value="app">Application only</SelectItem> <SelectItem value="app">{t("vmLxc.scheduled.targetOptionApp")}</SelectItem>
<SelectItem value="both">OS + application</SelectItem> <SelectItem value="both">{t("vmLxc.scheduled.targetOptionBoth")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -4366,7 +4363,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60" className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete schedule {t("vmLxc.scheduled.deleteButton")}
</button> </button>
</div> </div>
)} )}
@@ -4388,7 +4385,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5" className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
> >
<Settings2 className="h-3.5 w-3.5" /> <Settings2 className="h-3.5 w-3.5" />
Edit {t("vmLxc.options.editButton")}
</button> </button>
) : ( ) : (
<> <>
@@ -4398,7 +4395,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
disabled={scheduleSaving} disabled={scheduleSaving}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60" className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
> >
Cancel {t("vmLxc.options.cancelButton")}
</button> </button>
<button <button
type="button" type="button"
@@ -4409,7 +4406,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{scheduleSaving {scheduleSaving
? <Loader2 className="h-3.5 w-3.5 animate-spin" /> ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Check className="h-3.5 w-3.5" />} : <Check className="h-3.5 w-3.5" />}
Save {t("vmLxc.options.saveButton")}
</button> </button>
</> </>
)} )}
File diff suppressed because it is too large Load Diff