mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-09 01:06:22 +00:00
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:
@@ -33,6 +33,7 @@ import { Label } from "./ui/label"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
// installed_via is optional now — an empty value means "register only,
|
||||
// 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) {
|
||||
const t = useT()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)
|
||||
@@ -474,7 +476,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
setEditing(null)
|
||||
onChange?.()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Save failed")
|
||||
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||
} finally {
|
||||
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
|
||||
// POST fails, reload from server to resync.
|
||||
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
|
||||
? { ...prev, dismissed_slugs: [...(prev.dismissed_slugs || []), slug] }
|
||||
: 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")}>
|
||||
{managed.installed_version && (
|
||||
<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">
|
||||
{managed.installed_version}
|
||||
{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>
|
||||
@@ -694,9 +696,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
Latest upstream
|
||||
</div>
|
||||
<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 && (
|
||||
<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>
|
||||
@@ -715,7 +717,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
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">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -789,7 +791,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
<Card className="border border-border bg-card">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="relative">
|
||||
<Label htmlFor="app-name">Name</Label>
|
||||
<Label htmlFor="app-name">{t("vmLxc.appEditor.nameLabel")}</Label>
|
||||
<Input
|
||||
id="app-name"
|
||||
value={draft.name}
|
||||
@@ -815,7 +817,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
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="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>
|
||||
{matches.map((c) => (
|
||||
<button
|
||||
@@ -899,7 +901,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
type="url"
|
||||
value={draft.logo_url || ""}
|
||||
onChange={(e) => setField({ logo_url: e.target.value })}
|
||||
placeholder="e.g., https://example.com/logo.webp"
|
||||
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
||||
maxLength={512}
|
||||
className="text-sm mt-2 font-mono"
|
||||
/>
|
||||
@@ -968,7 +970,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
type="number"
|
||||
value={entry.port}
|
||||
onChange={(e) => setPort(i, { port: e.target.value ? Number(e.target.value) : "" })}
|
||||
placeholder="port"
|
||||
placeholder={t("vmLxc.appEditor.portPortPlaceholder")}
|
||||
min={1}
|
||||
max={65535}
|
||||
className="text-sm"
|
||||
@@ -976,7 +978,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
<Input
|
||||
value={entry.description || ""}
|
||||
onChange={(e) => setPort(i, { description: e.target.value })}
|
||||
placeholder="Description (e.g. Web UI, go2rtc, admin)"
|
||||
placeholder={t("vmLxc.appEditor.portDescriptionPlaceholder")}
|
||||
maxLength={64}
|
||||
className="text-sm"
|
||||
/>
|
||||
@@ -986,7 +988,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
size="sm"
|
||||
onClick={() => removePort(i)}
|
||||
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" />
|
||||
</Button>
|
||||
@@ -996,7 +998,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
<Input
|
||||
value={entry.logo_url || ""}
|
||||
onChange={(e) => setPort(i, { logo_url: e.target.value })}
|
||||
placeholder="Logo URL for this link (optional)"
|
||||
placeholder={t("vmLxc.appEditor.portLogoLabel")}
|
||||
maxLength={512}
|
||||
className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100"
|
||||
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>
|
||||
<Label htmlFor="app-method">Installed via</Label>
|
||||
<Label htmlFor="app-method">{t("vmLxc.appEditor.installedViaLabel")}</Label>
|
||||
<Select
|
||||
value={method || "none"}
|
||||
onValueChange={(v) =>
|
||||
@@ -1057,23 +1059,23 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
>
|
||||
<SelectTrigger id="app-method"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (link only)</SelectItem>
|
||||
<SelectItem value="dpkg">dpkg package (Debian/Ubuntu)</SelectItem>
|
||||
<SelectItem value="apk">apk package (Alpine)</SelectItem>
|
||||
<SelectItem value="binary">binary --version</SelectItem>
|
||||
<SelectItem value="file">file + regex</SelectItem>
|
||||
<SelectItem value="python_dist">python distribution (importlib.metadata)</SelectItem>
|
||||
<SelectItem value="docker_label">docker inspect (OCI label)</SelectItem>
|
||||
<SelectItem value="docker_exec">docker exec (binary in container)</SelectItem>
|
||||
<SelectItem value="command">command (advanced — argv)</SelectItem>
|
||||
<SelectItem value="manual">manual (I type the version)</SelectItem>
|
||||
<SelectItem value="none">{t("vmLxc.appEditor.methodNone")}</SelectItem>
|
||||
<SelectItem value="dpkg">{t("vmLxc.appEditor.methodDpkg")}</SelectItem>
|
||||
<SelectItem value="apk">{t("vmLxc.appEditor.methodApk")}</SelectItem>
|
||||
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
|
||||
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
|
||||
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
|
||||
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
|
||||
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
||||
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
||||
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isPackaged && (
|
||||
<div>
|
||||
<Label htmlFor="app-package">Package identifier</Label>
|
||||
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
|
||||
<Input
|
||||
id="app-package"
|
||||
value={draft.package || ""}
|
||||
@@ -1092,12 +1094,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
|
||||
{method === "binary" && (
|
||||
<div>
|
||||
<Label htmlFor="app-binary">Binary path</Label>
|
||||
<Label htmlFor="app-binary">{t("vmLxc.appEditor.binaryPathLabel")}</Label>
|
||||
<Input
|
||||
id="app-binary"
|
||||
value={draft.binary_path || ""}
|
||||
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"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
@@ -1112,7 +1114,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{method === "file" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="app-file-path">File path</Label>
|
||||
<Label htmlFor="app-file-path">{t("vmLxc.appEditor.filePathLabel")}</Label>
|
||||
<Input
|
||||
id="app-file-path"
|
||||
value={draft.file_path || ""}
|
||||
@@ -1122,12 +1124,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="app-file-regex">Regex (with capture group)</Label>
|
||||
<Label htmlFor="app-file-regex">{t("vmLxc.appEditor.regexCaptureGroupLabel")}</Label>
|
||||
<Input
|
||||
id="app-file-regex"
|
||||
value={draft.file_regex || ""}
|
||||
onChange={(e) => setField({ file_regex: e.target.value })}
|
||||
placeholder="version:\\s*(\\d+\\.\\d+\\.\\d+)"
|
||||
placeholder={t("vmLxc.appEditor.regexPlaceholderVersion")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1137,22 +1139,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{method === "python_dist" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="app-py-path">Python interpreter path</Label>
|
||||
<Label htmlFor="app-py-path">{t("vmLxc.appEditor.pythonInterpreterLabel")}</Label>
|
||||
<Input
|
||||
id="app-py-path"
|
||||
value={draft.python_path || ""}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="app-py-dist">Distribution name (pip package)</Label>
|
||||
<Label htmlFor="app-py-dist">{t("vmLxc.appEditor.pipDistLabel")}</Label>
|
||||
<Input
|
||||
id="app-py-dist"
|
||||
value={draft.distribution || ""}
|
||||
onChange={(e) => setField({ distribution: e.target.value })}
|
||||
placeholder="e.g., <your-pip-package>"
|
||||
placeholder={t("vmLxc.appEditor.pipDistPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1162,22 +1164,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{method === "docker_label" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="app-dl-container">Container name</Label>
|
||||
<Label htmlFor="app-dl-container">{t("vmLxc.appEditor.containerNameLabel")}</Label>
|
||||
<Input
|
||||
id="app-dl-container"
|
||||
value={draft.container_name || ""}
|
||||
onChange={(e) => setField({ container_name: e.target.value })}
|
||||
placeholder="e.g., <your-container-name>"
|
||||
placeholder={t("vmLxc.appEditor.containerNamePlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="app-dl-label">OCI label key</Label>
|
||||
<Label htmlFor="app-dl-label">{t("vmLxc.appEditor.ociLabelKeyLabel")}</Label>
|
||||
<Input
|
||||
id="app-dl-label"
|
||||
value={draft.label || ""}
|
||||
onChange={(e) => setField({ label: e.target.value })}
|
||||
placeholder="org.opencontainers.image.version"
|
||||
placeholder={t("vmLxc.appEditor.ociLabelPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1187,38 +1189,38 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{method === "docker_exec" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="app-de-container">Container name</Label>
|
||||
<Label htmlFor="app-de-container">{t("vmLxc.appEditor.containerNameLabel")}</Label>
|
||||
<Input
|
||||
id="app-de-container"
|
||||
value={draft.container_name || ""}
|
||||
onChange={(e) => setField({ container_name: e.target.value })}
|
||||
placeholder="e.g., <your-container-name>"
|
||||
placeholder={t("vmLxc.appEditor.containerNamePlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</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
|
||||
id="app-de-binary"
|
||||
value={draft.binary_path || ""}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="app-de-args"
|
||||
value={(draft.binary_args || []).join(", ")}
|
||||
onChange={(e) => setField({
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
@@ -1226,26 +1228,26 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
|
||||
{method === "command" && (
|
||||
<div>
|
||||
<Label htmlFor="app-cmd-argv">Command (comma-separated argv, no shell)</Label>
|
||||
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
|
||||
<Input
|
||||
id="app-cmd-argv"
|
||||
value={(draft.command_argv || []).join(", ")}
|
||||
onChange={(e) => setField({
|
||||
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"
|
||||
/>
|
||||
<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.
|
||||
</div>
|
||||
<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
|
||||
id="app-cmd-regex"
|
||||
value={draft.installed_regex || ""}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -1254,7 +1256,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
|
||||
{method === "manual" && (
|
||||
<div>
|
||||
<Label htmlFor="app-manual-ver">Installed version</Label>
|
||||
<Label htmlFor="app-manual-ver">{t("vmLxc.appEditor.installedVersionLabel")}</Label>
|
||||
<Input
|
||||
id="app-manual-ver"
|
||||
value={draft.installed_version || ""}
|
||||
@@ -1298,17 +1300,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="app-upstream-type">Upstream version source (optional)</Label>
|
||||
<Label htmlFor="app-upstream-type">{t("vmLxc.appEditor.upstreamSourceLabel")}</Label>
|
||||
<Select
|
||||
value={upstreamType || "none"}
|
||||
onValueChange={(v) => setUpstream(v === "none" ? "" : (v as any))}
|
||||
>
|
||||
<SelectTrigger id="app-upstream-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">none (skip upstream check)</SelectItem>
|
||||
<SelectItem value="github">GitHub repo</SelectItem>
|
||||
<SelectItem value="http_json">HTTP JSON endpoint</SelectItem>
|
||||
<SelectItem value="docker_hub">Docker Hub image</SelectItem>
|
||||
<SelectItem value="none">{t("vmLxc.appEditor.upstreamNone")}</SelectItem>
|
||||
<SelectItem value="github">{t("vmLxc.appEditor.upstreamGithub")}</SelectItem>
|
||||
<SelectItem value="http_json">{t("vmLxc.appEditor.upstreamHttp")}</SelectItem>
|
||||
<SelectItem value="docker_hub">{t("vmLxc.appEditor.upstreamDocker")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
@@ -1319,27 +1321,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{upstreamType === "github" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="app-repo">GitHub repo</Label>
|
||||
<Label htmlFor="app-repo">{t("vmLxc.appEditor.githubRepoLabel")}</Label>
|
||||
<Input
|
||||
id="app-repo"
|
||||
value={draft.repo || ""}
|
||||
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">
|
||||
Public GitHub repository where releases or tags are published.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="app-source">Latest from</Label>
|
||||
<Label htmlFor="app-source">{t("vmLxc.appEditor.latestFromLabel")}</Label>
|
||||
<Select
|
||||
value={draft.github_source || "releases"}
|
||||
onValueChange={(v) => setField({ github_source: v as GithubSource })}
|
||||
>
|
||||
<SelectTrigger id="app-source"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="releases">releases</SelectItem>
|
||||
<SelectItem value="tags">tags</SelectItem>
|
||||
<SelectItem value="releases">{t("vmLxc.appEditor.sourceReleases")}</SelectItem>
|
||||
<SelectItem value="tags">{t("vmLxc.appEditor.sourceTags")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1349,13 +1351,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{upstreamType === "http_json" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="app-upstream-url">Endpoint URL</Label>
|
||||
<Label htmlFor="app-upstream-url">{t("vmLxc.appEditor.endpointUrlLabel")}</Label>
|
||||
<Input
|
||||
id="app-upstream-url"
|
||||
type="url"
|
||||
value={draft.upstream_url || ""}
|
||||
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"
|
||||
maxLength={512}
|
||||
/>
|
||||
@@ -1364,12 +1366,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="app-upstream-path">JSON path</Label>
|
||||
<Label htmlFor="app-upstream-path">{t("vmLxc.appEditor.jsonPathLabel")}</Label>
|
||||
<Input
|
||||
id="app-upstream-path"
|
||||
value={draft.upstream_json_path || ""}
|
||||
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"
|
||||
maxLength={128}
|
||||
/>
|
||||
@@ -1382,12 +1384,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
|
||||
{upstreamType === "docker_hub" && (
|
||||
<div>
|
||||
<Label htmlFor="app-docker-image">Docker Hub image</Label>
|
||||
<Label htmlFor="app-docker-image">{t("vmLxc.appEditor.dockerImageLabel")}</Label>
|
||||
<Input
|
||||
id="app-docker-image"
|
||||
value={draft.docker_image || ""}
|
||||
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"
|
||||
maxLength={255}
|
||||
/>
|
||||
@@ -1402,13 +1404,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
{upstreamType && (
|
||||
<div>
|
||||
<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>
|
||||
<Input
|
||||
id="app-tag-regex"
|
||||
value={draft.tag_regex || ""}
|
||||
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"
|
||||
/>
|
||||
<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">
|
||||
<Button variant="ghost" onClick={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={closeEditor} disabled={saving}>{t("vmLxc.appEditor.cancelButton")}</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={saving || !draft.name.trim()}
|
||||
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" />}
|
||||
Save
|
||||
{t("vmLxc.appEditor.saveButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1466,7 +1468,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<Button
|
||||
@@ -1506,7 +1508,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<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 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"
|
||||
onClick={() => dismissDetection(d.slug, d.name)}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1546,19 +1548,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setBrowseOpen(false)}
|
||||
aria-label="Close panel"
|
||||
aria-label={t("vmLxc.appEditor.closePanel")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
{t("vmLxc.appEditor.cancelButton")}
|
||||
</Button>
|
||||
</div>
|
||||
{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"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-1.5" />
|
||||
Register a custom app
|
||||
{t("vmLxc.appEditor.registerCustom")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1590,12 +1592,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
<Package className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground text-center">
|
||||
No applications registered
|
||||
{t("vmLxc.appEditor.noAppsTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
||||
Register each application running in this container. Get
|
||||
clickable web links, and — optionally — upstream version
|
||||
tracking + notifications for new releases.
|
||||
{t("vmLxc.appEditor.noAppsBody")}
|
||||
</p>
|
||||
{visibleDetected.length > 0 && (
|
||||
<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"}
|
||||
className={visibleDetected.length > 0 ? "" : "bg-blue-500 hover:bg-blue-600 text-white"}>
|
||||
<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 && (
|
||||
<span className="ml-2 text-[10px] opacity-70">
|
||||
· {hiddenDetections.length} hidden
|
||||
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
|
||||
</span>
|
||||
)}
|
||||
</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")}>
|
||||
{st?.installed_version && (
|
||||
<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">
|
||||
{st.installed_version}
|
||||
{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>
|
||||
@@ -1720,9 +1720,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
||||
Latest upstream
|
||||
</div>
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
@@ -1229,12 +1229,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// 5-field cron expression the backend parser accepts. Order + slugs
|
||||
// stable so the Select value round-trips a saved schedule.
|
||||
const CRON_PRESETS: { value: string; label: string; cron: string }[] = [
|
||||
{ value: "hourly", label: "Hourly", cron: "0 * * * *" },
|
||||
{ value: "daily-3am", label: "Daily at 3:00", cron: "0 3 * * *" },
|
||||
{ value: "daily-noon", label: "Daily at 12:00", cron: "0 12 * * *" },
|
||||
{ value: "weekly-sun-3am", label: "Weekly (Sunday 3:00)", cron: "0 3 * * 0" },
|
||||
{ value: "monthly-1st-3am", label: "Monthly (1st at 3:00)", cron: "0 3 1 * *" },
|
||||
{ value: "custom", label: "Custom cron…", cron: "" },
|
||||
{ value: "hourly", label: t("vmLxc.cronPresets.hourly"), cron: "0 * * * *" },
|
||||
{ value: "daily-3am", label: t("vmLxc.cronPresets.dailyAt3"), cron: "0 3 * * *" },
|
||||
{ value: "daily-noon", label: t("vmLxc.cronPresets.dailyAtNoon"), cron: "0 12 * * *" },
|
||||
{ value: "weekly-sun-3am", label: t("vmLxc.cronPresets.weeklySun3"), cron: "0 3 * * 0" },
|
||||
{ value: "monthly-1st-3am", label: t("vmLxc.cronPresets.monthly1st3"), cron: "0 3 1 * *" },
|
||||
{ value: "custom", label: t("vmLxc.cronPresets.custom"), cron: "" },
|
||||
]
|
||||
|
||||
const loadSchedule = async (vmid: number) => {
|
||||
@@ -1329,7 +1329,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
const deleteScheduleFromOptions = async () => {
|
||||
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)
|
||||
try {
|
||||
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">
|
||||
Last checked: {lastChecked}
|
||||
{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 className="text-xs text-muted-foreground">
|
||||
@@ -3640,13 +3640,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="pb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<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 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?.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>
|
||||
{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">
|
||||
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
|
||||
{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>
|
||||
@@ -3679,12 +3679,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{sec > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
@@ -3692,7 +3692,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
})() : (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
No OS updates pending
|
||||
{t("vmLxc.updates.noOsUpdatesPending")}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end">
|
||||
@@ -3702,7 +3702,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className={hasOsUpdates ? pendingBtnCls : upToDateBtnCls}
|
||||
>
|
||||
{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>
|
||||
</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">
|
||||
<Package className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">
|
||||
{helperName || "Application"}
|
||||
{helperName || t("vmLxc.updates.applicationDefaultName")}
|
||||
</h3>
|
||||
</div>
|
||||
{matchApp?.checked_at && (
|
||||
<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>
|
||||
)}
|
||||
{/* 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" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
Installed by <span className="text-foreground/80">Proxmox Helper-Scripts</span>
|
||||
{" "}— updates run the community-scripts helper.
|
||||
{t("vmLxc.updates.installedByHelperPrefix")} <span className="text-foreground/80">{t("vmLxc.updates.helperScriptsName")}</span>
|
||||
{" "}{t("vmLxc.updates.helperUpdatesRun")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -3773,7 +3773,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" />
|
||||
@@ -3783,12 +3783,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
) : upToD ? (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<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>
|
||||
) : helperExists ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Version tracking pending — the next scheduled check will
|
||||
populate installed and upstream numbers.
|
||||
{t("vmLxc.updates.versionTrackingPending")}
|
||||
</p>
|
||||
) : null}
|
||||
{helperExists && (() => {
|
||||
@@ -3806,7 +3805,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const noState = !hasUpd && !upToD
|
||||
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 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 (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app")} className={cls}>
|
||||
@@ -3861,19 +3860,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
{aw.checked_at && !editing && (
|
||||
<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>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Custom update command
|
||||
{t("vmLxc.updates.customCommandLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={customCmdDraft}
|
||||
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]"
|
||||
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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
{t("vmLxc.updates.removeButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -3899,7 +3898,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
{t("vmLxc.updates.cancelButton")}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3919,7 +3918,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" />
|
||||
@@ -3929,11 +3928,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
) : upToDate ? (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<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 className="text-sm text-muted-foreground">
|
||||
Version tracking pending.
|
||||
{t("vmLxc.updates.versionTrackingPendingShort")}
|
||||
</div>
|
||||
)}
|
||||
{/* Buttons stacked bottom-right:
|
||||
@@ -3954,7 +3953,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className={hasUpdate ? pendingBtnCls : upToDateBtnCls}
|
||||
>
|
||||
{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
|
||||
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"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit command
|
||||
{t("vmLxc.updates.editCommandButton")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed min-w-0">
|
||||
No update method available. Update from the app itself, or
|
||||
wire up a custom command that ProxMenux will run inside the
|
||||
container.
|
||||
{t("vmLxc.updates.noMethodBody")}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add custom update command
|
||||
{t("vmLxc.updates.wireUpCommandButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -4024,7 +4021,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className={(hasOsUpdates || anyAppPending) ? pendingBtnCls : upToDateBtnCls}
|
||||
>
|
||||
{(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>
|
||||
</div>
|
||||
)
|
||||
@@ -4057,7 +4054,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className={(hasOsUpdates || anyAppPending) ? pendingBtnCls : upToDateBtnCls}
|
||||
>
|
||||
{(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>
|
||||
</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"}>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Options
|
||||
{t("vmLxc.options.title")}
|
||||
</h3>
|
||||
|
||||
{/* Two-mode rendering — VIEW mode is a
|
||||
@@ -4106,8 +4103,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
<span>
|
||||
{applyBackup
|
||||
? <>Snapshot before applying <span className="text-muted-foreground">— on <code className="text-foreground/80">{applyBackupStorage || selectedBackupStorage || "auto"}</code></span></>
|
||||
: <span className="text-muted-foreground">No pre-apply snapshot</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">{t("vmLxc.options.noSnapshot")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -4118,8 +4115,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
<span>
|
||||
{applyRestart
|
||||
? "Restart the container after applying"
|
||||
: <span className="text-muted-foreground">No post-apply restart</span>}
|
||||
? t("vmLxc.options.restartAfter")
|
||||
: <span className="text-muted-foreground">{t("vmLxc.options.noRestart")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4133,21 +4130,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<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">
|
||||
Applies to manual + scheduled runs.
|
||||
{t("vmLxc.options.appliesToBoth")}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{applyBackup && backupStorages.length > 0 && (
|
||||
<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
|
||||
value={applyBackupStorage || selectedBackupStorage}
|
||||
onValueChange={setApplyBackupStorage}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm mt-1 max-w-xs">
|
||||
<SelectValue placeholder="Pick a storage" />
|
||||
<SelectValue placeholder={t("vmLxc.options.pickStorage")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{backupStorages.map((s) => (
|
||||
@@ -4167,9 +4164,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<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">
|
||||
Applies to manual + scheduled runs.
|
||||
{t("vmLxc.options.appliesToBoth")}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -4191,13 +4188,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
Scheduled updates
|
||||
{t("vmLxc.scheduled.title")}
|
||||
</div>
|
||||
{optionsEditMode && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{scheduleEnabled
|
||||
? "Runs in the background using the apply options above."
|
||||
: "Enable to run updates automatically on a cron schedule."}
|
||||
? t("vmLxc.scheduled.enabledHelper")
|
||||
: t("vmLxc.scheduled.disabledHelper")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -4223,19 +4220,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<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={scheduleEnabled ? "" : "text-muted-foreground"}>
|
||||
<span className="text-foreground/80">Schedule</span> — {humanCron(scheduleCron)}
|
||||
{!scheduleEnabled && <span className="text-muted-foreground"> (disabled)</span>}
|
||||
<span className="text-foreground/80">{t("vmLxc.scheduled.chipLabel")}</span> — {humanCron(scheduleCron)}
|
||||
{!scheduleEnabled && <span className="text-muted-foreground"> {t("vmLxc.scheduled.disabledSuffix")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
{scheduleLastRunAt && (
|
||||
<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 && (
|
||||
<> · <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></>
|
||||
)}
|
||||
</div>
|
||||
@@ -4245,7 +4242,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{!optionsEditMode && !scheduleConfigured && !externalCron && (
|
||||
<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" />
|
||||
<span>Not scheduled</span>
|
||||
<span>{t("vmLxc.scheduled.notScheduled")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4255,13 +4252,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
scripts logo per the user's ask. */}
|
||||
{externalCron && optionsEditMode && (() => {
|
||||
const variantLabel = externalCron.variant === "tteck-legacy"
|
||||
? "tteck (legacy)"
|
||||
? t("vmLxc.cronChip.variantTteck")
|
||||
: externalCron.variant === "unknown"
|
||||
? "custom"
|
||||
: "community-scripts"
|
||||
? t("vmLxc.cronChip.variantCustom")
|
||||
: t("vmLxc.cronChip.variantCommunityScripts")
|
||||
const scopeText = externalCron.scope === "os"
|
||||
? "apt/apk on all CTs"
|
||||
: "scope unknown"
|
||||
? t("vmLxc.cronChip.scopeAptApk")
|
||||
: t("vmLxc.cronChip.scopeUnknown")
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground p-2 rounded-md bg-muted/40 border border-border/50">
|
||||
<img
|
||||
@@ -4272,7 +4269,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 className="mt-0.5">
|
||||
{externalCron.human_schedule} — {scopeText}
|
||||
@@ -4291,7 +4288,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Frequency</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.frequency")}</Label>
|
||||
<Select
|
||||
value={schedulePreset}
|
||||
onValueChange={(v) => {
|
||||
@@ -4314,7 +4311,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</Select>
|
||||
</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
|
||||
value={scheduleCron}
|
||||
onChange={(e) => {
|
||||
@@ -4322,13 +4319,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setSchedulePreset("custom")
|
||||
}}
|
||||
disabled={!optionsEditMode}
|
||||
placeholder="e.g., 0 3 * * *"
|
||||
placeholder={t("vmLxc.scheduled.cronPlaceholder")}
|
||||
className="h-8 text-sm mt-1 font-mono"
|
||||
/>
|
||||
</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
|
||||
value={scheduleTarget}
|
||||
onValueChange={(v) => setScheduleTarget(v as any)}
|
||||
@@ -4338,9 +4335,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="os">OS packages only</SelectItem>
|
||||
<SelectItem value="app">Application only</SelectItem>
|
||||
<SelectItem value="both">OS + application</SelectItem>
|
||||
<SelectItem value="os">{t("vmLxc.scheduled.targetOptionOs")}</SelectItem>
|
||||
<SelectItem value="app">{t("vmLxc.scheduled.targetOptionApp")}</SelectItem>
|
||||
<SelectItem value="both">{t("vmLxc.scheduled.targetOptionBoth")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete schedule
|
||||
{t("vmLxc.scheduled.deleteButton")}
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
{t("vmLxc.options.editButton")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -4398,7 +4395,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
{t("vmLxc.options.cancelButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -4409,7 +4406,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{scheduleSaving
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <Check className="h-3.5 w-3.5" />}
|
||||
Save
|
||||
{t("vmLxc.options.saveButton")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user