"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 image updates live exclusively in the Updates tab. The App * tab only registers the Docker engine/app identity and shows installed * metadata, so an unregistered detection can never create update noise. * * 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, Bell, BellOff, Search, } 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 { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache" import { categoryChipStyle, useIsLightTheme } from "../lib/category-color" 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 // Free-text label picked from the presets sourced by // /api/apps/categories (built from helpers_cache.category_names) or // typed manually. Powers the Apps dashboard filter/group. category?: string // Overrides ip:port composition when present — used for apps that // sit behind a reverse-proxy domain. The Apps dashboard opens this // URL as-is instead of `${scheme}://${ip}:${port}${path}`. custom_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 // Preserved here even though this editor does not execute updates. // The backend uses full-record replacement, so omitting these when // editing ports/tracking would silently erase the Updates-tab setup. update_command?: string hide_no_updater_notice?: boolean // Per-app opt-out for the `app_update_available` notification. // Absent / true = notify; false = silenced. Set from the bell // toggle on each app card and/or the Edit form's checkbox. notifications_enabled?: boolean // Per-app opt-out for the CT's aggregate updates badge (default // false = counted). Independent from `notifications_enabled`. exclude_from_badge?: boolean } interface DetectedApp { slug: string name: string logo_url?: string | null default_ports?: number[] // Categoría preset from helpers_cache.category_names[0] — used to // auto-fill the Web Link editor when the user clicks "Register". category?: string | null tracking_suggestion?: TrackingSuggestion | null } interface AppState { installed_version: string | null latest_version: string | null latest_published_at?: 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 DetectorTestResult { valid: boolean persisted: false checked_at: string installed: { configured: boolean method: InstalledVia | null effective_regex: string | null version: string | null error: string | null } upstream: { configured: boolean type: "github" | "http_json" | "docker_hub" | null version: string | null published_at?: string | null error: string | null } update_available: boolean | null } interface TrackingSuggestion { installed_via: Exclude 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_type?: "github" | "http_json" | "docker_hub" | "" upstream_url?: string upstream_json_path?: string docker_image?: string repo?: string github_source?: "releases" | "tags" tag_regex?: string detected_version?: string detector_verified?: boolean detector_source?: "primary" | "alternative" | "helper_marker" | "legacy_fallback" | "runtime_probe" | "candidate" detector_error?: string } interface DockerTagPreview { image: string regex: string tags: Array<{ tag: string; version: string | null; moving: boolean }> matched_count: number scanned_count: number cached_for_seconds: number } interface DockerWebLinkSuggestion { container_name: string service_name: string service_slug?: string | null image: string host_port: number container_port: number scheme: "http" | "https" web_path: string logo_url?: string | null } interface Suggestions { ready?: boolean 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 category?: string | null extras?: DetectedApp[] docker_web_links?: DockerWebLinkSuggestion[] } // 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[] // First helpers_cache.category_names value for this slug — auto-fills // the Categoría field on each port when seeded. category?: string | null 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 // Optional seed payload from the parent's cross-open ref cache. When // supplied, the panel renders with real content on the very first // frame and only revalidates silently in the background — no // "Loading applications…" flash on tab switch or modal reopen. Must // include BOTH sidecar and suggestions — the panel's empty-state and // detected-chip strip both depend on suggestions, so seeding sidecar // alone briefly flashes "no apps registered" until suggestions // arrives from the network. initialData?: { sidecar: SidecarResponse; suggestions: Suggestions | null } | 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: "", } const SELFHST_WEBP_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp" const SELFHST_THEME_LOGOS: Record = { frigate: { lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`, darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`, }, portainer: { lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`, darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`, }, vaultwarden: { lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`, darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`, }, } function selfhstThemeLogos(src: string) { if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null const match = src.toLowerCase().match(/\/(frigate|portainer|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/) return match ? SELFHST_THEME_LOGOS[match[1]] : null } export function ThemeAwareLogo({ src, className }: { src: string; className: string }) { const themed = selfhstThemeLogos(src) const hideBroken = (e: React.SyntheticEvent) => { e.currentTarget.style.display = "none" } if (!themed) { return } return ( <> ) } // 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", customUrl?: string) { const custom = (customUrl || "").trim() if (custom) return custom 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, "") } // The argv editors use a comma-separated display value, while the API stores // each argument as an array item. Keep this conversion separate from the text // shown in the controlled input: normalising the visible value on every // keystroke would remove a newly typed comma or trailing space before the user // can enter the next argument. function parseArgvInput(value: string): string[] { return value.split(",").map((item) => item.trim()).filter(Boolean) } export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) { const t = useT() const isLightTheme = useIsLightTheme() // Seed from `initialData` first, then fall back to the shared cache // module. Together those two sources cover every reopen scenario // without flashing a spinner — see lxc-apps-cache.ts for the dedup // logic that also keeps concurrent fetches from racing. const seed = initialData ?? getLxcAppsCached(vmid) ?? null const [loading, setLoading] = useState(!seed) const [sidecar, setSidecar] = useState(seed?.sidecar ?? null) const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) const [error, setError] = useState(null) const [searchingApplications, setSearchingApplications] = useState(false) const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null) // Editor state const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null) const [binaryArgsInput, setBinaryArgsInput] = useState("") const [commandArgvInput, setCommandArgvInput] = useState("") const [saving, setSaving] = useState(false) const [testingDetector, setTestingDetector] = useState(false) const [detectorTest, setDetectorTest] = useState(null) 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) // Preset categories exposed by /api/apps/categories (built from // helpers_cache.category_names). Feeds the Categoría { 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). */}
{suggestableDockerLinks.length > 0 && (
{t("vmLxc.appEditor.dockerPublishedServicesTitle")}

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

{suggestableDockerLinks.map((link) => (
{link.logo_url && ( )}
{link.service_name}
{link.image} · {t("vmLxc.appEditor.dockerPublishedPort", { containerPort: link.container_port, hostPort: link.host_port, })}
))}
)} {/* 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 && suggestableDockerLinks.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" type="url" /> {/* Category + custom URL — stacked on mobile so each field gets full width; side-by-side on tablet+ (≥sm) to save vertical space. Both span cols 1-3 via the outer wrapper. */}
{/* Per-link category — setPort(i, { category: e.target.value })} onBlur={() => { if (!(entry.category || "").trim()) { setCustomCategoryPorts((s) => { const n = new Set(s); n.delete(i); return n }) } }} onKeyDown={(e) => { if (e.key === "Escape") { setPort(i, { category: "" }) setCustomCategoryPorts((s) => { const n = new Set(s); n.delete(i); return n }) } }} placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")} maxLength={60} className="text-xs h-8" /> ) : ( )}
{/* Per-link custom URL — takes precedence over ip:port when the app lives behind a reverse proxy on a public domain. */} setPort(i, { custom_url: e.target.value })} placeholder={t("vmLxc.appEditor.portCustomUrlPlaceholder")} maxLength={512} className="flex-1 min-w-0 text-xs font-mono h-8" 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" />
{ const value = e.target.value setBinaryArgsInput(value) setField({ binary_args: parseArgvInput(value) }) }} placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")} className="font-mono text-xs" />
{t("vmLxc.appEditor.binaryArgsHintPrefix")} --version. {t("vmLxc.appEditor.binaryArgsHintGrafana")} server, -v.
)} {method === "command" && (
{ const value = e.target.value setCommandArgvInput(value) setField({ command_argv: parseArgvInput(value) }) }} 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} />
{t("vmLxc.appEditor.dockerVersionedTagsHelp")}
)} {upstreamType && (
setField({ tag_regex: e.target.value })} placeholder={t("vmLxc.appEditor.tagRegexPlaceholder")} className="font-mono text-xs" /> {upstreamType === "docker_hub" && (
)}
{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" && t("vmLxc.appEditor.dockerTagFilterHelp")}
)} {upstreamType === "docker_hub" && draft.docker_image?.trim() && (
{t("vmLxc.appEditor.dockerTagPreviewLabel")}
{dockerTagPreview && ( {t("vmLxc.appEditor.dockerTagPreviewCount", { matched: dockerTagPreview.matched_count, scanned: dockerTagPreview.scanned_count, })} )}
{dockerTagPreviewLoading ? (
{t("vmLxc.appEditor.dockerTagPreviewLoading")}
) : dockerTagPreviewError ? (
{dockerTagPreviewError}
) : dockerTagPreview?.tags.length ? (
{dockerTagPreview.tags.map((entry) => ( {entry.tag}{entry.moving ? ` · ${t("vmLxc.appEditor.dockerMovingTag")}` : ""} ))}
) : (
{t("vmLxc.appEditor.dockerTagPreviewEmpty")}
)} {dockerTagPreview?.tags.some((entry) => entry.moving) && (
{t("vmLxc.appEditor.dockerMovingTagHelp")}
)}
)} ) })()}
)}
{/* Per-app notification opt-out. Only makes sense for apps with tracking configured — without an upstream source there's no `app_update_available` event to mute. Default is ON (checkbox checked); the bell toggle on each card is a shortcut to the same field. The second checkbox below controls the CT's aggregate updates badge independently — a user may want the outbound notification but hide the counter (or the reverse). */} {method && (
)} {detectorTest && (
{t("vmLxc.appEditor.detectorTestTitle")}
{t("vmLxc.appEditor.installedStatus")}
{detectorTest.installed.version ? (
{detectorTest.installed.version}
) : (
{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}
)}
{detectorTest.installed.method || "—"} {detectorTest.installed.effective_regex ? ` · ${detectorTest.installed.effective_regex}` : ""}
{t("vmLxc.appEditor.latestUpstream")}
{!detectorTest.upstream.configured ? (
{t("vmLxc.appEditor.detectorTestNoUpstream")}
) : detectorTest.upstream.version ? (
{detectorTest.upstream.version}
) : (
{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}
)} {detectorTest.upstream.type && (
{detectorTest.upstream.type}
)}
)} {error && (
{error}
)}
{method && ( )}
) } // 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 && ( )}
{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 && ( )}
{d.name}
{d.tracking_suggestion?.detector_verified ? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" }) : t("vmLxc.appEditor.detectedInContainer")}
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
{t("vmLxc.appEditor.legacyDetectorUsed")}
)}
) 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)}
)}
{detectionNotice && (

{detectionNotice.text}

)}
)} {/* 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 return (
{app.logo_url && ( )}

{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 && (
{t("vmLxc.appEditor.checkedAt", { date: 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} )}
{(() => { const hasUpstream = !!(app.repo || app.upstream_type) const hasUpdate = st?.update_available === true if (!tracking || !(st?.installed_version || hasUpstream)) return null return (
{st?.installed_version && (
{t("vmLxc.appEditor.installedStatus")}
{st.installed_version}
)} {hasUpstream && (
{t("vmLxc.appEditor.latestUpstream")}
{st?.latest_version || {t("vmLxc.appEditor.checkingStatus")}} {hasUpdate && st?.latest_version && ( )}
)}
) })()} {tracking && st?.error && (
{localizeUpstreamError(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, p.custom_url) if (!url) return null const label = p.description || app.name return (
{p.logo_url && ( )}
{label}
{url} {/* Category chip — same OKLCH deterministic colour as the Apps dashboard. Anchored right end of the weblink row so the URL gets `flex-1` (truncates when long) while the chip keeps its full width. */} {p.category && ( {p.category} )}
) })}
)} {/* 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 remains available in edit mode. Buttons match the Settings-page section style (h-8, outline, small icon + label) for visual consistency across the app. */} {editMode && (
{tracking && ( )} {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 && ( // Mobile: three buttons in one row, aligned right. Order is // Search → Register → Edit (Edit rightmost, matches desktop). // All three share the same width — a min-w that fits the // widest translated label of the Edit button ("Bearbeiten" in // DE, 10 chars) so the icon-only Search and Register buttons // line up as neat equal squares next to the labelled Edit. // Desktop: no min-width — each button auto-sizes to its text.
)} {apps.length > 0 && detectionNotice && (

{detectionNotice.text}

)} {error && (
{error}
)}
) }