Merge develop into PR #337 and resolve shared app cache conflict

This commit is contained in:
MacRimi
2026-09-05 16:51:41 +02:00
37 changed files with 5970 additions and 797 deletions
+68 -75
View File
@@ -3,7 +3,7 @@
import type React from "react"
import { useState, useMemo, useEffect, useRef } from "react"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
@@ -22,6 +22,7 @@ import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo } from "./lxc-app-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
@@ -104,6 +105,8 @@ interface LxcAppWatch {
health_path?: string | null
installed_version: string | null
latest_version: string | null
latest_published_at?: string | null
state_revision?: number
update_available: boolean | null
error: string | null
checked_at: string | null
@@ -117,6 +120,7 @@ interface LxcAppWatch {
// update method. When set, the Updates tab renders an "Apply {app}"
// button that runs `pct exec vmid -- sh -c "$update_command"`.
update_command?: string
update_method?: AppUpdateMethod
// Updates tab: per-app dismiss for the "no update method defined"
// notice. Only hides the notice — the App tab still shows purple ⬆
// when an update is available upstream.
@@ -232,13 +236,21 @@ function normalizeVmSearchValue(value: unknown): string {
.toLocaleLowerCase()
}
function matchesVmSearch(vm: VMData, terms: string[]): boolean {
// Type synonyms come from the active locale so a user can search by the
// word they'd naturally use ("contenedor", "machine virtuelle", …) rather
// than only the internal type token. "lxc" / "qemu" already match through
// vm.type, so the catalog entries only carry the natural-language terms.
function matchesVmSearch(
vm: VMData,
terms: string[],
typeSynonyms: { lxc: string; qemu: string },
): boolean {
if (terms.length === 0) return true
const searchable = normalizeVmSearchValue([
vm.name,
vm.vmid,
vm.type,
vm.type === "lxc" ? "container kontajner" : "virtual machine virtualny stroj",
vm.type === "lxc" ? typeSynonyms.lxc : typeSynonyms.qemu,
vm.tags,
vm.description,
vm.ip,
@@ -624,6 +636,9 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
})
const flags = optsEntries.filter((o) => o.value === null).map((o) => o.key)
const keyValues = optsEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
const runtimeError = mp.runtime_error === "configured but not mounted"
? t("vmLxc.details.mountErrors.configuredButNotMounted")
: mp.runtime_error
return (
<div className={`rounded-lg p-4 ${cardClasses}`}>
@@ -792,13 +807,13 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
)}
{/* Error / divergence note. */}
{mp.runtime_error && (
{runtimeError && (
<p
className={`mt-3 text-sm ${
isStale ? "text-red-400" : "text-amber-400"
}`}
>
{mp.runtime_error}
{runtimeError}
</p>
)}
</div>
@@ -1120,6 +1135,12 @@ export function VirtualMachines() {
setSelectedVM(updated)
}, [vmData])
useEffect(() => {
for (const vm of vmData || []) {
if (vm.type === "lxc") syncLxcAppsState(vm.vmid, vm.app_watches || [])
}
}, [vmData])
// Backend lifecycle refreshes are asynchronous: a start response returns
// immediately, then the server waits for the guest (and Docker, for LXCs)
// before publishing a complete new snapshot. When its revision changes,
@@ -1821,15 +1842,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
updates: safeVMData.filter(hasLxcPendingUpdates).length,
}), [safeVMData])
const vmTypeSynonyms = useMemo(() => ({
lxc: t("vmLxc.filters.typeSynonyms.lxc"),
qemu: t("vmLxc.filters.typeSynonyms.qemu"),
}), [t])
const filteredVMs = useMemo(() => {
const statusMatched = statusFilter === "all"
? safeVMData
: safeVMData.filter((vm) => vm.status === statusFilter)
return statusMatched.filter((vm) => (
(!updatesOnly || hasLxcPendingUpdates(vm))
&& matchesVmSearch(vm, vmSearchTerms)
&& matchesVmSearch(vm, vmSearchTerms, vmTypeSynonyms)
))
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms])
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms, vmTypeSynonyms])
// ── LXC update apply flow (Phase 2a/b) ────────────────────────────
// Users pick a target (OS, App, both) + backup / restart options,
@@ -1872,15 +1898,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// Case-3a "no method" cards the user previously dismissed.
const [customCmdEditingApp, setCustomCmdEditingApp] = useState<string | null>(null)
const [customCmdDraft, setCustomCmdDraft] = useState<string>("")
const [updaterMethodDraft, setUpdaterMethodDraft] = useState<AppUpdateMethod>("none")
const [customCmdSaving, setCustomCmdSaving] = useState(false)
const [showHiddenNotices, setShowHiddenNotices] = useState(false)
const canonicalHelperUpdateCommand = (slug?: string | null) => {
const cleanSlug = (slug || "").trim()
if (!/^[A-Za-z0-9._-]+$/.test(cleanSlug)) return ""
return `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${cleanSlug}.sh)"`
}
// Docker Engine is updated by a protected host-side runner rather
// than by an arbitrary command inside the CT. Surface the exact
// command in the same editor used by every other app, but recognise
@@ -1890,6 +1911,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const openCustomCmdEditor = (app: LxcAppWatch, initialCommand = "") => {
setCustomCmdEditingApp(app.id)
setCustomCmdDraft(app.update_command || initialCommand)
setUpdaterMethodDraft(app.update_method || (app.update_command || initialCommand ? "custom" : "none"))
}
// ── Options card unified state ──────────────────────────────────
@@ -2389,7 +2411,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, {
update_command: customCmdDraft.trim(),
update_command: updaterMethodDraft === "helper" ? "" : customCmdDraft.trim(),
update_method: app.helper_slug === "docker" ? "custom" : updaterMethodDraft,
// Saving a command implicitly re-enables the notice (moot —
// the notice only shows when there is no command).
hide_no_updater_notice: false,
@@ -2402,10 +2425,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}
}
const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => {
if (!confirm(t("vmLxc.errors.removeCustomCommandConfirm", { name: app.name }))) return
const confirmKey = app.helper_slug === "docker"
? "vmLxc.errors.removeCustomCommandConfirm"
: "vmLxc.updates.disableUpdaterConfirm"
if (!confirm(t(confirmKey, { name: app.name || "" }))) return
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, { update_command: "" })
await patchAppWatch(vmid, app, { update_command: "", update_method: "none" })
closeCustomCmdEditor()
} catch (e) {
alert(t("vmLxc.errors.removeCustomCommandFailed", { message: (e as any)?.message || String(e) }))
@@ -5206,7 +5232,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const scheduledAppChoices = registeredApps.filter((app) => {
if (app.helper_slug === "docker" || app.helper_slug === "adguard") return false
if (app.update_command?.trim()) return true
return helperExists && app.helper_slug === uc?.helper_slug
return helperExists && app.update_method === "helper" && app.helper_slug === uc?.helper_slug
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
const versionTrackedScheduleAppIds = new Set(
registeredApps
@@ -5275,6 +5301,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (app.helper_slug === "docker") return false
if (app.update_command?.trim()) return true
return helperExists
&& app.update_method === "helper"
&& app.helper_slug === uc?.helper_slug
&& app.helper_slug !== "adguard"
}).map((app) => {
@@ -5801,9 +5828,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{helperSectionDetected && (() => {
const matchApp = helperOnlyApps[0] || null
if (!matchApp || customCmdEditingApp === matchApp.id) return null
const helperEditorCommand = helperExists && !helperUsesWebUpdater
? canonicalHelperUpdateCommand(uc?.helper_slug)
: ""
const helperSelected = matchApp.update_method === "helper"
const appWebUrl = helperUsesWebUpdater
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
: null
@@ -5823,12 +5848,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
type="button"
onClick={() => openCustomCmdEditor(
matchApp,
helperEditorCommand,
)}
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"
>
<Settings2 className="h-3.5 w-3.5" />
{helperEditorCommand
{helperSelected
? t("vmLxc.updates.editApp")
: t("vmLxc.updates.configureUpdater")}
</button>
@@ -5887,7 +5911,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.versionTrackingNotConfigured")}
</p>
) : null}
{helperExists && !helperUsesWebUpdater && (() => {
{helperExists && !helperUsesWebUpdater && !helperSelected && (
<p className="mt-3 text-xs text-muted-foreground">{t("vmLxc.updates.noUpdaterSelected")}</p>
)}
{helperExists && !helperUsesWebUpdater && helperSelected && (() => {
// Button state uses the matched
// App Watch entry when present.
// Without it we DON'T know the
@@ -5904,7 +5931,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
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", { runHelper: true, appName: helperName || "" })} className={cls}>
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "", targetIds: [`app:${matchApp.id}`] })} className={cls}>
{hasUpd && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{noState && <RefreshCw className="h-4 w-4 mr-1.5" />}
{label}
@@ -5965,7 +5992,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"
>
<Settings2 className="h-3.5 w-3.5" />
{hasCmd
{hasCmd || aw.update_method === "helper"
? t("vmLxc.updates.editApp")
: t("vmLxc.updates.configureUpdater")}
</button>
@@ -5977,56 +6004,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
)}
{editing ? (
<div className="space-y-3">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.updates.customCommandLabel")}
</Label>
<Textarea
value={customCmdDraft}
onChange={(e) => setCustomCmdDraft(e.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")}
className="font-mono text-xs mt-2 min-h-[100px]"
maxLength={4096}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div>
{hasCmd && (
<button
type="button"
onClick={() => removeCustomCommand(selectedVM.vmid, aw)}
disabled={customCmdSaving}
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" />
{t("vmLxc.updates.removeButton")}
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeCustomCmdEditor}
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"
>
{t("vmLxc.updates.cancelButton")}
</button>
<button
type="button"
onClick={() => saveCustomCommand(selectedVM.vmid, aw)}
disabled={customCmdSaving || !customCmdDraft.trim() || (
customCmdDraft.trim() === (aw.update_command || "").trim()
)}
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" />}
{t("vmLxc.updates.saveButton")}
</button>
</div>
</div>
</div>
<AppUpdaterEditor
method={updaterMethodDraft}
command={customCmdDraft}
helperAvailable={helperExists && !helperUsesWebUpdater && aw.helper_slug === uc?.helper_slug}
helperSlug={aw.helper_slug}
configured={hasCmd || aw.update_method === "helper"}
saving={customCmdSaving}
changed={updaterMethodDraft !== (aw.update_method || (hasCmd ? "custom" : "none")) || (
updaterMethodDraft === "custom" && customCmdDraft.trim() !== (aw.update_command || "").trim()
)}
onMethodChange={setUpdaterMethodDraft}
onCommandChange={setCustomCmdDraft}
onSave={() => saveCustomCommand(selectedVM.vmid, aw)}
onCancel={closeCustomCmdEditor}
onRemove={() => removeCustomCommand(selectedVM.vmid, aw)}
/>
) : (
<>
{hasUpdate ? (