mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-06 13:36:42 +00:00
ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps - App tab: containers installed from an OCI image are identified from their installation record; the application and image versions are shown and an update is detected by image digest; repository link; Refresh data. - Updates tab for OCI containers: Update and Recreate run the same flow as the OCI menu in the Monitor terminal; the pre-update backup can be kept in a backup storage; scheduled image updates with an optional minimum age. - Logs tab: console output of the application, kept on the host (lxc.console.logfile + logrotate) and followed live. - The Proxmox console opens a shell (cmode: shell) when the image has one. - A damaged image download is fetched again before failing. - Multi-container applications open at their LAN address; volume mount points on block storage report their usage. Monitor - Proxmox notifications are delivered to a loopback-only HTTP listener when HTTPS is enabled, so they no longer fail certificate verification. - Log persistence counts recurring patterns only; an ended burst is not reported as persistent and its warning clears on its own (#386). - Proxmox notification config backups are deduplicated and capped at three. - The update icon on the Apps page opens the container on its Updates tab. - Version 1.2.6.2-beta and its release notes in every Monitor language. Docs - OCI manager Apps and Audit & Report rebuilt as per-page message files, with a new page for OCI containers in the Monitor. - Seven pages fixed where rich-text tags were missing from t.rich. Translations - Spanish fixes across the OCI engine, the Monitor and the TUI menus. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -43,7 +43,7 @@ import { useT } from "@/lib/i18n/provider"
|
||||
// apps and casual "just want a link" registrations use this default.
|
||||
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
|
||||
"python_dist" | "docker_label" | "docker_exec" |
|
||||
"command" | "manual"
|
||||
"command" | "manual" | "oci_image"
|
||||
type GithubSource = "releases" | "tags"
|
||||
|
||||
interface PortEntry {
|
||||
@@ -120,6 +120,10 @@ interface DetectedApp {
|
||||
// auto-fill the Web Link editor when the user clicks "Register".
|
||||
category?: string | null
|
||||
tracking_suggestion?: TrackingSuggestion | null
|
||||
// The scheme the application is served on, when it is known rather than
|
||||
// guessed. A ProxMenux OCI install records it; Chromium answers on 3001
|
||||
// over https, which no port heuristic can tell from 3000 over http.
|
||||
scheme?: "http" | "https" | null
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -129,8 +133,31 @@ interface AppState {
|
||||
update_available: boolean | null
|
||||
error: string | null
|
||||
checked_at: string | null
|
||||
// An application installed from an OCI image: the image it runs and the
|
||||
// one its registry publishes today. The image decides whether there is an
|
||||
// update; the version above only says which application it carries.
|
||||
installed_digest?: string | null
|
||||
latest_digest?: string | null
|
||||
image_created?: string | null
|
||||
latest_image_created?: string | null
|
||||
image_reference?: string | null
|
||||
image_repository?: string | null
|
||||
}
|
||||
|
||||
// The repository of an OCI image, shown the way the repository of a tracked
|
||||
// application is: owner/name for GitHub, the image name for Docker Hub.
|
||||
export const ociRepoLabel = (url: string) =>
|
||||
url.replace(/^https?:\/\/(www\.)?/, "")
|
||||
.replace(/^github\.com\//, "")
|
||||
.replace(/^hub\.docker\.com\/(r|_)\//, "")
|
||||
.replace(/\/$/, "")
|
||||
|
||||
// "2026-09-24 · a3f21c08": an image as a reader can compare two of them.
|
||||
export const ociImageLabel = (created?: string | null, digest?: string | null) =>
|
||||
[created ? created.slice(0, 10) : null, digest ? digest.split(":").pop()!.slice(0, 8) : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
|
||||
interface AppEntry extends AppConfig {
|
||||
id: string
|
||||
state?: AppState
|
||||
@@ -219,10 +246,25 @@ interface DockerWebLinkSuggestion {
|
||||
logo_url?: string | null
|
||||
}
|
||||
|
||||
// Identity of a container ProxMenux installed from an OCI image. It comes
|
||||
// from the installation record rather than a probe, so it names the
|
||||
// application even when the guest also ships something else — CT 152 runs
|
||||
// Chromium and carries a docker client the probe reported instead.
|
||||
interface OciInstance {
|
||||
template_id: string | null
|
||||
image_reference: string | null
|
||||
repository: string | null
|
||||
scheme: string | null
|
||||
port: number | null
|
||||
path: string | null
|
||||
website: string | null
|
||||
}
|
||||
|
||||
interface Suggestions {
|
||||
ready?: boolean
|
||||
name_suggestion: string | null
|
||||
helper_slug: string | null
|
||||
oci_instance?: OciInstance | null
|
||||
port_suggestions: number[]
|
||||
web_path_hint: string | null
|
||||
tracking_suggestion?: TrackingSuggestion | null
|
||||
@@ -400,6 +442,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
const [loading, setLoading] = useState(!seed)
|
||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||
@@ -589,14 +632,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
const detectedList: DetectedApp[] = useMemo(() => {
|
||||
if (!suggestions) return []
|
||||
const out: DetectedApp[] = []
|
||||
if (suggestions.helper_slug && suggestions.name_suggestion) {
|
||||
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||
// community script — so its template id identifies it instead.
|
||||
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
|
||||
if (primarySlug && suggestions.name_suggestion) {
|
||||
out.push({
|
||||
slug: suggestions.helper_slug,
|
||||
slug: primarySlug,
|
||||
name: suggestions.name_suggestion,
|
||||
logo_url: suggestions.logo_url,
|
||||
default_ports: suggestions.default_ports,
|
||||
category: suggestions.category,
|
||||
tracking_suggestion: suggestions.tracking_suggestion,
|
||||
scheme: suggestions.oci_instance?.scheme === "https" ? "https"
|
||||
: suggestions.oci_instance?.scheme === "http" ? "http" : null,
|
||||
})
|
||||
}
|
||||
const seen = new Set(out.map((d) => d.slug))
|
||||
@@ -639,6 +687,43 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
|
||||
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
|
||||
|
||||
// A container installed from an OCI image holds exactly the application its
|
||||
// record names, so there is nothing to search for and nothing else to add.
|
||||
// What can go stale is what the record and the registry say, and this
|
||||
// reads both again.
|
||||
const isOciInstall = !!suggestions?.oci_instance
|
||||
const isOciAdguard = suggestions?.oci_instance?.template_id === "image-adguard-home"
|
||||
useEffect(() => {
|
||||
if (!isOciAdguard) return
|
||||
let cancelled = false
|
||||
fetchApi<{ available: boolean }>(`/api/vms/${vmid}/apps/adguard-setup`)
|
||||
.then((result) => { if (!cancelled) setAdguardSetupAvailable(result.available === true) })
|
||||
.catch(() => { if (!cancelled) setAdguardSetupAvailable(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, isOciAdguard])
|
||||
const refreshOciData = async () => {
|
||||
setSearchingApplications(true)
|
||||
setDetectionNotice(null)
|
||||
setError(null)
|
||||
try {
|
||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||
method: "POST",
|
||||
})
|
||||
setSuggestions(result)
|
||||
let next = sidecar
|
||||
if (apps.length > 0) {
|
||||
next = await fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
|
||||
setSidecar(next)
|
||||
}
|
||||
if (next) setLxcAppsCached(vmid, next, result)
|
||||
onChange?.()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.checkFailed"))
|
||||
} finally {
|
||||
setSearchingApplications(false)
|
||||
}
|
||||
}
|
||||
|
||||
const searchInstalledApplications = async () => {
|
||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||
setSearchingApplications(true)
|
||||
@@ -756,10 +841,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
// Docker endpoints come from the real published host-port mappings
|
||||
// listed under Web links. Do not pre-save a catalog default such as
|
||||
// 9000; the user explicitly chooses which workload links to add.
|
||||
if (p.slug !== "docker" && p.default_ports?.length) {
|
||||
seed.ports = p.default_ports.map((port) => ({
|
||||
const suggestedPorts = isOciAdguard && p.slug === "image-adguard-home"
|
||||
? [80, ...(adguardSetupAvailable ? [3000] : [])]
|
||||
: p.default_ports || []
|
||||
if (p.slug !== "docker" && suggestedPorts.length) {
|
||||
seed.ports = suggestedPorts.map((port) => ({
|
||||
port,
|
||||
scheme: defaultSchemeFor(port),
|
||||
...(isOciAdguard && port === 3000 ? { description: "Config" } : {}),
|
||||
// A recorded scheme beats the port heuristic: getting this wrong
|
||||
// hands the user a link that cannot connect.
|
||||
scheme: p.scheme || defaultSchemeFor(port),
|
||||
web_path: s?.web_path_hint || "",
|
||||
// Auto-fill Categoría from helpers_cache.category_names[0]
|
||||
// when the catalog entry carries one. User can still change
|
||||
@@ -822,7 +913,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
setEditing({ appId: existing?.id || null, draft: seed })
|
||||
setDetectorTest(null)
|
||||
setError(null)
|
||||
}, [suggestions, vmid, sidecar])
|
||||
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditing(null)
|
||||
@@ -1686,10 +1777,26 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
||||
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
||||
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
||||
{/* Only meaningful on a container ProxMenux installed
|
||||
from an image: the record it reads exists nowhere else. */}
|
||||
{(method === "oci_image" || suggestions?.oci_instance) && (
|
||||
<SelectItem value="oci_image">{t("vmLxc.appEditor.methodOciImage")}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{method === "oci_image" && (
|
||||
<div className="sm:col-span-2 text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.appEditor.ociImageHelp")}
|
||||
{suggestions?.oci_instance?.image_reference && (
|
||||
<div className="mt-1">
|
||||
<code className="text-foreground/80">{suggestions.oci_instance.image_reference}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPackaged && (
|
||||
<div>
|
||||
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
|
||||
@@ -1892,7 +1999,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method && (() => {
|
||||
{/* The registry of the image is the upstream of an OCI
|
||||
install, and the record already names it. */}
|
||||
{method && method !== "oci_image" && (() => {
|
||||
// Upstream source selector — 3 methods (github,
|
||||
// http_json, docker_hub). Legacy sidecars with a
|
||||
// `repo` set but no `upstream_type` default to
|
||||
@@ -2466,17 +2575,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
)}
|
||||
<div className="pt-1 flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
onClick={searchInstalledApplications}
|
||||
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
|
||||
disabled={searchingApplications}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||
>
|
||||
{searchingApplications
|
||||
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
: <Search className="h-4 w-4 mr-1.5" />}
|
||||
{searchingApplications
|
||||
? t("vmLxc.appEditor.searchingApplications")
|
||||
: t("vmLxc.appEditor.searchApplications")}
|
||||
: isOciInstall ? <RefreshCw className="h-4 w-4 mr-1.5" /> : <Search className="h-4 w-4 mr-1.5" />}
|
||||
{isOciInstall
|
||||
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
|
||||
: searchingApplications
|
||||
? t("vmLxc.appEditor.searchingApplications")
|
||||
: t("vmLxc.appEditor.searchApplications")}
|
||||
</Button>
|
||||
{!isOciInstall && (
|
||||
<Button
|
||||
onClick={openBrowseOrEditor}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||
@@ -2489,6 +2601,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{detectionNotice && (
|
||||
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
|
||||
@@ -2524,6 +2637,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
{app.installed_via === "apk" && app.package && <>apk · <code className="text-foreground/80">{app.package}</code></>}
|
||||
{app.installed_via === "file" && app.file_path && <>file · <code className="text-foreground/80">{app.file_path}</code></>}
|
||||
{app.installed_via === "binary" && app.binary_path && <>binary · <code className="text-foreground/80">{app.binary_path}</code></>}
|
||||
{app.installed_via === "oci_image" && st?.image_reference && <>OCI · <code className="text-foreground/80 break-all">{st.image_reference}</code></>}
|
||||
</div>
|
||||
)}
|
||||
{tracking && st?.checked_at && (
|
||||
@@ -2535,6 +2649,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
stack below Checked, full-width so long repo
|
||||
names wrap cleanly instead of competing with
|
||||
the top-right on narrow screens. */}
|
||||
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
|
||||
<a
|
||||
href={st.image_repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="md:hidden mt-1 text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<span className="truncate">{ociRepoLabel(st.image_repository)}</span>
|
||||
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
{app.repo && tracking && (
|
||||
<a
|
||||
href={`https://github.com/${app.repo}`}
|
||||
@@ -2551,6 +2676,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
{/* Desktop-only repo link: same row as the title on md+,
|
||||
hidden on mobile where the stacked variant above
|
||||
handles it. */}
|
||||
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
|
||||
<a
|
||||
href={st.image_repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden md:inline-flex text-xs text-muted-foreground hover:text-foreground items-center gap-1 flex-shrink-0 mt-1"
|
||||
>
|
||||
{ociRepoLabel(st.image_repository)}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{app.repo && tracking && (
|
||||
<a
|
||||
href={`https://github.com/${app.repo}`}
|
||||
@@ -2565,6 +2701,41 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// An application installed from an OCI image changes when its
|
||||
// image does. Both are shown — the application version it
|
||||
// carries and the image it runs — and the image decides.
|
||||
if (app.installed_via === "oci_image") {
|
||||
if (!tracking) return null
|
||||
const newImage = st?.update_available === true
|
||||
const appMoved = !!(st?.latest_version && st.latest_version !== st.installed_version)
|
||||
return (
|
||||
<div className="mb-3 grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
<div className="p-3 rounded-md bg-muted/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociAppVersion")}</div>
|
||||
<div className="text-lg font-semibold font-mono text-foreground break-all">
|
||||
{st?.installed_version || "—"}
|
||||
</div>
|
||||
{appMoved && (
|
||||
<div className="text-xs font-mono text-purple-400 mt-1 break-all">→ {st!.latest_version}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 rounded-md bg-muted/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociImage")}</div>
|
||||
<div className="text-sm font-semibold font-mono text-foreground">
|
||||
{ociImageLabel(st?.image_created, st?.installed_digest) || "—"}
|
||||
</div>
|
||||
{newImage ? (
|
||||
<div className="text-sm font-semibold font-mono text-purple-400 mt-1 flex items-center gap-2">
|
||||
{t("vmLxc.appEditor.ociNewImage")}: {ociImageLabel(st?.latest_image_created, st?.latest_digest)}
|
||||
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
||||
</div>
|
||||
) : st?.latest_digest ? (
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.ociImageCurrent")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A delegated app has no upstream of its own; the version to
|
||||
// compare against comes from the image it updates with.
|
||||
const delegated = app.update_via === "docker"
|
||||
@@ -2614,9 +2785,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
Logo is optional (per-port `logo_url`); when absent
|
||||
the row indents naturally to align with the text.
|
||||
If we can't resolve an IP for the CT we hide the row. */}
|
||||
{app.ports && app.ports.length > 0 && (
|
||||
{(isOciAdguard ? true : !!app.ports?.length) && (
|
||||
<div className="mb-3 space-y-4">
|
||||
{app.ports.map((p) => {
|
||||
{(isOciAdguard
|
||||
? [
|
||||
{ port: 80, description: app.name, scheme: "http" as const },
|
||||
...(adguardSetupAvailable ? [{ port: 3000, description: "Config", scheme: "http" as const }] : []),
|
||||
]
|
||||
: app.ports).map((p) => {
|
||||
const url = buildWebUrl(ctIp, p.port, p.scheme, p.custom_url)
|
||||
if (!url) return null
|
||||
const label = p.description || app.name
|
||||
@@ -2766,22 +2942,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={searchInstalledApplications}
|
||||
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
|
||||
disabled={searchingApplications || editMode}
|
||||
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
|
||||
aria-label={searchingApplications
|
||||
? t("vmLxc.appEditor.searchingApplications")
|
||||
: t("vmLxc.appEditor.searchApplications")}
|
||||
aria-label={isOciInstall
|
||||
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
|
||||
: searchingApplications
|
||||
? t("vmLxc.appEditor.searchingApplications")
|
||||
: t("vmLxc.appEditor.searchApplications")}
|
||||
>
|
||||
{searchingApplications
|
||||
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
|
||||
: <Search className="h-4 w-4 sm:mr-1.5" />}
|
||||
: isOciInstall ? <RefreshCw className="h-4 w-4 sm:mr-1.5" /> : <Search className="h-4 w-4 sm:mr-1.5" />}
|
||||
<span className="hidden sm:inline">
|
||||
{searchingApplications
|
||||
? t("vmLxc.appEditor.searchingApplications")
|
||||
: t("vmLxc.appEditor.searchApplications")}
|
||||
{isOciInstall
|
||||
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
|
||||
: searchingApplications
|
||||
? t("vmLxc.appEditor.searchingApplications")
|
||||
: t("vmLxc.appEditor.searchApplications")}
|
||||
</span>
|
||||
</Button>
|
||||
{!isOciInstall && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -2800,6 +2981,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
|
||||
Reference in New Issue
Block a user