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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+831
-113
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user