mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2837 lines
133 KiB
TypeScript
2837 lines
133 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 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, subscribeLxcApps } 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
|
|
// Set when the application runs inside Docker: registered for its identity
|
|
// and installed version, updated by the image it comes from.
|
|
update_via?: 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
|
|
update_method?: "none" | "helper" | "custom"
|
|
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
|
|
// Resolved server-side from the image a delegated app updates with. It is
|
|
// not the app's own upstream — the app deliberately has none — so it is
|
|
// kept in its own field rather than folded into state.latest_version.
|
|
docker_available_version?: string | null
|
|
docker_update_available?: boolean | null
|
|
}
|
|
|
|
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<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_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_workloads?: DockerWorkload[]
|
|
docker_web_links?: DockerWebLinkSuggestion[]
|
|
}
|
|
|
|
// A registrable application inside Docker. Its image remains the updater;
|
|
// registration adds identity and version tracking, not a second update path.
|
|
interface DockerWorkload {
|
|
slug: string
|
|
name: string
|
|
logo_url?: string | null
|
|
container_name?: string
|
|
installed_version?: string | null
|
|
installed_via?: string | null
|
|
tracking_suggestion?: TrackingSuggestion | null
|
|
default_ports?: number[]
|
|
category?: string | null
|
|
}
|
|
|
|
// 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<string, { lightTheme: string; darkTheme: string }> = {
|
|
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<HTMLImageElement>) => {
|
|
e.currentTarget.style.display = "none"
|
|
}
|
|
if (!themed) {
|
|
return <img src={src} alt="" className={className} onError={hideBroken} />
|
|
}
|
|
return (
|
|
<>
|
|
<img src={themed.lightTheme} alt="" className={`${className} block dark:hidden`} onError={hideBroken} />
|
|
<img src={themed.darkTheme} alt="" className={`${className} hidden dark:block`} onError={hideBroken} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
// 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<SidecarResponse | null>(seed?.sidecar ?? null)
|
|
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
|
const [error, setError] = useState<string | null>(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<DetectorTestResult | null>(null)
|
|
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)
|
|
// Preset categories exposed by /api/apps/categories (built from
|
|
// helpers_cache.category_names). Feeds the Categoría <Select> in
|
|
// the Web Link editor. Cached client-side for the session — the
|
|
// endpoint is static per app version so no revalidation needed.
|
|
const [categoryPresets, setCategoryPresets] = useState<string[]>([])
|
|
// Ports where the user picked "+ Add new" and is typing a custom
|
|
// category. Tracked by port index; cleared once they blur the
|
|
// input or come back to a preset. Also auto-inferred when the
|
|
// stored category isn't in the presets (freshly-loaded sidecar).
|
|
const [customCategoryPorts, setCustomCategoryPorts] = useState<Set<number>>(new Set())
|
|
|
|
// "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)
|
|
const [dockerTagPreview, setDockerTagPreview] = useState<DockerTagPreview | null>(null)
|
|
const [dockerTagPreviewLoading, setDockerTagPreviewLoading] = useState(false)
|
|
const [dockerTagPreviewError, setDockerTagPreviewError] = useState<string | null>(null)
|
|
|
|
// 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; detector checks remain available after enabling Edit.
|
|
// Toggled from a single button next to
|
|
// "Add another application".
|
|
const [editMode, setEditMode] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
if (managed) { setLoading(false); return }
|
|
// Only show the spinner on cold loads. When we came in seeded via
|
|
// `initialData` or the shared cache, the sidecar is already
|
|
// populated and the user shouldn't see a flash of "Loading…"
|
|
// while we revalidate.
|
|
if (!sidecar) setLoading(true)
|
|
setError(null)
|
|
try {
|
|
// `fetchLxcApps` bundles sidecar + suggestions in one shared
|
|
// in-flight promise. If the parent already fired this fetch on
|
|
// modal open (see prefetchVM / handleVMClick in
|
|
// virtual-machines.tsx), we await the SAME promise instead of
|
|
// duplicating the request against the backend — this eliminates
|
|
// the "Loading applications…" flash that used to show while a
|
|
// second, racing fetch caught up.
|
|
const bundle = await fetchLxcApps(vmid)
|
|
if (bundle) {
|
|
setSidecar(bundle.sidecar)
|
|
setSuggestions(bundle.suggestions)
|
|
}
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [vmid, managed])
|
|
|
|
useEffect(() => {
|
|
if (managed) return
|
|
return subscribeLxcApps(vmid, bundle => {
|
|
setSidecar(bundle.sidecar)
|
|
setSuggestions(bundle.suggestions)
|
|
})
|
|
}, [vmid, managed])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// Live Docker Hub preview. Debounced so typing an image/regex does not
|
|
// issue one request per keystroke; the backend additionally caches the
|
|
// raw tag list for 60 seconds per image.
|
|
useEffect(() => {
|
|
const draft = editing?.draft
|
|
const image = (draft?.docker_image || "").trim()
|
|
if (draft?.upstream_type !== "docker_hub" || !image) {
|
|
setDockerTagPreview(null)
|
|
setDockerTagPreviewError(null)
|
|
setDockerTagPreviewLoading(false)
|
|
return
|
|
}
|
|
let cancelled = false
|
|
setDockerTagPreview(null)
|
|
setDockerTagPreviewError(null)
|
|
const timer = window.setTimeout(async () => {
|
|
setDockerTagPreviewLoading(true)
|
|
try {
|
|
const result: DockerTagPreview = await fetchApi("/api/lxc-apps/dockerhub-tag-preview", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ image, regex: draft.tag_regex || "" }),
|
|
})
|
|
if (!cancelled) setDockerTagPreview(result)
|
|
} catch (e: any) {
|
|
if (!cancelled) setDockerTagPreviewError(e?.message || t("vmLxc.appEditor.dockerTagPreviewFailed"))
|
|
} finally {
|
|
if (!cancelled) setDockerTagPreviewLoading(false)
|
|
}
|
|
}, 500)
|
|
return () => {
|
|
cancelled = true
|
|
window.clearTimeout(timer)
|
|
}
|
|
}, [editing?.draft.docker_image, editing?.draft.tag_regex, editing?.draft.upstream_type, t])
|
|
|
|
// Turn a raw backend error string ("network error: The read operation
|
|
// timed out", etc.) into a localized message. Upstream check errors
|
|
// are surfaced verbatim by `lxc_apps.py:_fetch_upstream()`, and the
|
|
// panel used to render them in English regardless of locale. Match
|
|
// the two shapes the backend produces today and fall back to the
|
|
// original string so unknown errors still show something useful.
|
|
const localizeUpstreamError = (msg: string | null | undefined): string => {
|
|
if (!msg) return ""
|
|
const trimmed = msg.trim()
|
|
const lower = trimmed.toLowerCase()
|
|
if (lower.startsWith("network error:")) {
|
|
const detail = trimmed.slice("network error:".length).trim()
|
|
if (detail.toLowerCase().includes("timed out") || detail.toLowerCase().includes("timeout")) {
|
|
return t("vmLxc.appEditor.upstreamErrorTimeout")
|
|
}
|
|
return t("vmLxc.appEditor.upstreamErrorNetwork", { detail })
|
|
}
|
|
if (lower.includes("github rate limited")) {
|
|
return t("vmLxc.appEditor.upstreamErrorGithubRateLimit")
|
|
}
|
|
return msg
|
|
}
|
|
|
|
const editorOpen = !!editing
|
|
|
|
// The picker catalog is only needed after the user opens the editor.
|
|
useEffect(() => {
|
|
if (!editorOpen || catalog.length > 0) return
|
|
let cancelled = false
|
|
fetchApi<CatalogEntry[]>("/api/apps/catalog")
|
|
.then((data: CatalogEntry[]) => {
|
|
if (!cancelled && Array.isArray(data)) setCatalog(data)
|
|
})
|
|
.catch(() => { /* non-fatal */ })
|
|
return () => { cancelled = true }
|
|
}, [editorOpen, catalog.length])
|
|
|
|
useEffect(() => {
|
|
if (!editorOpen || categoryPresets.length > 0) return
|
|
let cancelled = false
|
|
fetchApi<string[]>("/api/apps/categories")
|
|
.then((data: string[]) => {
|
|
if (!cancelled && Array.isArray(data)) setCategoryPresets(data)
|
|
})
|
|
.catch(() => { /* non-fatal — datalist stays empty, user still types freely */ })
|
|
return () => { cancelled = true }
|
|
}, [editorOpen, categoryPresets.length])
|
|
|
|
// 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,
|
|
category: suggestions.category,
|
|
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
|
|
// Applications running inside Docker. They are filtered the same way, and
|
|
// deliberately NOT nested inside the Docker chip: that chip disappears the
|
|
// moment Docker is registered, which is exactly when these become
|
|
// registrable.
|
|
const visibleWorkloads = (suggestions?.docker_workloads || []).filter(
|
|
(w) => !registeredSlugs.has(w.slug) && !dismissedSlugs.has(w.slug),
|
|
)
|
|
// Detections the user hid and could restore from the Register-a-
|
|
// different-app panel. Not affected by registration state.
|
|
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
|
|
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
|
|
|
|
const searchInstalledApplications = async () => {
|
|
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
|
setSearchingApplications(true)
|
|
setDetectionNotice(null)
|
|
setError(null)
|
|
try {
|
|
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
|
method: "POST",
|
|
})
|
|
setSuggestions(result)
|
|
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
|
|
|
const detected = new Set<string>()
|
|
if (result.helper_slug) detected.add(result.helper_slug)
|
|
for (const item of result.extras || []) detected.add(item.slug)
|
|
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
|
const visible = [...detected].filter(
|
|
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
|
)
|
|
const newCount = visible.filter((slug) => !before.has(slug)).length
|
|
if (newCount === 1) {
|
|
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
|
} else if (newCount > 1) {
|
|
setDetectionNotice({
|
|
found: true,
|
|
text: t("vmLxc.appEditor.newApplicationsDetected", { count: newCount }),
|
|
})
|
|
} else {
|
|
setDetectionNotice({
|
|
found: false,
|
|
text: t(visible.length === 0 && apps.length === 0
|
|
? "vmLxc.appEditor.noApplicationsDetected"
|
|
: "vmLxc.appEditor.noNewApplicationsDetected"),
|
|
})
|
|
}
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
|
} finally {
|
|
setSearchingApplications(false)
|
|
}
|
|
}
|
|
|
|
// "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 || "",
|
|
// Preserved explicitly: saving replaces the whole record, so dropping
|
|
// it here would silently un-delegate the app the first time someone
|
|
// edits one of its ports.
|
|
update_via: (existing as { update_via?: string }).update_via || "",
|
|
update_command: existing.update_command || "",
|
|
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
|
|
hide_no_updater_notice: existing.hide_no_updater_notice === true,
|
|
notifications_enabled: existing.notifications_enabled !== false,
|
|
exclude_from_badge: existing.exclude_from_badge === true,
|
|
}
|
|
// 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
|
|
// Docker endpoints come from the real published host-port mappings
|
|
// listed under Web links. Do not pre-save a catalog default such as
|
|
// 9000; the user explicitly chooses which workload links to add.
|
|
if (p.slug !== "docker" && p.default_ports?.length) {
|
|
seed.ports = p.default_ports.map((port) => ({
|
|
port,
|
|
scheme: defaultSchemeFor(port),
|
|
web_path: s?.web_path_hint || "",
|
|
// Auto-fill Categoría from helpers_cache.category_names[0]
|
|
// when the catalog entry carries one. User can still change
|
|
// it in the editor before saving.
|
|
...(p.category ? { category: p.category } : {}),
|
|
}))
|
|
}
|
|
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 || "",
|
|
binary_args: t.binary_args ? [...t.binary_args] : [],
|
|
python_path: t.python_path || "",
|
|
distribution: t.distribution || "",
|
|
container_name: t.container_name || "",
|
|
label: t.label || "",
|
|
command_argv: t.command_argv ? [...t.command_argv] : [],
|
|
installed_version: t.installed_version || "",
|
|
installed_regex: t.installed_regex || "",
|
|
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+)",
|
|
}
|
|
// A containerised workload delegates its update to its image, and
|
|
// the backend refuses a delegation that also carries an upstream —
|
|
// so the upstream fields seeded above are cleared, not just ignored.
|
|
if ((t as { update_via?: string }).update_via === "docker") {
|
|
seed = {
|
|
...seed,
|
|
update_via: "docker",
|
|
upstream_type: "",
|
|
repo: "",
|
|
upstream_url: "",
|
|
upstream_json_path: "",
|
|
docker_image: "",
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
|
setCommandArgvInput((seed.command_argv || []).join(", "))
|
|
setEditing({ appId: existing?.id || null, draft: seed })
|
|
setDetectorTest(null)
|
|
setError(null)
|
|
}, [suggestions, vmid, sidecar])
|
|
|
|
const closeEditor = () => {
|
|
setEditing(null)
|
|
setBinaryArgsInput("")
|
|
setCommandArgvInput("")
|
|
setDetectorTest(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)
|
|
setLxcAppsCached(vmid, r, suggestions)
|
|
setEditing(null)
|
|
setBinaryArgsInput("")
|
|
setCommandArgvInput("")
|
|
onChange?.()
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const testDetector = async () => {
|
|
if (!editing?.draft.installed_via) return
|
|
setTestingDetector(true)
|
|
setDetectorTest(null)
|
|
setError(null)
|
|
try {
|
|
const result: DetectorTestResult = await fetchApi(`/api/vms/${vmid}/apps/test`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(editing.draft),
|
|
})
|
|
setDetectorTest(result)
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.detectorTestFailed"))
|
|
} finally {
|
|
setTestingDetector(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)
|
|
setLxcAppsCached(vmid, r, suggestions)
|
|
onChange?.()
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.checkFailed"))
|
|
} finally {
|
|
setBusyAppId(null)
|
|
}
|
|
}
|
|
|
|
// Silence / re-enable `app_update_available` for this specific
|
|
// app. Full-record PUT because the backend replaces the whole
|
|
// config on update — omit a field and it disappears. We hydrate
|
|
// from the current app entry, flip the flag, and post it back.
|
|
const toggleAppNotifications = async (app: AppEntry) => {
|
|
setBusyAppId(app.id)
|
|
setError(null)
|
|
try {
|
|
const { id: _id, state: _state, created_at: _created, ...rest } = app
|
|
const nextEnabled = app.notifications_enabled === false
|
|
const payload = { ...rest, notifications_enabled: nextEnabled }
|
|
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${app.id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
setSidecar(r)
|
|
setLxcAppsCached(vmid, r, suggestions)
|
|
onChange?.()
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
|
} 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 {
|
|
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${appId}`, { method: "DELETE" })
|
|
setSidecar(r)
|
|
setLxcAppsCached(vmid, r, suggestions)
|
|
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)
|
|
setLxcAppsCached(vmid, r, suggestions)
|
|
} 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)
|
|
setLxcAppsCached(vmid, r, suggestions)
|
|
} 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 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 && (managed.installed_version || repo) && (
|
|
<div className={"mt-3 grid gap-3 " + (managed.installed_version && 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">
|
|
{managed.installed_version}
|
|
</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">{localizeUpstreamError(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>) => {
|
|
setDetectorTest(null)
|
|
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 addDockerWebLink = (link: DockerWebLinkSuggestion) => {
|
|
const entry: PortEntry = {
|
|
port: link.host_port,
|
|
description: link.service_name,
|
|
scheme: link.scheme,
|
|
web_path: link.web_path || "/",
|
|
logo_url: link.logo_url || "",
|
|
}
|
|
setDetectorTest(null)
|
|
setEditing((current) => {
|
|
if (!current) return current
|
|
const currentPorts = current.draft.ports
|
|
// The buttons disappear after a port is added, but guard the state
|
|
// update too so a double click can never create duplicate links.
|
|
if (currentPorts.some((port) => port.port === link.host_port)) return current
|
|
const ports = [...currentPorts]
|
|
const last = ports[ports.length - 1]
|
|
if (last && last.port === "" && !last.description) ports[ports.length - 1] = entry
|
|
else ports.push(entry)
|
|
return { ...current, draft: { ...current.draft, ports } }
|
|
})
|
|
// Ask the backend whether this service_name has a known catalog
|
|
// category and, if so, patch the just-inserted port so the user
|
|
// finds it pre-selected instead of having to open the dropdown.
|
|
// This must be a functional update: the response can arrive after the
|
|
// user has added or edited more links, and must never restore the old
|
|
// draft captured by this render.
|
|
const q = (link.service_name || "").trim()
|
|
if (q) {
|
|
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
|
|
.then((r) => {
|
|
if (!r?.category) return
|
|
setEditing((current) => {
|
|
if (!current) return current
|
|
let changed = false
|
|
const ports = current.draft.ports.map((port) => {
|
|
if (port.port !== link.host_port || port.category) return port
|
|
changed = true
|
|
return { ...port, category: r.category || undefined }
|
|
})
|
|
return changed
|
|
? { ...current, draft: { ...current.draft, ports } }
|
|
: current
|
|
})
|
|
})
|
|
.catch(() => { /* non-fatal — user can pick manually */ })
|
|
}
|
|
}
|
|
const removePort = (i: number) =>
|
|
setField({ ports: draft.ports.filter((_, idx) => idx !== i) })
|
|
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
|
const isDockerDraft = draft.helper_slug === "docker" ||
|
|
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
|
|
// A recognised Docker workload belongs in one place at a time. Keep its
|
|
// links out of Docker while it is offered (or already registered) as an
|
|
// independent app. Dismissing that detection makes the links available
|
|
// under Docker again.
|
|
const independentDockerWorkloadSlugs = new Set(
|
|
(suggestions?.docker_workloads || [])
|
|
.filter((workload) => !dismissedSlugs.has(workload.slug))
|
|
.map((workload) => workload.slug),
|
|
)
|
|
const suggestableDockerLinks = isDockerDraft
|
|
? (suggestions?.docker_web_links || []).filter(
|
|
(link) =>
|
|
!usedPorts.has(link.host_port) &&
|
|
(!link.service_slug || !independentDockerWorkloadSlugs.has(link.service_slug)),
|
|
)
|
|
: []
|
|
// Keep the generic ss/netstat probe available for Docker too. It covers
|
|
// host-networked services and listeners that Docker does not expose in
|
|
// NetworkSettings.Ports. Published ports already represented by a named
|
|
// Docker workload stay deduplicated from the generic chips.
|
|
const dockerPublishedPorts = new Set(
|
|
(suggestions?.docker_web_links || []).map((link) => link.host_port),
|
|
)
|
|
const suggestable = (suggestions?.port_suggestions || []).filter(
|
|
(port) => !usedPorts.has(port) && (!isDockerDraft || !dockerPublishedPorts.has(port)),
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Editor card — raised to `bg-accent` (matches the tone
|
|
clickable cards get on hover) so the `bg-background` inputs
|
|
sit clearly recessed against it. The three descendant
|
|
selectors push every Input / Textarea / SelectTrigger
|
|
(role="combobox") under this card down to `bg-background`
|
|
in one shot, so future fields inherit the sunken look
|
|
without per-input styling. Reverts to `bg-card/50`
|
|
automatically because this render branch only fires when
|
|
`editing !== null`. */}
|
|
<Card className="border border-border bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
|
<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}?vmid=${vmid}`)
|
|
// 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.slug !== "docker" && 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.command_argv = t.command_argv || []
|
|
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+)"
|
|
setBinaryArgsInput((t.binary_args || []).join(", "))
|
|
setCommandArgvInput((t.command_argv || []).join(", "))
|
|
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>
|
|
|
|
{suggestableDockerLinks.length > 0 && (
|
|
<div className="mb-3 space-y-2">
|
|
<div>
|
|
<div className="text-xs font-medium text-foreground">
|
|
{t("vmLxc.appEditor.dockerPublishedServicesTitle")}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed max-w-2xl">
|
|
{t("vmLxc.appEditor.dockerPublishedServicesHelp")}
|
|
</p>
|
|
</div>
|
|
<div className="divide-y divide-border/50 border-y border-border/50">
|
|
{suggestableDockerLinks.map((link) => (
|
|
<div
|
|
key={`${link.container_name}:${link.host_port}`}
|
|
className="flex flex-col sm:flex-row sm:items-center gap-2 py-2"
|
|
>
|
|
{link.logo_url && (
|
|
<ThemeAwareLogo
|
|
src={link.logo_url}
|
|
className="h-7 w-7 rounded object-contain flex-shrink-0"
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm text-foreground truncate">{link.service_name}</div>
|
|
<div className="text-[10px] text-muted-foreground truncate" title={link.image}>
|
|
{link.image} · {t("vmLxc.appEditor.dockerPublishedPort", {
|
|
containerPort: link.container_port,
|
|
hostPort: link.host_port,
|
|
})}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addDockerWebLink(link)}
|
|
className="h-8 flex-shrink-0"
|
|
>
|
|
<PlusCircle className="h-3.5 w-3.5 mr-1" />
|
|
{t("vmLxc.appEditor.dockerPublishedAdd")}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</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 && suggestableDockerLinks.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"
|
|
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. */}
|
|
<div className="col-start-1 col-end-4 flex flex-col sm:flex-row gap-2">
|
|
{/* Per-link category — <Select> for presets +
|
|
"+ Add new" option that flips to a text
|
|
input. Matches the "Installed via" Select
|
|
look elsewhere in this editor. */}
|
|
<div className="flex-1 min-w-0">
|
|
{(customCategoryPorts.has(i) ||
|
|
(entry.category && !categoryPresets.includes(entry.category))) ? (
|
|
<Input
|
|
autoFocus={customCategoryPorts.has(i)}
|
|
value={entry.category || ""}
|
|
onChange={(e) => 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"
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={entry.category || "__none__"}
|
|
onValueChange={(v) => {
|
|
if (v === "__add__") {
|
|
setCustomCategoryPorts((s) => new Set(s).add(i))
|
|
setPort(i, { category: "" })
|
|
} else if (v === "__none__") {
|
|
setPort(i, { category: "" })
|
|
} else {
|
|
setPort(i, { category: v })
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="text-xs h-8">
|
|
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
|
{categoryPresets.map((c) => (
|
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
|
))}
|
|
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
{/* Per-link custom URL — takes precedence over
|
|
ip:port when the app lives behind a reverse
|
|
proxy on a public domain. */}
|
|
<Input
|
|
value={entry.custom_url || ""}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</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" disabled>{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 <app></code> or{" "}
|
|
<code className="text-foreground/70">systemctl show <service> -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={binaryArgsInput}
|
|
onChange={(e) => {
|
|
const value = e.target.value
|
|
setBinaryArgsInput(value)
|
|
setField({ binary_args: parseArgvInput(value) })
|
|
}}
|
|
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={commandArgvInput}
|
|
onChange={(e) => {
|
|
const value = e.target.value
|
|
setCommandArgvInput(value)
|
|
setField({ command_argv: parseArgvInput(value) })
|
|
}}
|
|
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)
|
|
}
|
|
// A delegated app has no upstream of its own on purpose:
|
|
// the image it runs on resolves the available version.
|
|
// Showing an empty "none" dropdown reads as a setting the
|
|
// user forgot, so say what is happening instead.
|
|
if (editing.draft.update_via === "docker") {
|
|
return (
|
|
<div className="rounded-md border border-border/60 bg-background/40 p-3">
|
|
<div className="text-xs font-medium text-foreground">
|
|
{t("vmLxc.appEditor.upstreamDelegatedTitle")}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground mt-1 leading-relaxed">
|
|
{t("vmLxc.appEditor.upstreamDelegatedHelp")}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
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">
|
|
{t("vmLxc.appEditor.dockerVersionedTagsHelp")}
|
|
</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"
|
|
/>
|
|
{upstreamType === "docker_hub" && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
onClick={() => setField({ tag_regex: "^v?(\\d+\\.\\d+\\.\\d+)$" })}
|
|
>
|
|
{t("vmLxc.appEditor.dockerPresetSemver")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
onClick={() => setField({ tag_regex: "^v?(\\d+\\.\\d+\\.\\d+(?:[-+._][0-9A-Za-z.-]+)?)$" })}
|
|
>
|
|
{t("vmLxc.appEditor.dockerPresetSemverSuffix")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<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" && t("vmLxc.appEditor.dockerTagFilterHelp")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{upstreamType === "docker_hub" && draft.docker_image?.trim() && (
|
|
<div className="rounded-md border border-border/70 bg-background/50 p-3">
|
|
<div className="flex items-center justify-between gap-2 mb-2">
|
|
<div className="text-xs font-medium text-foreground">
|
|
{t("vmLxc.appEditor.dockerTagPreviewLabel")}
|
|
</div>
|
|
{dockerTagPreview && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("vmLxc.appEditor.dockerTagPreviewCount", {
|
|
matched: dockerTagPreview.matched_count,
|
|
scanned: dockerTagPreview.scanned_count,
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{dockerTagPreviewLoading ? (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("vmLxc.appEditor.dockerTagPreviewLoading")}
|
|
</div>
|
|
) : dockerTagPreviewError ? (
|
|
<div className="text-xs text-amber-300">{dockerTagPreviewError}</div>
|
|
) : dockerTagPreview?.tags.length ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{dockerTagPreview.tags.map((entry) => (
|
|
<Badge
|
|
key={entry.tag}
|
|
variant="outline"
|
|
className={entry.moving ? "border-amber-500/40 text-amber-300" : "font-mono"}
|
|
>
|
|
{entry.tag}{entry.moving ? ` · ${t("vmLxc.appEditor.dockerMovingTag")}` : ""}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("vmLxc.appEditor.dockerTagPreviewEmpty")}
|
|
</div>
|
|
)}
|
|
{dockerTagPreview?.tags.some((entry) => entry.moving) && (
|
|
<div className="mt-2 text-[10px] text-amber-300 leading-relaxed">
|
|
{t("vmLxc.appEditor.dockerMovingTagHelp")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<div className="pt-2 border-t border-border/50 space-y-3">
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.notifications_enabled !== false}
|
|
onChange={(e) => setField({ notifications_enabled: e.target.checked })}
|
|
className="mt-0.5 h-4 w-4 rounded border-border accent-blue-500"
|
|
/>
|
|
<div className="text-sm">
|
|
<div className="text-foreground">{t("vmLxc.appEditor.notifyUpstreamLabel")}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.notifyUpstreamHelp")}</div>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.exclude_from_badge === true}
|
|
onChange={(e) => setField({ exclude_from_badge: e.target.checked })}
|
|
className="mt-0.5 h-4 w-4 rounded border-border accent-blue-500"
|
|
/>
|
|
<div className="text-sm">
|
|
<div className="text-foreground">{t("vmLxc.appEditor.excludeFromBadgeLabel")}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.excludeFromBadgeHelp")}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{detectorTest && (
|
|
<div className="rounded-md border border-border/70 bg-background/60 p-3 space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-xs font-medium text-foreground">
|
|
{t("vmLxc.appEditor.detectorTestTitle")}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className={"rounded-md border p-2.5 " + (detectorTest.installed.version ? "border-emerald-500/30 bg-emerald-500/5" : "border-amber-500/30 bg-amber-500/5")}>
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
|
|
{t("vmLxc.appEditor.installedStatus")}
|
|
</div>
|
|
{detectorTest.installed.version ? (
|
|
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
|
|
) : (
|
|
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
|
)}
|
|
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
|
|
{detectorTest.installed.method || "—"}
|
|
{detectorTest.installed.effective_regex ? ` · ${detectorTest.installed.effective_regex}` : ""}
|
|
</div>
|
|
</div>
|
|
<div className={"rounded-md border p-2.5 " + (!detectorTest.upstream.configured || detectorTest.upstream.version ? "border-emerald-500/30 bg-emerald-500/5" : "border-amber-500/30 bg-amber-500/5")}>
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
|
|
{t("vmLxc.appEditor.latestUpstream")}
|
|
</div>
|
|
{!detectorTest.upstream.configured ? (
|
|
<div className="text-xs text-muted-foreground">{t("vmLxc.appEditor.detectorTestNoUpstream")}</div>
|
|
) : detectorTest.upstream.version ? (
|
|
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
|
|
) : (
|
|
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
|
)}
|
|
{detectorTest.upstream.type && (
|
|
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</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">
|
|
{method && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={testDetector}
|
|
disabled={saving || testingDetector || !draft.name.trim()}
|
|
className="sm:mr-auto"
|
|
>
|
|
{testingDetector ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <ShieldCheck className="h-4 w-4 mr-1.5" />}
|
|
{testingDetector ? t("vmLxc.appEditor.testingDetectorButton") : t("vmLxc.appEditor.testDetectorButton")}
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" onClick={closeEditor} disabled={saving || testingDetector}>{t("vmLxc.appEditor.cancelButton")}</Button>
|
|
<Button
|
|
onClick={save}
|
|
disabled={saving || testingDetector || !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 && (
|
|
<ThemeAwareLogo
|
|
src={d.logo_url}
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
/>
|
|
)}
|
|
<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 && (
|
|
<ThemeAwareLogo
|
|
src={d.logo_url}
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
/>
|
|
)}
|
|
<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">
|
|
{d.tracking_suggestion?.detector_verified
|
|
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
|
: t("vmLxc.appEditor.detectedInContainer")}
|
|
</div>
|
|
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
|
<div className="text-[10px] text-amber-400/90 mt-0.5">
|
|
{t("vmLxc.appEditor.legacyDetectorUsed")}
|
|
</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>
|
|
)
|
|
|
|
// A containerised application. Same chip language as a native detection,
|
|
// with the update path spelled out: it is registered for what it is, not
|
|
// for an updater it does not have.
|
|
const renderWorkloadChip = (w: DockerWorkload) => (
|
|
<div key={`workload-${w.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">
|
|
{w.logo_url && (
|
|
<ThemeAwareLogo
|
|
src={w.logo_url}
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
/>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-foreground truncate">{w.name}</div>
|
|
<div className="text-xs text-emerald-400/90">
|
|
{w.installed_version
|
|
? t("vmLxc.appEditor.versionDetected", { version: w.installed_version })
|
|
: t("vmLxc.appEditor.detectedInContainer")}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">
|
|
{t("vmLxc.appEditor.runsInsideDocker")}
|
|
{w.container_name ? ` · ${w.container_name}` : ""}
|
|
</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: !!w.tracking_suggestion,
|
|
preset: {
|
|
slug: w.slug,
|
|
name: w.name,
|
|
logo_url: w.logo_url || "",
|
|
default_ports: w.default_ports || [],
|
|
category: w.category || null,
|
|
tracking_suggestion: w.tracking_suggestion || null,
|
|
} as unknown as DetectedApp,
|
|
})}
|
|
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(w.slug, w.name)}
|
|
aria-label={`${t("vmLxc.appEditor.hideButton")}: ${w.name}`}
|
|
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 || visibleWorkloads.length > 0) && (
|
|
<div className="space-y-2 pt-2">
|
|
{visibleDetected.map(renderDetectionChip)}
|
|
{visibleWorkloads.map(renderWorkloadChip)}
|
|
</div>
|
|
)}
|
|
<div className="pt-1 flex flex-wrap justify-center gap-2">
|
|
<Button
|
|
onClick={searchInstalledApplications}
|
|
disabled={searchingApplications}
|
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
|
>
|
|
{searchingApplications
|
|
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
|
: <Search className="h-4 w-4 mr-1.5" />}
|
|
{searchingApplications
|
|
? t("vmLxc.appEditor.searchingApplications")
|
|
: t("vmLxc.appEditor.searchApplications")}
|
|
</Button>
|
|
<Button
|
|
onClick={openBrowseOrEditor}
|
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
|
>
|
|
<PlusCircle className="h-4 w-4 mr-1.5" />
|
|
{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>
|
|
{detectionNotice && (
|
|
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
|
|
{detectionNotice.text}
|
|
</p>
|
|
)}
|
|
</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
|
|
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 && (
|
|
<ThemeAwareLogo
|
|
src={app.logo_url}
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
/>
|
|
)}
|
|
<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">
|
|
{t("vmLxc.appEditor.checkedAt", { date: 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>
|
|
|
|
{(() => {
|
|
// A delegated app has no upstream of its own; the version to
|
|
// compare against comes from the image it updates with.
|
|
const delegated = app.update_via === "docker"
|
|
const hasUpstream = !!(app.repo || app.upstream_type) || (delegated && !!app.docker_available_version)
|
|
const hasUpdate = delegated
|
|
? app.docker_update_available === true
|
|
: st?.update_available === true
|
|
const latestVersion = delegated ? app.docker_available_version : st?.latest_version
|
|
if (!tracking || !(st?.installed_version || hasUpstream)) return null
|
|
return (
|
|
<div className={"mb-3 grid gap-3 " + (st?.installed_version && 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">
|
|
{st.installed_version}
|
|
</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")}>
|
|
{latestVersion || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
|
{hasUpdate && latestVersion && (
|
|
<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">{localizeUpstreamError(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, p.custom_url)
|
|
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 && (
|
|
<ThemeAwareLogo
|
|
src={p.logo_url}
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex flex-col gap-1 flex-1">
|
|
<span className="text-sm font-medium text-foreground truncate">{label}</span>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<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 flex-1"
|
|
title={url}
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
|
|
<span className="font-mono text-sm truncate">{url}</span>
|
|
</a>
|
|
{/* 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 && (
|
|
<span
|
|
style={categoryChipStyle(p.category, isLightTheme)}
|
|
className="flex-shrink-0 px-1.5 py-0.5 border rounded text-[10px] font-medium truncate max-w-[40%]"
|
|
title={p.category}
|
|
>
|
|
{p.category}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</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 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 && (
|
|
<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={() => toggleAppNotifications(app)}
|
|
disabled={busyAppId === app.id}
|
|
title={
|
|
app.notifications_enabled === false
|
|
? t("vmLxc.appEditor.notificationsMuted")
|
|
: t("vmLxc.appEditor.notificationsEnabled")
|
|
}
|
|
className={
|
|
app.notifications_enabled === false
|
|
? "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 text-muted-foreground"
|
|
: "h-8 px-3 text-xs rounded-md border border-blue-500/30 bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
}
|
|
>
|
|
{app.notifications_enabled === false
|
|
? <BellOff className="h-3.5 w-3.5" />
|
|
: <Bell className="h-3.5 w-3.5" />}
|
|
</button>
|
|
)}
|
|
{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 || visibleWorkloads.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)}
|
|
{visibleWorkloads.map(renderWorkloadChip)}
|
|
</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 && (
|
|
// 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.
|
|
<div className="flex flex-row flex-wrap justify-end items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={searchInstalledApplications}
|
|
disabled={searchingApplications || editMode}
|
|
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
|
|
aria-label={searchingApplications
|
|
? t("vmLxc.appEditor.searchingApplications")
|
|
: t("vmLxc.appEditor.searchApplications")}
|
|
>
|
|
{searchingApplications
|
|
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
|
|
: <Search className="h-4 w-4 sm:mr-1.5" />}
|
|
<span className="hidden sm:inline">
|
|
{searchingApplications
|
|
? t("vmLxc.appEditor.searchingApplications")
|
|
: t("vmLxc.appEditor.searchApplications")}
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={openBrowseOrEditor}
|
|
disabled={editMode}
|
|
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
|
|
aria-label={t("vmLxc.appEditor.addAnotherApplication")}
|
|
>
|
|
<PlusCircle className="h-4 w-4 sm:mr-1.5" />
|
|
<span className="hidden sm:inline">
|
|
{t("vmLxc.appEditor.addAnotherApplication")}
|
|
{hiddenDetections.length > 0 && (
|
|
<span className="ml-2 text-[10px] opacity-70">
|
|
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
|
|
</span>
|
|
)}
|
|
</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 justify-center gap-1.5 min-w-[7rem] sm:min-w-0"
|
|
>
|
|
{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>
|
|
)}
|
|
|
|
{apps.length > 0 && detectionNotice && (
|
|
<p className={`text-xs text-right ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
|
|
{detectionNotice.text}
|
|
</p>
|
|
)}
|
|
|
|
{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>
|
|
)
|
|
}
|