Files
ProxMenux/AppImage/components/lxc-app-panel.tsx
T

1891 lines
88 KiB
TypeScript

"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<SidecarResponse | null>(null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)
const [error, setError] = useState<string | null>(null)
// Editor state
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
const [saving, setSaving] = useState(false)
const [busyAppId, setBusyAppId] = useState<string | null>(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<CatalogEntry[]>([])
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 (
<div className="space-y-4">
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
{/* Block 1 — Secure Gateway identity (the ProxMenux
product). Big shield, title, catalog subtitle. */}
<div className="flex items-start gap-3">
<div className="h-14 w-14 flex-shrink-0 rounded-md bg-cyan-500/10 flex items-center justify-center">
<ShieldCheck className="h-11 w-11 text-cyan-500" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-xl font-semibold text-foreground truncate">{displayName}</h3>
{displaySubtitle && (
<div className="text-sm text-muted-foreground mt-0.5 truncate">{displaySubtitle}</div>
)}
</div>
</div>
{/* 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 && (
<div className="mt-4 pt-4 border-t border-border/50">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{upstreamLogoLightUrl || upstreamLogoDarkUrl ? (
<>
{upstreamLogoLightUrl && (
<img
src={upstreamLogoLightUrl}
alt=""
className="h-10 w-10 flex-shrink-0 rounded-md object-contain block dark:hidden"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
{upstreamLogoDarkUrl && (
<img
src={upstreamLogoDarkUrl}
alt=""
className="h-10 w-10 flex-shrink-0 rounded-md object-contain hidden dark:block"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
</>
) : (
<div className="h-10 w-10 flex-shrink-0 rounded-md bg-muted flex items-center justify-center">
<Package className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-foreground truncate">{upstreamName}</div>
<div className="text-xs text-muted-foreground mt-0.5">{methodLine}</div>
{managed.checked_at && (
<div className="text-[10px] text-muted-foreground/80 mt-0.5">
{t("vmLxc.appEditor.checkedAt", { date: new Date(managed.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) })}
</div>
)}
{repo && (
<a
href={`https://github.com/${repo}`}
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"
>
<ExternalLink className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{repo}</span>
</a>
)}
</div>
</div>
{repo && (
<a
href={`https://github.com/${repo}`}
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"
>
{repo}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
)}
{showVersions && (
<div className={"mt-3 grid gap-3 " + (repo ? "grid-cols-2" : "grid-cols-1")}>
{managed.installed_version && (
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
{managed.installed_version}
{upToDate && (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
)}
</div>
</div>
)}
{repo && (
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">
{t("vmLxc.appEditor.latestUpstream")}
</div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{managed.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && managed.latest_version && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)}
</div>
</div>
)}
</div>
)}
{managed.error && (
<div className="mt-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-amber-300">{managed.error}</div>
</div>
)}
{/* Managed banner — green translucent badge signalling this
CT's lifecycle is owned by ProxMenux (not user CRUD). */}
<div className="mt-4 p-2.5 rounded-md bg-green-500/10 border border-green-500/30 flex items-center gap-2 text-sm">
<ShieldCheck className="h-4 w-4 text-green-400 flex-shrink-0" />
<span className="text-green-300">{t("vmLxc.appEditor.installedManaged")}</span>
</div>
</CardContent>
</Card>
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{t("vmLxc.appEditor.loadingApplications")}
</div>
)
}
// ── Editor ─────────────────────────────────────────────────────
if (editing) {
const draft = editing.draft
const method = draft.installed_via || ""
const isPackaged = method === "dpkg" || method === "apk"
const setField = (patch: Partial<AppConfig>) =>
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<AppConfig> = { name }
if (isPackaged && !draft.package?.trim()) {
patch.package = suggestPackageName(name)
}
setField(patch)
}
const setPort = (i: number, patch: Partial<PortEntry>) => {
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 (
<div className="space-y-4">
{/* 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`. */}
<Card className="border border-border bg-card">
<CardContent className="p-4 space-y-4">
<div className="relative">
<Label htmlFor="app-name">{t("vmLxc.appEditor.nameLabel")}</Label>
<Input
id="app-name"
value={draft.name}
onChange={(e) => { 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 (
<div className="absolute z-20 left-0 right-0 mt-1 max-h-72 overflow-y-auto rounded-md border border-border bg-popover shadow-lg">
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border/60">
{matches.length === 20 ? t("vmLxc.appEditor.top20Matches") : t("vmLxc.appEditor.matchCount", { count: matches.length })}
</div>
{matches.map((c) => (
<button
key={c.slug}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={async () => {
setPickerOpen(false)
try {
const detail: CatalogDetail = await fetchApi(`/api/apps/catalog/${c.slug}`)
// Seed the entire form from the picker detail.
const patch: Partial<AppConfig> = {
name: detail.name,
helper_slug: detail.slug,
logo_url: detail.logo_url || "",
ports: detail.default_ports?.length
? detail.default_ports.map((p) => ({
port: p,
scheme: defaultSchemeFor(p),
web_path: "",
}))
: draft.ports,
}
if (detail.tracking_suggestion) {
const t = detail.tracking_suggestion as any
patch.installed_via = t.installed_via
patch.package = t.package || ""
patch.file_path = t.file_path || ""
patch.file_regex = t.file_regex || ""
patch.binary_path = t.binary_path || ""
patch.binary_args = t.binary_args || []
patch.python_path = t.python_path || ""
patch.distribution = t.distribution || ""
patch.container_name = t.container_name || ""
patch.label = t.label || ""
patch.installed_regex = t.installed_regex || ""
patch.upstream_type = (t as any).upstream_type || (t.repo ? "github" : "")
patch.repo = t.repo || ""
patch.github_source = t.github_source || "releases"
patch.upstream_url = (t as any).upstream_url || ""
patch.upstream_json_path = (t as any).upstream_json_path || ""
patch.docker_image = (t as any).docker_image || ""
patch.tag_regex = t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)"
setShowAdvanced(true)
}
setEditing((prev) => prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev)
} catch { /* non-fatal */ }
}}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground transition-colors"
>
{c.logo && (
<img src={c.logo} alt="" className="h-6 w-6 flex-shrink-0 rounded object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }} />
)}
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground truncate">{c.name}</div>
<div className="text-[10px] text-muted-foreground">
{c.slug}
{c.default_port ? ` · ${t("vmLxc.appEditor.catalogPort", { port: c.default_port })}` : ""}
{c.has_tracking ? ` · ${t("vmLxc.appEditor.trackingAvailable")}` : ""}
</div>
</div>
</button>
))}
</div>
)
})()}
</div>
{/* 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. */}
<div className="pt-2 border-t border-border/50">
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
</Label>
<Input
id="app-logo"
type="url"
value={draft.logo_url || ""}
onChange={(e) => setField({ logo_url: e.target.value })}
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
maxLength={512}
className="text-sm mt-2 font-mono"
/>
</div>
{/* Web links — each port becomes a clickable link (built as
http[s]://<ip>:<port>). Detected chips add the port
directly (no need to first open an empty row). */}
<div className="pt-2 border-t border-border/50">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.webLinks")}
</Label>
<Button type="button" variant="ghost" size="sm" onClick={addEmptyPort}>
<PlusCircle className="h-3.5 w-3.5 mr-1" />
{t("vmLxc.appEditor.addPort")}
</Button>
</div>
{/* 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 && (
<div className="mb-2 flex flex-wrap gap-1 items-center">
<span className="text-[10px] text-muted-foreground pr-1">
{t("vmLxc.appEditor.detectedPorts")}
</span>
{suggestable.map((p) => (
<button
key={p}
type="button"
onClick={() => addDetectedPort(p)}
className="text-[10px] px-1.5 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 text-foreground font-mono"
>
+{p}
</button>
))}
</div>
)}
{draft.ports.length === 0 && suggestable.length === 0 && (
<div className="text-xs text-muted-foreground italic">
{t("vmLxc.appEditor.noWebPorts")}
</div>
)}
<div className="space-y-3">
{draft.ports.map((entry, i) => (
<div
key={i}
className="grid grid-cols-[86px_86px_1fr_auto] gap-2 items-center gap-y-1.5"
>
<Select
value={entry.scheme || defaultSchemeFor(entry.port)}
onValueChange={(v) => setPort(i, { scheme: v as "http" | "https" })}
>
<SelectTrigger className="text-sm h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={entry.port}
onChange={(e) => setPort(i, { port: e.target.value ? Number(e.target.value) : "" })}
placeholder={t("vmLxc.appEditor.portPortPlaceholder")}
min={1}
max={65535}
className="text-sm"
/>
<Input
value={entry.description || ""}
onChange={(e) => setPort(i, { description: e.target.value })}
placeholder={t("vmLxc.appEditor.portDescriptionPlaceholder")}
maxLength={64}
className="text-sm"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removePort(i)}
className="text-red-400 hover:text-red-300"
aria-label={t("vmLxc.appEditor.removePortTooltip")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{/* Per-link logo URL — spans cols 1-3 so its
right edge lines up with the description input
above (never covers the trash column). */}
<Input
value={entry.logo_url || ""}
onChange={(e) => 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"
/>
</div>
))}
</div>
</div>
{/* ── 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. */}
<div className="pt-2 border-t border-border/50">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground w-full"
>
{showAdvanced ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t("vmLxc.appEditor.trackUpstream")}
{!showAdvanced && !method && (
<span className="ml-auto text-[10px] normal-case tracking-normal">
{t("vmLxc.appEditor.trackOff")}
</span>
)}
{!showAdvanced && method && (
<span className="ml-auto text-[10px] normal-case tracking-normal text-emerald-400/80">
{t("vmLxc.appEditor.trackOn", { method })}
</span>
)}
</button>
{showAdvanced && (
<div className="mt-3 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.appEditor.trackHelp")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="app-method">{t("vmLxc.appEditor.installedViaLabel")}</Label>
<Select
value={method || "none"}
onValueChange={(v) =>
setField({ installed_via: v === "none" ? "" : (v as InstalledVia) })
}
>
<SelectTrigger id="app-method"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("vmLxc.appEditor.methodNone")}</SelectItem>
<SelectItem value="dpkg">{t("vmLxc.appEditor.methodDpkg")}</SelectItem>
<SelectItem value="apk">{t("vmLxc.appEditor.methodApk")}</SelectItem>
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
<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>
</SelectContent>
</Select>
</div>
{isPackaged && (
<div>
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
<Input
id="app-package"
value={draft.package || ""}
onChange={(e) => setField({ package: e.target.value })}
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
/>
<div className="text-[10px] text-muted-foreground mt-1">
Auto-filled from Name. Verify with{" "}
<code className="text-foreground/70">
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
</code>{" "}
inside the CT.
</div>
</div>
)}
{method === "binary" && (
<div>
<Label htmlFor="app-binary">{t("vmLxc.appEditor.binaryPathLabel")}</Label>
<Input
id="app-binary"
value={draft.binary_path || ""}
onChange={(e) => setField({ binary_path: e.target.value })}
placeholder={t("vmLxc.appEditor.binaryPathPlaceholder")}
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Absolute path. Find it with{" "}
<code className="text-foreground/70">which &lt;app&gt;</code> or{" "}
<code className="text-foreground/70">systemctl show &lt;service&gt; -p ExecStart</code>.
</div>
</div>
)}
</div>
{method === "file" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="app-file-path">{t("vmLxc.appEditor.filePathLabel")}</Label>
<Input
id="app-file-path"
value={draft.file_path || ""}
onChange={(e) => setField({ file_path: e.target.value })}
placeholder="/opt/app/VERSION"
className="font-mono text-xs"
/>
</div>
<div>
<Label htmlFor="app-file-regex">{t("vmLxc.appEditor.regexCaptureGroupLabel")}</Label>
<Input
id="app-file-regex"
value={draft.file_regex || ""}
onChange={(e) => setField({ file_regex: e.target.value })}
placeholder={t("vmLxc.appEditor.regexPlaceholderVersion")}
className="font-mono text-xs"
/>
</div>
</div>
)}
{method === "python_dist" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="app-py-path">{t("vmLxc.appEditor.pythonInterpreterLabel")}</Label>
<Input
id="app-py-path"
value={draft.python_path || ""}
onChange={(e) => setField({ python_path: e.target.value })}
placeholder={t("vmLxc.appEditor.pythonInterpreterPlaceholder")}
className="font-mono text-xs"
/>
</div>
<div>
<Label htmlFor="app-py-dist">{t("vmLxc.appEditor.pipDistLabel")}</Label>
<Input
id="app-py-dist"
value={draft.distribution || ""}
onChange={(e) => setField({ distribution: e.target.value })}
placeholder={t("vmLxc.appEditor.pipDistPlaceholder")}
className="font-mono text-xs"
/>
</div>
</div>
)}
{method === "docker_label" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="app-dl-container">{t("vmLxc.appEditor.containerNameLabel")}</Label>
<Input
id="app-dl-container"
value={draft.container_name || ""}
onChange={(e) => setField({ container_name: e.target.value })}
placeholder={t("vmLxc.appEditor.containerNamePlaceholder")}
className="font-mono text-xs"
/>
</div>
<div>
<Label htmlFor="app-dl-label">{t("vmLxc.appEditor.ociLabelKeyLabel")}</Label>
<Input
id="app-dl-label"
value={draft.label || ""}
onChange={(e) => setField({ label: e.target.value })}
placeholder={t("vmLxc.appEditor.ociLabelPlaceholder")}
className="font-mono text-xs"
/>
</div>
</div>
)}
{method === "docker_exec" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="app-de-container">{t("vmLxc.appEditor.containerNameLabel")}</Label>
<Input
id="app-de-container"
value={draft.container_name || ""}
onChange={(e) => setField({ container_name: e.target.value })}
placeholder={t("vmLxc.appEditor.containerNamePlaceholder")}
className="font-mono text-xs"
/>
</div>
<div>
<Label htmlFor="app-de-binary">{t("vmLxc.appEditor.binaryPathBare")}</Label>
<Input
id="app-de-binary"
value={draft.binary_path || ""}
onChange={(e) => setField({ binary_path: e.target.value })}
placeholder={t("vmLxc.appEditor.binaryPathBarePlaceholder")}
className="font-mono text-xs"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="app-de-args">{t("vmLxc.appEditor.binaryArgsLabel")}</Label>
<Input
id="app-de-args"
value={(draft.binary_args || []).join(", ")}
onChange={(e) => setField({
binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
})}
placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")}
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.binaryArgsHintPrefix")} <code className="text-foreground/70">--version</code>. {t("vmLxc.appEditor.binaryArgsHintGrafana")} <code className="text-foreground/70">server, -v</code>.
</div>
</div>
</div>
)}
{method === "command" && (
<div>
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
<Input
id="app-cmd-argv"
value={(draft.command_argv || []).join(", ")}
onChange={(e) => setField({
command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
})}
placeholder={t("vmLxc.appEditor.commandPlaceholder")}
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.commandSafetyHelp")}
</div>
<div className="mt-2">
<Label htmlFor="app-cmd-regex">{t("vmLxc.appEditor.installedVersionRegexLabel")}</Label>
<Input
id="app-cmd-regex"
value={draft.installed_regex || ""}
onChange={(e) => setField({ installed_regex: e.target.value })}
placeholder={t("vmLxc.appEditor.tagRegexBare")}
className="font-mono text-xs mt-1"
/>
</div>
</div>
)}
{method === "manual" && (
<div>
<Label htmlFor="app-manual-ver">{t("vmLxc.appEditor.installedVersionLabel")}</Label>
<Input
id="app-manual-ver"
value={draft.installed_version || ""}
onChange={(e) => setField({ installed_version: e.target.value })}
placeholder="1.2.3"
maxLength={64}
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.manualVersionHelp")}
</div>
</div>
)}
{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<AppConfig> = { 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 (
<>
<div>
<Label htmlFor="app-upstream-type">{t("vmLxc.appEditor.upstreamSourceLabel")}</Label>
<Select
value={upstreamType || "none"}
onValueChange={(v) => setUpstream(v === "none" ? "" : (v as any))}
>
<SelectTrigger id="app-upstream-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("vmLxc.appEditor.upstreamNone")}</SelectItem>
<SelectItem value="github">{t("vmLxc.appEditor.upstreamGithub")}</SelectItem>
<SelectItem value="http_json">{t("vmLxc.appEditor.upstreamHttp")}</SelectItem>
<SelectItem value="docker_hub">{t("vmLxc.appEditor.upstreamDocker")}</SelectItem>
</SelectContent>
</Select>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.upstreamHelp")}
</div>
</div>
{upstreamType === "github" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="app-repo">{t("vmLxc.appEditor.githubRepoLabel")}</Label>
<Input
id="app-repo"
value={draft.repo || ""}
onChange={(e) => setField({ repo: e.target.value })}
placeholder={t("vmLxc.appEditor.githubRepoPlaceholder")}
/>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.githubRepoHelp")}
</div>
</div>
<div>
<Label htmlFor="app-source">{t("vmLxc.appEditor.latestFromLabel")}</Label>
<Select
value={draft.github_source || "releases"}
onValueChange={(v) => setField({ github_source: v as GithubSource })}
>
<SelectTrigger id="app-source"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="releases">{t("vmLxc.appEditor.sourceReleases")}</SelectItem>
<SelectItem value="tags">{t("vmLxc.appEditor.sourceTags")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{upstreamType === "http_json" && (
<div className="space-y-3">
<div>
<Label htmlFor="app-upstream-url">{t("vmLxc.appEditor.endpointUrlLabel")}</Label>
<Input
id="app-upstream-url"
type="url"
value={draft.upstream_url || ""}
onChange={(e) => setField({ upstream_url: e.target.value })}
placeholder={t("vmLxc.appEditor.endpointUrlPlaceholder")}
className="font-mono text-xs"
maxLength={512}
/>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.httpJsonHelp")}
</div>
</div>
<div>
<Label htmlFor="app-upstream-path">{t("vmLxc.appEditor.jsonPathLabel")}</Label>
<Input
id="app-upstream-path"
value={draft.upstream_json_path || ""}
onChange={(e) => setField({ upstream_json_path: e.target.value })}
placeholder={t("vmLxc.appEditor.jsonPathPlaceholder")}
className="font-mono text-xs"
maxLength={128}
/>
<div className="text-[10px] text-muted-foreground mt-1">
{t("vmLxc.appEditor.jsonPathHelp")}
</div>
</div>
</div>
)}
{upstreamType === "docker_hub" && (
<div>
<Label htmlFor="app-docker-image">{t("vmLxc.appEditor.dockerImageLabel")}</Label>
<Input
id="app-docker-image"
value={draft.docker_image || ""}
onChange={(e) => setField({ docker_image: e.target.value })}
placeholder={t("vmLxc.appEditor.dockerImagePlaceholder")}
className="font-mono text-xs"
maxLength={255}
/>
<div className="text-[10px] text-muted-foreground mt-1">
<code className="text-foreground/70">owner/name</code> or bare
name for official images. ProxMenux picks the highest semver tag
matching the filter below.
</div>
</div>
)}
{upstreamType && (
<div>
<Label htmlFor="app-tag-regex">
{upstreamType === "docker_hub" ? t("vmLxc.appEditor.tagFilterRegexOptional") : t("vmLxc.appEditor.versionRegexOptional")}
</Label>
<Input
id="app-tag-regex"
value={draft.tag_regex || ""}
onChange={(e) => setField({ tag_regex: e.target.value })}
placeholder={t("vmLxc.appEditor.tagRegexPlaceholder")}
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
{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."}
</div>
</div>
)}
</>
)
})()}
</div>
)}
</div>
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
<div className="flex flex-wrap gap-2 justify-end pt-2">
<Button variant="ghost" onClick={closeEditor} disabled={saving}>{t("vmLxc.appEditor.cancelButton")}</Button>
<Button
onClick={save}
disabled={saving || !draft.name.trim()}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
{t("vmLxc.appEditor.saveButton")}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
// 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) => (
<div key={d.slug} className="p-3 rounded-md border border-border/60 bg-background/40">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{d.logo_url && (
<img
src={d.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
<div className="text-xs text-muted-foreground">{t("vmLxc.appEditor.hiddenBadge")}</div>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => restoreDetection(d.slug)}
className="w-full sm:w-auto"
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
{t("vmLxc.appEditor.restoreButton")}
</Button>
</div>
</div>
)
// 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) => (
<div key={d.slug} className="p-3 rounded-md border border-border/60 bg-background/40">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{d.logo_url && (
<img
src={d.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
<div className="text-xs text-emerald-400/90">{t("vmLxc.appEditor.detectedInContainer")}</div>
</div>
</div>
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
<Button
size="sm"
onClick={() => openEditor(undefined, { withTracking: !!d.tracking_suggestion, preset: d })}
className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white"
>
<PlusCircle className="h-3.5 w-3.5 mr-1" />
{t("vmLxc.appEditor.registerButton")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => dismissDetection(d.slug, d.name)}
aria-label={`Hide ${d.name} detection`}
title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
>
<EyeOff className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
</Button>
</div>
</div>
</div>
)
return (
<div className="space-y-4">
{/* 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 && (
<Card className="border border-border bg-card/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.appEditor.registerDifferent")}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{t(hiddenDetections.length === 1 ? "vmLxc.appEditor.hiddenDetectionsHelpSingular" : "vmLxc.appEditor.hiddenDetectionsHelpPlural", { count: hiddenDetections.length })}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setBrowseOpen(false)}
aria-label={t("vmLxc.appEditor.closePanel")}
className="text-muted-foreground hover:text-foreground"
>
{t("vmLxc.appEditor.cancelButton")}
</Button>
</div>
{hiddenDetections.length > 0 && (
<div className="space-y-2">
{hiddenDetections.map(renderRestoreChip)}
</div>
)}
<div className="pt-1 flex justify-center">
<Button
onClick={() => { setBrowseOpen(false); openEditor() }}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
<PlusCircle className="h-4 w-4 mr-1.5" />
{t("vmLxc.appEditor.registerCustom")}
</Button>
</div>
</CardContent>
</Card>
)}
{/* 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 && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
<Package className="h-5 w-5 text-emerald-400" />
</div>
<h3 className="text-sm font-semibold text-foreground text-center">
{t("vmLxc.appEditor.noAppsTitle")}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.noAppsBody")}
</p>
{visibleDetected.length > 0 && (
<div className="space-y-2 pt-2">
{visibleDetected.map(renderDetectionChip)}
</div>
)}
<div className="pt-1 flex justify-center">
<Button onClick={openBrowseOrEditor} variant={visibleDetected.length > 0 ? "outline" : "default"}
className={visibleDetected.length > 0 ? "" : "bg-blue-500 hover:bg-blue-600 text-white"}>
<PlusCircle className="h-4 w-4 mr-1.5" />
{visibleDetected.length > 0 ? t("vmLxc.appEditor.registerDifferent") : t("vmLxc.appEditor.registerApplication")}
{hiddenDetections.length > 0 && (
<span className="ml-2 text-[10px] opacity-70">
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
</span>
)}
</Button>
</div>
</CardContent>
</Card>
)}
{/* 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 (
<Card key={app.id} className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{app.logo_url && (
<img
src={app.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground truncate">{app.name}</h3>
{tracking && (
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5 flex-wrap">
{app.installed_via === "dpkg" && app.package && <>dpkg · <code className="text-foreground/80">{app.package}</code></>}
{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></>}
</div>
)}
{tracking && st?.checked_at && (
<div className="text-[10px] text-muted-foreground/80 mt-0.5">
Checked {new Date(st.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}
</div>
)}
{/* 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 && (
<a
href={`https://github.com/${app.repo}`}
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"
>
<ExternalLink className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{app.repo}</span>
</a>
)}
</div>
</div>
{/* Desktop-only repo link: same row as the title on md+,
hidden on mobile where the stacked variant above
handles it. */}
{app.repo && tracking && (
<a
href={`https://github.com/${app.repo}`}
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"
>
{app.repo}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{/* 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 (
<div className={"mb-3 grid gap-3 " + (hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
{st?.installed_version && (
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
{st.installed_version}
{upToDate && (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
)}
</div>
</div>
)}
{hasUpstream && (
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">
{t("vmLxc.appEditor.latestUpstream")}
</div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && st?.latest_version && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)}
</div>
</div>
)}
</div>
)
})()}
{tracking && st?.error && (
<div className="mb-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-amber-300">{st.error}</div>
</div>
)}
{/* 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 && (
<div className="mb-3 space-y-4">
{app.ports.map((p) => {
const url = buildWebUrl(ctIp, p.port, p.scheme)
if (!url) return null
const label = p.description || app.name
return (
<div key={p.port} className="flex items-start gap-3 min-w-0">
{p.logo_url && (
<img
src={p.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0 flex flex-col">
<span className="text-sm font-medium text-foreground truncate">{label}</span>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1.5 min-w-0"
title={url}
>
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
<span className="font-mono text-sm truncate">{url}</span>
</a>
</div>
</div>
)
})}
</div>
)}
{/* 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 && (
<div className="flex flex-wrap gap-2 items-center mt-3 pt-3 border-t border-border/50">
<button
type="button"
onClick={() => removeOne(app.id)}
disabled={busyAppId === app.id}
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.appEditor.removeButton")}
</button>
<div className="ml-auto flex flex-wrap gap-2">
{tracking && (
<button
type="button"
onClick={() => checkOne(app.id)}
disabled={busyAppId === app.id}
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"
>
{busyAppId === app.id
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
{t("vmLxc.appEditor.checkButton")}
</button>
)}
<button
type="button"
onClick={() => openEditor(app)}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
>
<Settings2 className="h-3.5 w-3.5" />
{t("vmLxc.appEditor.editFieldsButton")}
</button>
</div>
</div>
)}
</CardContent>
</Card>
)
})}
{/* 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 && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.alsoDetectedContainer")}
</div>
{unregisteredDetected.map(renderDetectionChip)}
</div>
)}
{/* 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 && (
<div className="flex justify-end items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={openBrowseOrEditor}
disabled={editMode}
>
<PlusCircle className="h-4 w-4 mr-1.5" />
{t("vmLxc.appEditor.addAnotherApplication")}
{hiddenDetections.length > 0 && (
<span className="ml-2 text-[10px] opacity-70">
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
</span>
)}
</Button>
<button
type="button"
onClick={() => setEditMode((v) => !v)}
className="h-9 px-3 text-sm rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
>
{editMode ? (
<>
<Check className="h-3.5 w-3.5" />
{t("vmLxc.appEditor.doneButton")}
</>
) : (
<>
<Pencil className="h-3.5 w-3.5" />
{t("vmLxc.appEditor.editButton")}
</>
)}
</button>
</div>
)}
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
</div>
)
}