"use client" /** * LxcAppPanel — Body of the "App" tab in the LXC modal. * * Handles a LIST of apps per CT (one CT can host several services * — e.g. Frigate on port 5000 + go2rtc on 1984, or Docker + two * containerised apps). Each app has: * • an install method (dpkg / apk / file / binary / docker) * • an optional GitHub repo for upstream version tracking * • a list of ports, each with a description and web path * * Docker apps are "register-only": they exist to produce clickable * links, ProxMenux does NOT try to track their version and NEVER * emits warnings for them — updates for Docker apps are handled by * Docker itself. * * For ProxMenux-managed OCI CTs (Secure Gateway) the panel is * read-only — the actual update lifecycle lives in Security → * Secure Gateway. */ import { useCallback, useEffect, useMemo, useState } from "react" import { Loader2, Save, RefreshCw, Trash2, Package, ExternalLink, AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff, ArrowUpCircle, RotateCcw, Check, Settings2, ShieldCheck, CheckCircle2, } from "lucide-react" import { Card, CardContent } from "./ui/card" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Badge } from "./ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" import { useT } from "@/lib/i18n/provider" // installed_via is optional now — an empty value means "register only, // no version tracking, no warnings, just a clickable link". Docker // 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" type GithubSource = "releases" | "tags" interface PortEntry { port: number | "" description?: string scheme?: "http" | "https" web_path?: string logo_url?: string } interface AppConfig { name: string installed_via?: InstalledVia package?: string file_path?: string file_regex?: string binary_path?: string binary_args?: string[] python_path?: string distribution?: string container_name?: string label?: string command_argv?: string[] installed_version?: string installed_regex?: string // Upstream source discriminator + fields. When `upstream_type` is // "github" (default when `repo` is set) the classic repo / // github_source / tag_regex fields drive the check. "http_json" and // "docker_hub" open two new source types validated separately on // the backend. upstream_type?: "github" | "http_json" | "docker_hub" | "" repo?: string github_source?: GithubSource upstream_url?: string upstream_json_path?: string docker_image?: string tag_regex?: string ports: PortEntry[] health_path?: string logo_url?: string helper_slug?: string } interface DetectedApp { slug: string name: string logo_url?: string | null default_ports?: number[] tracking_suggestion?: TrackingSuggestion | null } interface AppState { installed_version: string | null latest_version: string | null update_available: boolean | null error: string | null checked_at: string | null } interface AppEntry extends AppConfig { id: string state?: AppState created_at?: string } interface SidecarResponse { vmid: number apps: AppEntry[] dismissed_slugs?: string[] created_at?: string updated_at?: string } interface TrackingSuggestion { installed_via: "dpkg" | "apk" | "file" | "binary" package?: string file_path?: string file_regex?: string binary_path?: string repo?: string github_source?: "releases" | "tags" tag_regex?: string } interface Suggestions { name_suggestion: string | null helper_slug: string | null port_suggestions: number[] web_path_hint: string | null tracking_suggestion?: TrackingSuggestion | null default_ports?: number[] logo_url?: string | null extras?: DetectedApp[] } // Compact catalog entry — one row for every registerable app the // picker can offer. Fetched once from /api/apps/catalog on panel // mount, filtered client-side while the user types. interface CatalogEntry { slug: string name: string logo: string default_port: number has_tracking: boolean } // Full detail for a picked catalog entry — server merges catalog // metadata + curated tracking_suggestion (when available) so the // editor can pre-fill every field in one round-trip. interface CatalogDetail { slug: string name: string logo_url: string | null website: string default_ports: number[] tracking_suggestion?: TrackingSuggestion | null } interface ManagedAppInfo { managed_oci_app_id: string name: string installed_version?: string | null latest_version?: string | null update_available?: boolean | null checked_at?: string | null error?: string | null } interface Props { vmid: number ctIp?: string | null onChange?: () => void managed?: ManagedAppInfo | null } const EMPTY_APP: AppConfig = { name: "", installed_via: "", // no tracking by default — just a link package: "", upstream_type: "", repo: "", github_source: "releases", upstream_url: "", upstream_json_path: "", docker_image: "", tag_regex: "v?(\\d+\\.\\d+\\.\\d+)", ports: [], logo_url: "", } // Default scheme heuristic for freshly-added ports — only used to // pre-select the dropdown. The user always has the final say via // the http/https selector next to the port input. const HTTPS_HINT_PORTS = new Set([443, 4443, 8443, 9443]) const defaultSchemeFor = (port: number | ""): "http" | "https" => HTTPS_HINT_PORTS.has(Number(port)) ? "https" : "http" function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?: "http" | "https") { if (!ip || ip === "DHCP" || !port) return null return `${scheme || defaultSchemeFor(port)}://${ip}:${port}` } // Suggest a dpkg/apk package name from a friendly app name — lowercase, // spaces and slashes to hyphens, drop punctuation. Only used as a // placeholder / auto-fill; user can always override. function suggestPackageName(name: string) { return name .trim() .toLowerCase() .replace(/[\s/]+/g, "-") .replace(/[^a-z0-9._+@:-]/g, "") .replace(/-{2,}/g, "-") .replace(/^-+|-+$/g, "") } export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { const t = useT() const [loading, setLoading] = useState(true) const [sidecar, setSidecar] = useState(null) const [suggestions, setSuggestions] = useState(null) const [error, setError] = useState(null) // Editor state const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null) const [saving, setSaving] = useState(false) const [busyAppId, setBusyAppId] = useState(null) // Advanced section (version tracking) is collapsed by default so the // basic Name + Ports flow stays approachable. Auto-expanded when // editing an app that already has installed_via set, or when the // user clicked Register on an auto-detected chip whose hint carries // tracking metadata — the user sees the fields we auto-filled and // can tweak or opt out before saving. const [showAdvanced, setShowAdvanced] = useState(false) // Catalog picker: 700+ apps fetched once from /api/apps/catalog and // filtered client-side while the user types in the Name input. The // dropdown shows top 20 matches. Selecting one calls the detail // endpoter to seed name / logo / ports / tracking_suggestion at once. const [catalog, setCatalog] = useState([]) const [pickerOpen, setPickerOpen] = useState(false) // "Register a different app" browse panel: when the user has hidden // some detections we surface them here with a Restore button before // falling through to the blank-form path. If there's nothing to // restore, this panel is skipped entirely and the button opens the // editor directly (fast path for the common case). const [browseOpen, setBrowseOpen] = useState(false) // Global "manage apps" mode. When ON, every app card grows a footer // with Remove / Check / Edit fields actions. When OFF the cards are // pure info — the only surfaced action is a hover-reveal Check icon // on the LATEST UPSTREAM panel. Toggled from a single button next to // "Add another application". const [editMode, setEditMode] = useState(false) const load = useCallback(async () => { if (managed) { setLoading(false); return } setLoading(true) setError(null) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps`) setSidecar(r) // Always fetch suggestions — used both by the empty-state form // seed AND by the "Also detected on this container" chip strip // that surfaces unregistered detections even after the CT already // has ≥1 registered app. Previously gated on `!r.apps?.length`, // which meant a CT with 1 registered + 1 detected-but-not-yet- // registered app never showed the second app until the user // opened + cancelled the editor (which triggered a re-load path // that happened to fetch it). try { const s: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`) setSuggestions(s) } catch { /* non-fatal */ } } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.loadFailed")) } finally { setLoading(false) } }, [vmid, managed]) useEffect(() => { load() }, [load]) // Fetch the picker catalog once per panel mount. Best-effort — if // the API is unreachable, the picker just stays empty and users // type the app name manually (same as before this feature). useEffect(() => { let cancelled = false fetchApi("/api/apps/catalog") .then((data: CatalogEntry[]) => { if (!cancelled && Array.isArray(data)) setCatalog(data) }) .catch(() => { /* non-fatal */ }) return () => { cancelled = true } }, []) // Derived state — computed here BEFORE any conditional early // return so React sees the same hook order on every render. // Rules of Hooks: `useMemo` after an `if (loading) return …` // trips React error #310 the moment `loading` flips false. const apps = sidecar?.apps || [] // Unified detection list — primary community-scripts install + // every other app whose install signature was found on the CT // (`extras[]` from the backend). Both use the same DetectedApp // shape so the empty state renders them uniformly. const detectedList: DetectedApp[] = useMemo(() => { if (!suggestions) return [] const out: DetectedApp[] = [] if (suggestions.helper_slug && suggestions.name_suggestion) { out.push({ slug: suggestions.helper_slug, name: suggestions.name_suggestion, logo_url: suggestions.logo_url, default_ports: suggestions.default_ports, tracking_suggestion: suggestions.tracking_suggestion, }) } const seen = new Set(out.map((d) => d.slug)) for (const e of suggestions.extras || []) { if (!seen.has(e.slug)) { out.push(e) seen.add(e.slug) } } return out }, [suggestions]) // Registered slugs — used to filter the detection list down to // what the user hasn't already registered on this CT. const registeredSlugs = useMemo( () => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]), [apps], ) // Dismissed slugs — persisted in the sidecar. Chips the user // explicitly hid via the ✕ button stay hidden across reloads until // they register the app (which also un-dismisses implicitly). const dismissedSlugs = useMemo( () => new Set(sidecar?.dismissed_slugs || []), [sidecar], ) const visibleDetected = detectedList.filter( (d) => !registeredSlugs.has(d.slug) && !dismissedSlugs.has(d.slug), ) // Alias for pre-existing consumers (post-registration chip strip). const unregisteredDetected = visibleDetected // Detections the user hid and could restore from the Register-a- // different-app panel. Not affected by registration state. const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug)) // "Register a different app" behavior: if there are hidden slugs, // surface them first (with Restore) so the user can bring one back // instead of typing everything by hand. If nothing to restore, go // straight to the blank editor. const openBrowseOrEditor = () => { if (hiddenDetections.length > 0) setBrowseOpen(true) else openEditor() } const openEditor = useCallback(async ( existing?: AppEntry, opts?: { withTracking?: boolean, preset?: DetectedApp }, ) => { let seed: AppConfig if (existing) { seed = { name: existing.name, installed_via: (existing.installed_via as InstalledVia) || "", package: existing.package || "", file_path: existing.file_path || "", file_regex: existing.file_regex || "", binary_path: existing.binary_path || "", binary_args: existing.binary_args ? [...existing.binary_args] : [], python_path: existing.python_path || "", distribution: existing.distribution || "", container_name: existing.container_name || "", label: existing.label || "", command_argv: existing.command_argv ? [...existing.command_argv] : [], installed_version: existing.installed_version || "", installed_regex: existing.installed_regex || "", upstream_type: existing.upstream_type || (existing.repo ? "github" : ""), repo: existing.repo || "", github_source: existing.github_source || "releases", upstream_url: existing.upstream_url || "", upstream_json_path: existing.upstream_json_path || "", docker_image: existing.docker_image || "", tag_regex: existing.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)", ports: existing.ports?.length ? existing.ports.map((p) => ({ ...p })) : [], health_path: existing.health_path || "", logo_url: existing.logo_url || "", helper_slug: existing.helper_slug || "", } // Editing an existing app: expand Advanced when tracking is on setShowAdvanced(!!seed.installed_via) } else { seed = { ...EMPTY_APP, ports: [] } let s = suggestions if (!s) { try { s = await fetchApi(`/api/vms/${vmid}/apps/suggestions`) setSuggestions(s) } catch { /* non-fatal */ } } // Preset path: a chip in the empty state (primary OR extra) was // clicked. Seed EVERYTHING from the preset so this works // regardless of whether it's the first or Nth app on the CT. // Primary detection is `{...suggestions}`-shaped, an extra is // `DetectedApp`-shaped — both carry name/logo/ports/tracking. if (opts?.preset) { const p = opts.preset seed.name = p.name seed.logo_url = p.logo_url || "" seed.helper_slug = p.slug if (p.default_ports?.length) { seed.ports = p.default_ports.map((port) => ({ port, scheme: defaultSchemeFor(port), web_path: s?.web_path_hint || "", })) } if (opts.withTracking && p.tracking_suggestion) { const t = p.tracking_suggestion seed = { ...seed, installed_via: t.installed_via, package: t.package || "", file_path: t.file_path || "", file_regex: t.file_regex || "", binary_path: t.binary_path || "", upstream_type: (t as any).upstream_type || (t.repo ? "github" : ""), repo: t.repo || "", github_source: t.github_source || "releases", upstream_url: (t as any).upstream_url || "", upstream_json_path: (t as any).upstream_json_path || "", docker_image: (t as any).docker_image || "", tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)", } setShowAdvanced(true) } else { setShowAdvanced(false) } } else { // No preset (bare "+ Register application"): start empty so // the user types name/ports/logo for a custom app the auto- // detector doesn't know about. setShowAdvanced(false) } } setEditing({ appId: existing?.id || null, draft: seed }) setError(null) }, [suggestions, vmid, sidecar]) const closeEditor = () => { setEditing(null) setError(null) } const save = async () => { if (!editing) return setSaving(true) setError(null) try { const url = editing.appId ? `/api/vms/${vmid}/apps/${editing.appId}` : `/api/vms/${vmid}/apps` const method = editing.appId ? "PUT" : "POST" const r: SidecarResponse & { error?: string } = await fetchApi(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(editing.draft), }) if ((r as any).error) throw new Error((r as any).error) setSidecar(r) setEditing(null) onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.saveFailed")) } finally { setSaving(false) } } const checkOne = async (appId: string) => { setBusyAppId(appId) setError(null) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${appId}/check`, { method: "POST", }) setSidecar(r) onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.checkFailed")) } finally { setBusyAppId(null) } } const removeOne = async (appId: string) => { if (!confirm("Remove this application from the CT's App tab?")) return setBusyAppId(appId) setError(null) try { await fetchApi(`/api/vms/${vmid}/apps/${appId}`, { method: "DELETE" }) // Reload from server so the empty state re-fetches suggestions await load() onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.deleteFailed")) } finally { setBusyAppId(null) } } // Hide an auto-detected chip. Optimistic UI: update the local // sidecar state immediately so the chip disappears without // waiting for the round-trip, then persist to the server. If the // POST fails, reload from server to resync. const dismissDetection = async (slug: string, name: string) => { if (!confirm(t("vmLxc.appEditor.confirmHide", { name }))) return setSidecar((prev) => prev ? { ...prev, dismissed_slugs: [...(prev.dismissed_slugs || []), slug] } : prev, ) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/dismiss`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, dismissed: true }), }) setSidecar(r) } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.dismissFailed")) await load() // resync on failure } } // Un-hide a previously dismissed slug. Used from the "Register a // different app" panel. Optimistically drops the slug from local // dismissed_slugs so the chip re-appears in the main list, then // persists. const restoreDetection = async (slug: string) => { setSidecar((prev) => prev ? { ...prev, dismissed_slugs: (prev.dismissed_slugs || []).filter((s) => s !== slug) } : prev, ) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/dismiss`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, dismissed: false }), }) setSidecar(r) } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.restoreFailed")) await load() } } // ── Managed CT (Secure Gateway etc.) ────────────────────────── // Mirrors the visual identity of a regular app card so managed OCI // apps sit next to user-registered apps without a jarring style // shift. Version data comes from managed_installs.update_check // (already tracked by oci_manager — same source as the Security → // Secure Gateway page). No footer: no Edit, no Check, no Remove — // the whole lifecycle lives in Security → Secure Gateway. if (managed) { // Currently the only OCI managed app is Secure Gateway (Tailscale // in an Alpine CT). When we add more OCI apps we'll swap this to // a lookup keyed on managed_oci_app_id → catalog metadata. const isSecureGateway = managed.managed_oci_app_id === "secure-gateway" const displayName = isSecureGateway ? "Secure Gateway" : (managed.name || t("vmLxc.appEditor.managedApp")) const displaySubtitle = isSecureGateway ? "Tailscale VPN Gateway" : "" // Two variants — the selfh.st mark (dark logo on light bg) reads // better in light mode; the homarr-labs "-light" variant (light // logo on dark bg) reads better in dark mode. Both are rendered // and Tailwind's dark: class picks which one is visible. const upstreamLogoLightUrl = isSecureGateway ? "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/tailscale.webp" : "" const upstreamLogoDarkUrl = isSecureGateway ? "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/tailscale-light.webp" : "" const upstreamName = isSecureGateway ? "Tailscale" : "" const repo = isSecureGateway ? "tailscale/tailscale" : "" const methodLine = isSecureGateway ? `apk · tailscale · ${t("vmLxc.appEditor.managedStatus")}` : t("vmLxc.appEditor.managedStatus") const hasUpdate = managed.update_available === true const upToDate = managed.update_available === false && !!managed.installed_version const showVersions = !!(managed.installed_version || managed.latest_version || repo) return (
{/* Block 1 — Secure Gateway identity (the ProxMenux product). Big shield, title, catalog subtitle. */}

{displayName}

{displaySubtitle && (
{displaySubtitle}
)}
{/* Block 2 — Underlying engine (Tailscale). Same visual pattern as a regular app card so it's clear this is what version tracking is anchored to. Repo link goes here (top-right on desktop / stacked on mobile) because the repo is the engine's, not Secure Gateway's. */} {upstreamName && (
{upstreamLogoLightUrl || upstreamLogoDarkUrl ? ( <> {upstreamLogoLightUrl && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )} {upstreamLogoDarkUrl && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )} ) : (
)}
{upstreamName}
{methodLine}
{managed.checked_at && (
{t("vmLxc.appEditor.checkedAt", { date: new Date(managed.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) })}
)} {repo && ( {repo} )}
{repo && ( {repo} )}
)} {showVersions && (
{managed.installed_version && (
{t("vmLxc.appEditor.installedStatus")}
{managed.installed_version} {upToDate && ( )}
)} {repo && (
{t("vmLxc.appEditor.latestUpstream")}
{managed.latest_version || {t("vmLxc.appEditor.checkingStatus")}} {hasUpdate && managed.latest_version && ( )}
)}
)} {managed.error && (
{managed.error}
)} {/* Managed banner — green translucent badge signalling this CT's lifecycle is owned by ProxMenux (not user CRUD). */}
{t("vmLxc.appEditor.installedManaged")}
) } if (loading) { return (
{t("vmLxc.appEditor.loadingApplications")}
) } // ── Editor ───────────────────────────────────────────────────── if (editing) { const draft = editing.draft const method = draft.installed_via || "" const isPackaged = method === "dpkg" || method === "apk" const setField = (patch: Partial) => setEditing({ ...editing, draft: { ...draft, ...patch } }) // Editing the Name auto-fills the Package field on packaged // methods when it's still empty. Rationale: 90% of the time the // dpkg/apk package name mirrors the friendly app name (jellyfin, // adguardhome, portainer-ce). The user can still override. const setName = (name: string) => { const patch: Partial = { name } if (isPackaged && !draft.package?.trim()) { patch.package = suggestPackageName(name) } setField(patch) } const setPort = (i: number, patch: Partial) => { const ports = draft.ports.map((p, idx) => (idx === i ? { ...p, ...patch } : p)) setField({ ports }) } // "Add port" adds an EMPTY row for manual entry. Detected chips // ("+5000", "+1984"…) add the port directly with the port // pre-filled — no need for the user to open a row first. const addEmptyPort = () => setField({ ports: [...draft.ports, { port: "", description: "", scheme: "http" }] }) const addDetectedPort = (port: number) => { const scheme = defaultSchemeFor(port) // If the last row is still empty, fill it instead of appending // a duplicate. Avoids the two-lines-appear bug. const last = draft.ports[draft.ports.length - 1] if (last && last.port === "" && !last.description) { const ports = [...draft.ports] ports[ports.length - 1] = { port, description: "", scheme } setField({ ports }) } else { setField({ ports: [...draft.ports, { port, description: "", scheme }] }) } } const removePort = (i: number) => setField({ ports: draft.ports.filter((_, idx) => idx !== i) }) const usedPorts = new Set(draft.ports.map((p) => p.port)) const suggestable = (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p)) return (
{/* Editor card — opaque `bg-card` (not the `/50` used by view-mode cards) so the darker `bg-background` inputs sit visibly recessed against it. Matches the sunken-input pattern used in the PBS setup wizard: card = surface, fields = wells inside the surface. Reverts to `bg-card/50` automatically because this render branch only fires when `editing !== null`. */}
{ setName(e.target.value); setPickerOpen(true) }} onFocus={() => setPickerOpen(true)} onBlur={() => setTimeout(() => setPickerOpen(false), 150)} placeholder={suggestions?.name_suggestion || t("vmLxc.appEditor.nameSearchPlaceholder")} maxLength={64} autoComplete="off" /> {/* Catalog picker dropdown — filters the 700+ helpers_cache entries by name substring while the user types. Top 20 matches shown. Click one to auto-fill name / logo / ports / tracking hint in one shot. Empty query with the input focused shows a "start typing" hint. */} {pickerOpen && catalog.length > 0 && (() => { const q = (draft.name || "").trim().toLowerCase() if (!q) return null const matches = catalog .filter((c) => c.name.toLowerCase().includes(q) || c.slug.toLowerCase().includes(q)) .slice(0, 20) if (!matches.length) return null return (
{matches.length === 20 ? t("vmLxc.appEditor.top20Matches") : t("vmLxc.appEditor.matchCount", { count: matches.length })}
{matches.map((c) => ( ))}
) })()}
{/* App-level logo URL — optional. Auto-filled from the catalog for helper-scripts installs, blank otherwise. For a custom app the user can paste a URL (typically from https://selfh.st/icons); empty → no logo in the app card header. */}
setField({ logo_url: e.target.value })} placeholder={t("vmLxc.appEditor.portLogoPlaceholder")} maxLength={512} className="text-sm mt-2 font-mono" />
{/* Web links — each port becomes a clickable link (built as http[s]://:). Detected chips add the port directly (no need to first open an empty row). */}
{/* Detected chips FIRST — one-click add. Only shown when there are chips left to suggest, so empty states stay clean. Click on a chip: fills the current empty row or adds a new one; never duplicates. */} {suggestable.length > 0 && (
{t("vmLxc.appEditor.detectedPorts")} {suggestable.map((p) => ( ))}
)} {draft.ports.length === 0 && suggestable.length === 0 && (
{t("vmLxc.appEditor.noWebPorts")}
)}
{draft.ports.map((entry, i) => (
setPort(i, { port: e.target.value ? Number(e.target.value) : "" })} placeholder={t("vmLxc.appEditor.portPortPlaceholder")} min={1} max={65535} className="text-sm" /> setPort(i, { description: e.target.value })} placeholder={t("vmLxc.appEditor.portDescriptionPlaceholder")} maxLength={64} className="text-sm" /> {/* Per-link logo URL — spans cols 1-3 so its right edge lines up with the description input above (never covers the trash column). */} setPort(i, { logo_url: e.target.value })} placeholder={t("vmLxc.appEditor.portLogoLabel")} maxLength={512} className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100" type="url" />
))}
{/* ── Advanced (Version tracking, optional) ────────── Collapsed by default so casual users never see the technical fields. Auto-expanded when editing an app that already has tracking configured, or when the user registered from an auto-detected chip whose hint carried tracking metadata. */}
{showAdvanced && (

{t("vmLxc.appEditor.trackHelp")}

{isPackaged && (
setField({ package: e.target.value })} placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"} />
Auto-filled from Name. Verify with{" "} {method === "dpkg" ? "dpkg -l | grep " : "apk info | grep "} {" "} inside the CT.
)} {method === "binary" && (
setField({ binary_path: e.target.value })} placeholder={t("vmLxc.appEditor.binaryPathPlaceholder")} className="font-mono text-xs" />
Absolute path. Find it with{" "} which <app> or{" "} systemctl show <service> -p ExecStart.
)}
{method === "file" && (
setField({ file_path: e.target.value })} placeholder="/opt/app/VERSION" className="font-mono text-xs" />
setField({ file_regex: e.target.value })} placeholder={t("vmLxc.appEditor.regexPlaceholderVersion")} className="font-mono text-xs" />
)} {method === "python_dist" && (
setField({ python_path: e.target.value })} placeholder={t("vmLxc.appEditor.pythonInterpreterPlaceholder")} className="font-mono text-xs" />
setField({ distribution: e.target.value })} placeholder={t("vmLxc.appEditor.pipDistPlaceholder")} className="font-mono text-xs" />
)} {method === "docker_label" && (
setField({ container_name: e.target.value })} placeholder={t("vmLxc.appEditor.containerNamePlaceholder")} className="font-mono text-xs" />
setField({ label: e.target.value })} placeholder={t("vmLxc.appEditor.ociLabelPlaceholder")} className="font-mono text-xs" />
)} {method === "docker_exec" && (
setField({ container_name: e.target.value })} placeholder={t("vmLxc.appEditor.containerNamePlaceholder")} className="font-mono text-xs" />
setField({ binary_path: e.target.value })} placeholder={t("vmLxc.appEditor.binaryPathBarePlaceholder")} className="font-mono text-xs" />
setField({ binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean), })} placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")} className="font-mono text-xs" />
{t("vmLxc.appEditor.binaryArgsHintPrefix")} --version. {t("vmLxc.appEditor.binaryArgsHintGrafana")} server, -v.
)} {method === "command" && (
setField({ command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean), })} placeholder={t("vmLxc.appEditor.commandPlaceholder")} className="font-mono text-xs" />
{t("vmLxc.appEditor.commandSafetyHelp")}
setField({ installed_regex: e.target.value })} placeholder={t("vmLxc.appEditor.tagRegexBare")} className="font-mono text-xs mt-1" />
)} {method === "manual" && (
setField({ installed_version: e.target.value })} placeholder="1.2.3" maxLength={64} className="font-mono text-xs" />
{t("vmLxc.appEditor.manualVersionHelp")}
)} {method && (() => { // Upstream source selector — 3 methods (github, // http_json, docker_hub). Legacy sidecars with a // `repo` set but no `upstream_type` default to // github so the classic behaviour keeps working // until the user re-saves. const upstreamType = draft.upstream_type || (draft.repo ? "github" : "") const setUpstream = (t: "" | "github" | "http_json" | "docker_hub") => { // Clear other-type fields when switching so the // backend doesn't receive stale data. const patch: Partial = { upstream_type: t } if (t !== "github") { patch.repo = "" patch.github_source = "releases" } if (t !== "http_json") { patch.upstream_url = "" patch.upstream_json_path = "" } if (t !== "docker_hub") { patch.docker_image = "" } if (!t) patch.tag_regex = "" setField(patch) } return ( <>
{t("vmLxc.appEditor.upstreamHelp")}
{upstreamType === "github" && (
setField({ repo: e.target.value })} placeholder={t("vmLxc.appEditor.githubRepoPlaceholder")} />
{t("vmLxc.appEditor.githubRepoHelp")}
)} {upstreamType === "http_json" && (
setField({ upstream_url: e.target.value })} placeholder={t("vmLxc.appEditor.endpointUrlPlaceholder")} className="font-mono text-xs" maxLength={512} />
{t("vmLxc.appEditor.httpJsonHelp")}
setField({ upstream_json_path: e.target.value })} placeholder={t("vmLxc.appEditor.jsonPathPlaceholder")} className="font-mono text-xs" maxLength={128} />
{t("vmLxc.appEditor.jsonPathHelp")}
)} {upstreamType === "docker_hub" && (
setField({ docker_image: e.target.value })} placeholder={t("vmLxc.appEditor.dockerImagePlaceholder")} className="font-mono text-xs" maxLength={255} />
owner/name — or bare name for official images. ProxMenux picks the highest semver tag matching the filter below.
)} {upstreamType && (
setField({ tag_regex: e.target.value })} placeholder={t("vmLxc.appEditor.tagRegexPlaceholder")} className="font-mono text-xs" />
{upstreamType === "github" && "Extracts the version from the release tag name."} {upstreamType === "http_json" && "Optional — extract a substring from the endpoint's value."} {upstreamType === "docker_hub" && "Filter which tags qualify (e.g. only semver). Applied before picking the highest."}
)} ) })()}
)}
{error && (
{error}
)}
) } // Restore chip — same visual shell as detection chip but the // action switches from Register/Hide to Restore. Used inside the // Register-a-different-app panel when hidden slugs exist. const renderRestoreChip = (d: DetectedApp) => (
{d.logo_url && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )}
{d.name}
{t("vmLxc.appEditor.hiddenBadge")}
) // Uniform detection chip used in every context — empty state, // post-registration "also detected" strip, and the Register-a- // different-app panel. Actions layout responsive: // • Desktop (sm+): Register + Hide side-by-side, both labeled // • Mobile: same row, 3/4 Register (label+icon) + 1/4 Hide // (eye icon only inside a red-translucent button) // // Single "Register" button covers both paths — with or without // tracking hint — the editor opens pre-filled with whatever data // we have, and the user can adjust in Advanced. const renderDetectionChip = (d: DetectedApp) => (
{d.logo_url && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )}
{d.name}
{t("vmLxc.appEditor.detectedInContainer")}
) return (
{/* Browse panel — surfaces hidden detections with Restore before falling through to the blank-form path. Rendered before app cards / empty state so it takes precedence when open. Closes automatically once all hidden slugs are restored (nothing left to show → back to normal flow). */} {browseOpen && (

{t("vmLxc.appEditor.registerDifferent")}

{t(hiddenDetections.length === 1 ? "vmLxc.appEditor.hiddenDetectionsHelpSingular" : "vmLxc.appEditor.hiddenDetectionsHelpPlural", { count: hiddenDetections.length })}

{hiddenDetections.length > 0 && (
{hiddenDetections.map(renderRestoreChip)}
)}
)} {/* Empty state — always uniform chip list regardless of how many detections there are (0, 1, or many). Below the chips, a single "Register a different app" button lets the user add something the auto-detector doesn't know about. */} {apps.length === 0 && (

{t("vmLxc.appEditor.noAppsTitle")}

{t("vmLxc.appEditor.noAppsBody")}

{visibleDetected.length > 0 && (
{visibleDetected.map(renderDetectionChip)}
)}
)} {/* App cards */} {apps.map((app) => { const st = app.state // Version tracking is on when installed_via is set. Without a // method the app is register-only — no cards, no warnings. const tracking = !!app.installed_via const hasUpdate = tracking && st?.update_available === true const upToDate = tracking && st?.update_available === false && !!st?.installed_version return (
{app.logo_url && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )}

{app.name}

{tracking && (
{app.installed_via === "dpkg" && app.package && <>dpkg · {app.package}} {app.installed_via === "apk" && app.package && <>apk · {app.package}} {app.installed_via === "file" && app.file_path && <>file · {app.file_path}} {app.installed_via === "binary" && app.binary_path && <>binary · {app.binary_path}}
)} {tracking && st?.checked_at && (
Checked {new Date(st.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}
)} {/* Mobile-only repo link: falls into the metadata stack below Checked, full-width so long repo names wrap cleanly instead of competing with the top-right on narrow screens. */} {app.repo && tracking && ( {app.repo} )}
{/* Desktop-only repo link: same row as the title on md+, hidden on mobile where the stacked variant above handles it. */} {app.repo && tracking && ( {app.repo} )}
{/* Version panels — always same-line (grid-cols-2), on every viewport when ANY upstream source is configured (github repo, http_json endpoint, or docker_hub image). The signal that an update exists is the LATEST UPSTREAM number turning purple + a small ArrowUpCircle next to it, matching the project-wide update button convention. Absence of purple = up to date; no colored banners, no green/orange noise. Labels stay muted so numbers keep visual priority. When no upstream is configured, the LATEST panel is omitted entirely and INSTALLED takes the full width. */} {(() => { const hasUpstream = !!(app.repo || app.upstream_type) if (!tracking || !(st?.installed_version || hasUpstream)) return null return (
{st?.installed_version && (
{t("vmLxc.appEditor.installedStatus")}
{st.installed_version} {upToDate && ( )}
)} {hasUpstream && (
{t("vmLxc.appEditor.latestUpstream")}
{st?.latest_version || {t("vmLxc.appEditor.checkingStatus")}} {hasUpdate && st?.latest_version && ( )}
)}
) })()} {tracking && st?.error && (
{st.error}
)} {/* Web links — one row per port. Each row: [logo 56px] Description or app name ↗ http://IP:PORT 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 && (
{app.ports.map((p) => { const url = buildWebUrl(ctIp, p.port, p.scheme) if (!url) return null const label = p.description || app.name return (
{p.logo_url && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )}
{label} {url}
) })}
)} {/* Footer with per-card actions — only rendered in the global edit mode (toggled from the "Edit" button next to Add another application). View mode keeps cards chrome-free; Check is still reachable via the hover-reveal icon on the LATEST panel. Buttons match the Settings-page section style (h-8, outline, small icon + label) for visual consistency across the app. */} {editMode && (
{tracking && ( )}
)}
) })} {/* Post-registration "also detected" strip — every hint slug whose install signature is present on the CT AND that hasn't been registered yet is shown as a chip with a one-click Register button. Filtered against the sidecar's `helper_slug` field so a registered app never re-appears. */} {apps.length > 0 && unregisteredDetected.length > 0 && (
{t("vmLxc.appEditor.alsoDetectedContainer")}
{unregisteredDetected.map(renderDetectionChip)}
)} {/* Add-more + Edit toggle. Edit is a global toggle that reveals the per-card action footer (Remove / Check / Edit fields). Add-more is disabled while editing so the two flows don't overlap. Routes through the browse panel if there are hidden detections, so the user gets one-click Restore before hand- typing a custom app. */} {apps.length > 0 && (
)} {error && (
{error}
)}
) }