mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
feat(host-backup): custom SSH port for Borg remote targets Add Borg destinations no longer assume port 22. The Monitor form and the shell TUI both take an optional port (default 22, range 1-65535) and embed it in the persisted ssh://user@host:port/path URL. BORG_RSH, the sshpass key installer and the capacity probe all honour it. Reported by @songochain in #236 — NAS-style Borg hosts on non-standard SSH ports are now first-class targets. Existing borg-targets.txt entries without a port keep working; the port is only serialised into the URL when it differs from 22.
2657 lines
124 KiB
TypeScript
2657 lines
124 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 } from "../lib/lxc-apps-cache"
|
|
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
|
import { useT } from "@/lib/i18n/provider"
|
|
|
|
// installed_via is optional now — an empty value means "register only,
|
|
// no version tracking, no warnings, just a clickable link". Docker
|
|
// apps and casual "just want a link" registrations use this default.
|
|
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
|
|
"python_dist" | "docker_label" | "docker_exec" |
|
|
"command" | "manual"
|
|
type GithubSource = "releases" | "tags"
|
|
|
|
interface PortEntry {
|
|
port: number | ""
|
|
description?: string
|
|
scheme?: "http" | "https"
|
|
web_path?: string
|
|
logo_url?: string
|
|
// Free-text label picked from the presets sourced by
|
|
// /api/apps/categories (built from helpers_cache.category_names) or
|
|
// typed manually. Powers the Apps dashboard filter/group.
|
|
category?: string
|
|
// Overrides ip:port composition when present — used for apps that
|
|
// sit behind a reverse-proxy domain. The Apps dashboard opens this
|
|
// URL as-is instead of `${scheme}://${ip}:${port}${path}`.
|
|
custom_url?: string
|
|
}
|
|
|
|
interface AppConfig {
|
|
name: string
|
|
installed_via?: InstalledVia
|
|
package?: string
|
|
file_path?: string
|
|
file_regex?: string
|
|
binary_path?: string
|
|
binary_args?: string[]
|
|
python_path?: string
|
|
distribution?: string
|
|
container_name?: string
|
|
label?: string
|
|
command_argv?: string[]
|
|
installed_version?: string
|
|
installed_regex?: string
|
|
// Upstream source discriminator + fields. When `upstream_type` is
|
|
// "github" (default when `repo` is set) the classic repo /
|
|
// github_source / tag_regex fields drive the check. "http_json" and
|
|
// "docker_hub" open two new source types validated separately on
|
|
// the backend.
|
|
upstream_type?: "github" | "http_json" | "docker_hub" | ""
|
|
repo?: string
|
|
github_source?: GithubSource
|
|
upstream_url?: string
|
|
upstream_json_path?: string
|
|
docker_image?: string
|
|
tag_regex?: string
|
|
ports: PortEntry[]
|
|
health_path?: string
|
|
logo_url?: string
|
|
helper_slug?: string
|
|
// Preserved here even though this editor does not execute updates.
|
|
// The backend uses full-record replacement, so omitting these when
|
|
// editing ports/tracking would silently erase the Updates-tab setup.
|
|
update_command?: string
|
|
hide_no_updater_notice?: boolean
|
|
// Per-app opt-out for the `app_update_available` notification.
|
|
// Absent / true = notify; false = silenced. Set from the bell
|
|
// toggle on each app card and/or the Edit form's checkbox.
|
|
notifications_enabled?: boolean
|
|
// Per-app opt-out for the CT's aggregate updates badge (default
|
|
// false = counted). Independent from `notifications_enabled`.
|
|
exclude_from_badge?: boolean
|
|
}
|
|
|
|
interface DetectedApp {
|
|
slug: string
|
|
name: string
|
|
logo_url?: string | null
|
|
default_ports?: number[]
|
|
// Categoría preset from helpers_cache.category_names[0] — used to
|
|
// auto-fill the Web Link editor when the user clicks "Register".
|
|
category?: string | null
|
|
tracking_suggestion?: TrackingSuggestion | null
|
|
}
|
|
|
|
interface AppState {
|
|
installed_version: string | null
|
|
latest_version: string | null
|
|
latest_published_at?: string | null
|
|
update_available: boolean | null
|
|
error: string | null
|
|
checked_at: string | null
|
|
}
|
|
|
|
interface AppEntry extends AppConfig {
|
|
id: string
|
|
state?: AppState
|
|
created_at?: string
|
|
}
|
|
|
|
interface SidecarResponse {
|
|
vmid: number
|
|
apps: AppEntry[]
|
|
dismissed_slugs?: string[]
|
|
created_at?: string
|
|
updated_at?: string
|
|
}
|
|
|
|
interface DetectorTestResult {
|
|
valid: boolean
|
|
persisted: false
|
|
checked_at: string
|
|
installed: {
|
|
configured: boolean
|
|
method: InstalledVia | null
|
|
effective_regex: string | null
|
|
version: string | null
|
|
error: string | null
|
|
}
|
|
upstream: {
|
|
configured: boolean
|
|
type: "github" | "http_json" | "docker_hub" | null
|
|
version: string | null
|
|
published_at?: string | null
|
|
error: string | null
|
|
}
|
|
update_available: boolean | null
|
|
}
|
|
|
|
interface TrackingSuggestion {
|
|
installed_via: Exclude<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_web_links?: DockerWebLinkSuggestion[]
|
|
}
|
|
|
|
// Compact catalog entry — one row for every registerable app the
|
|
// picker can offer. Fetched once from /api/apps/catalog on panel
|
|
// mount, filtered client-side while the user types.
|
|
interface CatalogEntry {
|
|
slug: string
|
|
name: string
|
|
logo: string
|
|
default_port: number
|
|
has_tracking: boolean
|
|
}
|
|
|
|
// Full detail for a picked catalog entry — server merges catalog
|
|
// metadata + curated tracking_suggestion (when available) so the
|
|
// editor can pre-fill every field in one round-trip.
|
|
interface CatalogDetail {
|
|
slug: string
|
|
name: string
|
|
logo_url: string | null
|
|
website: string
|
|
default_ports: number[]
|
|
// First helpers_cache.category_names value for this slug — auto-fills
|
|
// the Categoría field on each port when seeded.
|
|
category?: string | null
|
|
tracking_suggestion?: TrackingSuggestion | null
|
|
}
|
|
|
|
interface ManagedAppInfo {
|
|
managed_oci_app_id: string
|
|
name: string
|
|
installed_version?: string | null
|
|
latest_version?: string | null
|
|
update_available?: boolean | null
|
|
checked_at?: string | null
|
|
error?: string | null
|
|
}
|
|
|
|
interface Props {
|
|
vmid: number
|
|
ctIp?: string | null
|
|
onChange?: () => void
|
|
managed?: ManagedAppInfo | null
|
|
// Optional seed payload from the parent's cross-open ref cache. When
|
|
// supplied, the panel renders with real content on the very first
|
|
// frame and only revalidates silently in the background — no
|
|
// "Loading applications…" flash on tab switch or modal reopen. Must
|
|
// include BOTH sidecar and suggestions — the panel's empty-state and
|
|
// detected-chip strip both depend on suggestions, so seeding sidecar
|
|
// alone briefly flashes "no apps registered" until suggestions
|
|
// arrives from the network.
|
|
initialData?: { sidecar: SidecarResponse; suggestions: Suggestions | null } | null
|
|
}
|
|
|
|
const EMPTY_APP: AppConfig = {
|
|
name: "",
|
|
installed_via: "", // no tracking by default — just a link
|
|
package: "",
|
|
upstream_type: "",
|
|
repo: "",
|
|
github_source: "releases",
|
|
upstream_url: "",
|
|
upstream_json_path: "",
|
|
docker_image: "",
|
|
tag_regex: "v?(\\d+\\.\\d+\\.\\d+)",
|
|
ports: [],
|
|
logo_url: "",
|
|
}
|
|
|
|
const SELFHST_WEBP_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
|
|
const SELFHST_THEME_LOGOS: Record<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(() => { 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 })
|
|
}
|
|
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
|
|
// Detections the user hid and could restore from the Register-a-
|
|
// different-app panel. Not affected by registration state.
|
|
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
|
|
|
|
const searchInstalledApplications = async () => {
|
|
const before = new Set(visibleDetected.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)
|
|
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 || "",
|
|
update_command: existing.update_command || "",
|
|
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+)",
|
|
}
|
|
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 || "",
|
|
}
|
|
const last = draft.ports[draft.ports.length - 1]
|
|
const indexAfterAdd = (last && last.port === "" && !last.description)
|
|
? draft.ports.length - 1
|
|
: draft.ports.length
|
|
if (indexAfterAdd === draft.ports.length) {
|
|
setField({ ports: [...draft.ports, entry] })
|
|
} else {
|
|
const ports = [...draft.ports]
|
|
ports[indexAfterAdd] = entry
|
|
setField({ 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.
|
|
// Non-blocking — the port is already visible either way.
|
|
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
|
|
setPort(indexAfterAdd, { category: r.category })
|
|
})
|
|
.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"))
|
|
const suggestableDockerLinks = isDockerDraft
|
|
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
|
|
: []
|
|
// A Docker registration uses structured container → published-port
|
|
// suggestions below. Suppress the generic ss/netstat chips in that case
|
|
// so the same endpoint is not presented twice without its workload name.
|
|
const suggestable = isDockerDraft
|
|
? []
|
|
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
|
|
|
|
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)
|
|
}
|
|
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>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Browse panel — surfaces hidden detections with Restore
|
|
before falling through to the blank-form path. Rendered
|
|
before app cards / empty state so it takes precedence when
|
|
open. Closes automatically once all hidden slugs are
|
|
restored (nothing left to show → back to normal flow). */}
|
|
{browseOpen && (
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.appEditor.registerDifferent")}</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{t(hiddenDetections.length === 1 ? "vmLxc.appEditor.hiddenDetectionsHelpSingular" : "vmLxc.appEditor.hiddenDetectionsHelpPlural", { count: hiddenDetections.length })}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setBrowseOpen(false)}
|
|
aria-label={t("vmLxc.appEditor.closePanel")}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("vmLxc.appEditor.cancelButton")}
|
|
</Button>
|
|
</div>
|
|
{hiddenDetections.length > 0 && (
|
|
<div className="space-y-2">
|
|
{hiddenDetections.map(renderRestoreChip)}
|
|
</div>
|
|
)}
|
|
<div className="pt-1 flex justify-center">
|
|
<Button
|
|
onClick={() => { setBrowseOpen(false); openEditor() }}
|
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
|
>
|
|
<PlusCircle className="h-4 w-4 mr-1.5" />
|
|
{t("vmLxc.appEditor.registerCustom")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Empty state — always uniform chip list regardless of how
|
|
many detections there are (0, 1, or many). Below the chips,
|
|
a single "Register a different app" button lets the user
|
|
add something the auto-detector doesn't know about. */}
|
|
{apps.length === 0 && (
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-6 space-y-3">
|
|
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
|
|
<Package className="h-5 w-5 text-emerald-400" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-foreground text-center">
|
|
{t("vmLxc.appEditor.noAppsTitle")}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
|
{t("vmLxc.appEditor.noAppsBody")}
|
|
</p>
|
|
{visibleDetected.length > 0 && (
|
|
<div className="space-y-2 pt-2">
|
|
{visibleDetected.map(renderDetectionChip)}
|
|
</div>
|
|
)}
|
|
<div className="pt-1 flex 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>
|
|
|
|
{(() => {
|
|
const hasUpstream = !!(app.repo || app.upstream_type)
|
|
const hasUpdate = st?.update_available === true
|
|
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")}>
|
|
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
|
{hasUpdate && st?.latest_version && (
|
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{tracking && st?.error && (
|
|
<div className="mb-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-amber-300">{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 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
{t("vmLxc.appEditor.alsoDetectedContainer")}
|
|
</div>
|
|
{unregisteredDetected.map(renderDetectionChip)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add-more + Edit toggle. Edit is a global toggle that reveals
|
|
the per-card action footer (Remove / Check / Edit fields).
|
|
Add-more is disabled while editing so the two flows don't
|
|
overlap. Routes through the browse panel if there are hidden
|
|
detections, so the user gets one-click Restore before hand-
|
|
typing a custom app. */}
|
|
{apps.length > 0 && (
|
|
// 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>
|
|
)
|
|
}
|