"use client" import { useEffect, useMemo, useRef, useState } from "react" import useSWR from "swr" import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" import { ThemeAwareLogo } from "./lxc-app-panel" import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor" import { Button } from "./ui/button" import { categoryChipStyle, useIsLightTheme } from "../lib/category-color" import { getCategoryLabel } from "../lib/category-label" // ─── Local subset of /api/vms shape ───────────────────────────── // Kept narrow on purpose — this component only needs what feeds a // launcher card. Full VMData / LxcAppWatch types live in // virtual-machines.tsx. interface AppPort { port: number description?: string scheme?: "http" | "https" web_path?: string logo_url?: string | null category?: string custom_url?: string } interface AppWatch { id: string name: string | null logo_url?: string | null ports?: AppPort[] installed_version: string | null latest_version: string | null update_available: boolean | null managed_oci_app_id?: string | null helper_slug?: string } interface DockerImageUpdate { reference: string display_name?: string | null used_by?: string[] update_available: boolean | null } // Locate the docker_inventory image whose lifecycle matches a given // Web Link. The port's `description` is a user-typed label (e.g. // "Paperless") so exact match on `used_by` (real container names // like "paperless-webserver-1") almost never hits. Fall back through: // 1. exact match in `used_by` // 2. case-insensitive substring either way in `used_by` // 3. substring in `display_name` // 4. substring in `reference` (the full image path) // Returns undefined when nothing matches — the caller treats that as // "no upstream update signal for this port". function findDockerImageForPort( port: AppPort, images: DockerImageUpdate[], ): DockerImageUpdate | undefined { const desc = (port.description || "").trim().toLowerCase() if (!desc || !images.length) return undefined const exact = images.find((i) => (i.used_by || []).some((c) => c.toLowerCase() === desc), ) if (exact) return exact const inclUsedBy = images.find((i) => (i.used_by || []).some((c) => { const cl = c.toLowerCase() return cl.includes(desc) || desc.includes(cl) }), ) if (inclUsedBy) return inclUsedBy const byDisplay = images.find((i) => { const d = (i.display_name || "").toLowerCase() return !!d && (d.includes(desc) || desc.includes(d)) }) if (byDisplay) return byDisplay return images.find((i) => (i.reference || "").toLowerCase().includes(desc)) } interface VM { vmid: number name: string ip?: string type: string app_watches?: AppWatch[] docker_inventory?: { images?: DockerImageUpdate[] } } interface LaunchLink { key: string // Present for LXC-registered apps and for custom links with a // guest binding. Absent when the link is an unbound custom entry // (e.g. an external service). vmid: number | null guestType: "lxc" | "qemu" | null ctName: string appName: string logoUrl: string | null weblink: string category: string updateAvailable: boolean // Set for user-defined custom links so the card can offer edit // and delete actions in edit mode. isCustom: boolean customId?: string } // ─── Helpers ───────────────────────────────────────────────────── // Same URL construction as the Web Link row in the App tab. // Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx // is scoped to that module, and copying keeps this component free of // hidden cross-file dependencies. A per-port `custom_url` overrides // the ip:port composition entirely — used for apps served behind a // reverse-proxy domain. function buildWebUrl(ip: string | undefined, port: AppPort): string | null { const custom = (port.custom_url || "").trim() if (custom) return custom const raw = (ip || "").trim().split("/")[0] if (!raw || raw === "DHCP" || !port?.port) return null const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http") const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : "" return `${scheme}://${host}:${port.port}${path}` } type SortMode = "name" | "ct" | "category" const SORT_STORAGE_KEY = "proxmenux-apps-sort" const ALL_CATEGORIES = "__all__" const fetcher = async (url: string) => fetchApi(url) // ─── Component ─────────────────────────────────────────────────── export function AppsDashboard() { const t = useT() const { data: vms } = useSWR("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false }) // Custom links persisted in /etc/proxmenux/custom_links.json. Small // and rarely changes, so we don't poll on an interval — mutate() is // called explicitly after create / update / delete. const { data: customLinks, mutate: mutateCustomLinks } = useSWR( "/api/apps/custom-links", fetcher, { revalidateOnFocus: false }, ) // Category presets for the "+ Add link" modal. const { data: categoryPresets } = useSWR( "/api/apps/categories", fetcher, { revalidateOnFocus: false }, ) const isLightTheme = useIsLightTheme() // Flatten VMs → LaunchLinks. One card per (app × port with weblink) // for LXC-registered apps, plus one card per user-defined custom // link. A custom link with a binding resolves its ctName from the // matching guest in `vms` so renames stay in sync automatically. const links = useMemo(() => { const out: LaunchLink[] = [] const vmsList = Array.isArray(vms) ? vms : [] for (const vm of vmsList) { const apps = vm.app_watches || [] if (!apps.length) continue for (const app of apps) { // Skip the synthetic entry ProxMenux inserts for managed // OCI apps (Secure Gateway) — it has no user-assigned // Web Link and doesn't belong in a launcher. if (app.managed_oci_app_id) continue // `app.update_available` refers to the app itself. For a // Docker registration that app is the Docker engine, and its // ports are containers running INSIDE Docker (Portainer, // Frigate…) — each with an independent image update // lifecycle in `vm.docker_inventory.images[]`. Propagating // the engine-level flag to every container card would falsely // mark Portainer/Frigate as updatable when only the engine // needs bumping; missing the per-image flag would hide real // Portainer/Frigate updates that ARE tracked in the App tab // and fire notifications. Resolution: for each Docker port, // find the image entry whose `used_by` includes the port's // container name (== `port.description`) and use THAT image's // update_available. Engine update stays out of the port cards // — it belongs in the Updates tab. const isDockerApp = app.helper_slug === "docker" const dockerImages = vm.docker_inventory?.images || [] for (const port of app.ports || []) { const url = buildWebUrl(vm.ip, port) if (!url) continue let updateAvailable = false if (isDockerApp) { const img = findDockerImageForPort(port, dockerImages) updateAvailable = img?.update_available === true } else { updateAvailable = app.update_available === true } out.push({ key: `lxc-${vm.vmid}-${app.id}-${port.port}`, vmid: vm.vmid, guestType: "lxc", ctName: vm.name, appName: (port.description || app.name || vm.name || "").trim(), logoUrl: port.logo_url || app.logo_url || null, weblink: url, category: (port.category || "").trim(), updateAvailable, isCustom: false, }) } } } // Merge user-defined custom links. Their `binding` decides how the // CT/VM reference renders and where clicking it navigates. const guestByVmid = new Map() for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type }) for (const link of customLinks || []) { let ctName = "" let vmid: number | null = null let guestType: "lxc" | "qemu" | null = null if (link.binding) { const guest = guestByVmid.get(link.binding.vmid) vmid = link.binding.vmid guestType = link.binding.guest_type ctName = guest?.name || "" } out.push({ key: `custom-${link.id}`, vmid, guestType, ctName, appName: link.name, logoUrl: link.logo_url || null, weblink: link.url, category: (link.category || "").trim(), updateAvailable: false, isCustom: true, customId: link.id, }) } return out }, [vms, customLinks]) // Category list for the filter dropdown — built from the data so // it always reflects reality (presets and custom-entered names). const categoryCounts = useMemo(() => { const map = new Map() for (const l of links) { const key = l.category || t("apps.uncategorized") map.set(key, (map.get(key) || 0) + 1) } return map }, [links, t]) const sortedCategoryEntries = useMemo( () => Array.from(categoryCounts.entries()).sort((a, b) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))), [categoryCounts, t], ) // ─── Controls state ──────────────────────────────────────────── const [query, setQuery] = useState("") const [currentCat, setCurrentCat] = useState(ALL_CATEGORIES) const [sortMode, setSortMode] = useState("name") const [searchExpanded, setSearchExpanded] = useState(false) const searchInputRef = useRef(null) // Restore sort from localStorage on mount. useEffect(() => { try { const saved = localStorage.getItem(SORT_STORAGE_KEY) if (saved === "name" || saved === "ct" || saved === "category") { setSortMode(saved) } } catch (_) { /* private mode / storage disabled — silent */ } }, []) // Persist sort choice — only this one preference survives reload; // category filter and search reset each visit so the dashboard // always opens showing every app. useEffect(() => { try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {} }, [sortMode]) // Filter category resets if the user removes/renames the currently // selected one and it disappears from the list. useEffect(() => { if (currentCat === ALL_CATEGORIES) return if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES) }, [currentCat, categoryCounts]) // ─── Custom link editor state ────────────────────────────────── const [editorOpen, setEditorOpen] = useState(false) const [editingLink, setEditingLink] = useState(null) const [editMode, setEditMode] = useState(false) // Guest list feeds the binding dropdown in the editor modal. const guestOptions = useMemo(() => { if (!Array.isArray(vms)) return [] return vms .filter((v) => v.type === "lxc" || v.type === "qemu") .map((v) => ({ vmid: v.vmid, name: v.name, type: v.type as "lxc" | "qemu", })) }, [vms]) const openNewLink = () => { setEditingLink(null) setEditorOpen(true) } const openEditForLink = (customId: string) => { const found = (customLinks || []).find((l) => l.id === customId) if (!found) return setEditingLink(found) setEditorOpen(true) } // ─── Filter + sort ───────────────────────────────────────────── const shown = useMemo(() => { const q = query.trim().toLowerCase() const uncatKey = t("apps.uncategorized") let filtered = links if (currentCat !== ALL_CATEGORIES) { filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat) } if (q) { filtered = filtered.filter((l) => l.appName.toLowerCase().includes(q) || (l.ctName || "").toLowerCase().includes(q) || (l.vmid != null && String(l.vmid).includes(q)) || (l.category || "").toLowerCase().includes(q) || getCategoryLabel(t, l.category || "").toLowerCase().includes(q) ) } const sorted = [...filtered] sorted.sort((a, b) => { if (sortMode === "name") return a.appName.localeCompare(b.appName) if (sortMode === "ct") { // Unbound custom links have no vmid; sort them after every // bound entry, ordered alphabetically by app name. if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName) if (a.vmid == null) return 1 if (b.vmid == null) return -1 return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName) } // category — grouped alphabetically, then by app name inside const catA = a.category || uncatKey const catB = b.category || uncatKey const c = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, catB)) return c !== 0 ? c : a.appName.localeCompare(b.appName) }) return sorted }, [links, query, currentCat, sortMode, t]) // ─── Empty state ─────────────────────────────────────────────── const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0) if (vms && !hasAnyData) { return ( <>
{t("apps.emptyTitle")}
{t("apps.emptyHint")}
mutateCustomLinks()} /> ) } // ─── Render ──────────────────────────────────────────────────── const countLabel = shown.length === 1 ? t("apps.countOne") : t("apps.countMany", { n: shown.length }) return (
{/* Toolbar */}
{/* Search — icon-only until tapped on narrow screens */}
{!searchExpanded && ( )}
setQuery(e.target.value)} onBlur={() => { if (!query.trim()) setSearchExpanded(false) }} placeholder={t("apps.searchPlaceholder")} aria-label={t("apps.searchAriaLabel")} className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring" />
{/* Category filter */} {/* Sort */} {/* Count — desktop only. Mobile gives the horizontal room to the + button instead so everything stays on one line. */} {countLabel} {/* + Add custom link. Icon-only on mobile (mirrors the search icon-toggle pattern) so the toolbar fits in one line even in the narrowest viewport. On desktop shows label + icon. */} {/* Edit mode toggle — only shown when at least one custom link exists, since it's the only card type that carries per-card edit/delete actions. LXC-registered apps are edited in the LXC App tab of their guest modal. */} {(customLinks && customLinks.length > 0) && ( )}
{/* Grid — grouped headers when sorted by category */} mutateCustomLinks()} />
) } // ─── Cards grid + card ─────────────────────────────────────────── function CardsGrid({ links, grouped, uncategorizedLabel, openLabel, isLightTheme, editMode, onEditCustom, }: { links: LaunchLink[] grouped: boolean uncategorizedLabel: string openLabel: string isLightTheme: boolean editMode: boolean onEditCustom: (customId: string) => void }) { const t = useT() if (!links.length) { return (
{/* No results after filter/search */}
) } if (!grouped) { return (
{links.map((link) => ( ))}
) } // Group by category, insert header rows spanning the full grid width. const groups: Array<[string, LaunchLink[]]> = [] let currentCat: string | null = null let bucket: LaunchLink[] = [] for (const link of links) { const cat = link.category || uncategorizedLabel if (cat !== currentCat) { if (bucket.length) groups.push([currentCat!, bucket]) currentCat = cat bucket = [] } bucket.push(link) } if (bucket.length) groups.push([currentCat!, bucket]) return (
{groups.map(([cat, items]) => (

{getCategoryLabel(t, cat)} {items.length}

{items.map((link) => ( ))}
))}
) } // hueForCategory / categoryChipStyle / readIsLightTheme moved to // lib/category-color.ts so the LXC App tab can render the same chip. // Dispatch the pair of events that jumps from the Apps dashboard to // the VMs modal on the App tab for a given CT. Two events by design: // `changeTab` switches the outer tab (dashboard-level) and // `openLxcAppModal` tells VirtualMachines which guest to open and on // which inner tab to land. Both fire in the same tick. function openLxcModalOnAppTab(vmid: number) { window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } })) window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } })) } // Same pattern for a QEMU guest: land on the modal's Status tab // (QEMU guests don't have the App tab). Used by custom links whose // binding is a VM instead of an LXC. function openVmModalOnStatusTab(vmid: number) { window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } })) window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } })) } function AppCard({ link, openLabel, isLightTheme, editMode, onEditCustom, }: { link: LaunchLink openLabel: string isLightTheme: boolean editMode: boolean onEditCustom: (customId: string) => void }) { const t = useT() // Navigate to the bound guest's modal on the appropriate inner tab: // LXC → App tab (where the weblink was registered), VM → Status tab // (VMs don't have an App tab). Unbound custom links have no CT ref // to click, so this handler is only wired when `link.vmid` exists. const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => { e.preventDefault() e.stopPropagation() if (link.vmid == null) return if (link.guestType === "qemu") { openVmModalOnStatusTab(link.vmid) } else { openLxcModalOnAppTab(link.vmid) } } const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => { e.preventDefault() e.stopPropagation() if (link.customId) onEditCustom(link.customId) } const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC" const hasBinding = link.vmid != null return ( {/* Head: logo + name (+ update icon) */}
{link.logoUrl ? ( ) : ( {link.appName.slice(0, 2)} )}
{link.appName}
{/* Edit mode on a custom card takes over the update-icon slot with a proper edit button — custom links never carry the update signal, so nothing is displaced. Falls back to the update icon in every other case. */} {editMode && link.isCustom ? ( ) : link.updateAvailable && (
{/* Foot: weblink + CT ref + category chip */}
{link.weblink}
{/* Guest ref → click opens that guest's modal (LXC → App tab, VM → Status tab). stopPropagation keeps the outer anchor from firing at the same time. Unbound custom links: in edit mode show the edit button here, otherwise show nothing. */} {hasBinding && ( )} {link.category && ( {getCategoryLabel(t, link.category)} )}
) }