mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-09 01:06:22 +00:00
Extracts 212 hardcoded English strings from virtual-machines.tsx and lxc-app-panel.tsx into AppImage/messages/en/common.json, matching the i18n pattern established by PR #277. Added namespaces under vmLxc: - options (14 keys): Snapshot / Restart / Backup storage / Edit / Cancel / Save - scheduled (22 keys): Scheduled updates, Frequency, Cron, target, Delete - cronChip (6 keys): variants + scope for external cron detection - cronPresets (6 keys): Hourly / Daily / Weekly / Monthly picker labels - updates (58 keys): OS packages, Helper info, Apply buttons, custom command editor, footer combined buttons - tabs (6 keys): tab labels - appEditor (105 keys): app registration form — installed_via methods, upstream source selectors, ports, form field labels + placeholders Other locales (sk/de/fr/it/pt/es) fall back to English via the provider's built-in fallback until translations are contributed. Follow-up: i18n coverage issue for translation work in the other locales.
1896 lines
88 KiB
TypeScript
1896 lines
88 KiB
TypeScript
"use client"
|
|
|
|
/**
|
|
* LxcAppPanel — Body of the "App" tab in the LXC modal.
|
|
*
|
|
* Handles a LIST of apps per CT (one CT can host several services
|
|
* — e.g. Frigate on port 5000 + go2rtc on 1984, or Docker + two
|
|
* containerised apps). Each app has:
|
|
* • an install method (dpkg / apk / file / binary / docker)
|
|
* • an optional GitHub repo for upstream version tracking
|
|
* • a list of ports, each with a description and web path
|
|
*
|
|
* Docker apps are "register-only": they exist to produce clickable
|
|
* links, ProxMenux does NOT try to track their version and NEVER
|
|
* emits warnings for them — updates for Docker apps are handled by
|
|
* Docker itself.
|
|
*
|
|
* For ProxMenux-managed OCI CTs (Secure Gateway) the panel is
|
|
* read-only — the actual update lifecycle lives in Security →
|
|
* Secure Gateway.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import {
|
|
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
|
|
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
|
|
ArrowUpCircle, RotateCcw, Check, Settings2, ShieldCheck, CheckCircle2,
|
|
} from "lucide-react"
|
|
import { Card, CardContent } from "./ui/card"
|
|
import { Button } from "./ui/button"
|
|
import { Input } from "./ui/input"
|
|
import { Label } from "./ui/label"
|
|
import { Badge } from "./ui/badge"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
|
import { fetchApi } from "../lib/api-config"
|
|
import { useT } from "@/lib/i18n/provider"
|
|
|
|
// installed_via is optional now — an empty value means "register only,
|
|
// no version tracking, no warnings, just a clickable link". Docker
|
|
// apps and casual "just want a link" registrations use this default.
|
|
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
|
|
"python_dist" | "docker_label" | "docker_exec" |
|
|
"command" | "manual"
|
|
type GithubSource = "releases" | "tags"
|
|
|
|
interface PortEntry {
|
|
port: number | ""
|
|
description?: string
|
|
scheme?: "http" | "https"
|
|
web_path?: string
|
|
logo_url?: string
|
|
}
|
|
|
|
interface AppConfig {
|
|
name: string
|
|
installed_via?: InstalledVia
|
|
package?: string
|
|
file_path?: string
|
|
file_regex?: string
|
|
binary_path?: string
|
|
binary_args?: string[]
|
|
python_path?: string
|
|
distribution?: string
|
|
container_name?: string
|
|
label?: string
|
|
command_argv?: string[]
|
|
installed_version?: string
|
|
installed_regex?: string
|
|
// Upstream source discriminator + fields. When `upstream_type` is
|
|
// "github" (default when `repo` is set) the classic repo /
|
|
// github_source / tag_regex fields drive the check. "http_json" and
|
|
// "docker_hub" open two new source types validated separately on
|
|
// the backend.
|
|
upstream_type?: "github" | "http_json" | "docker_hub" | ""
|
|
repo?: string
|
|
github_source?: GithubSource
|
|
upstream_url?: string
|
|
upstream_json_path?: string
|
|
docker_image?: string
|
|
tag_regex?: string
|
|
ports: PortEntry[]
|
|
health_path?: string
|
|
logo_url?: string
|
|
helper_slug?: string
|
|
}
|
|
|
|
interface DetectedApp {
|
|
slug: string
|
|
name: string
|
|
logo_url?: string | null
|
|
default_ports?: number[]
|
|
tracking_suggestion?: TrackingSuggestion | null
|
|
}
|
|
|
|
interface AppState {
|
|
installed_version: string | null
|
|
latest_version: string | null
|
|
update_available: boolean | null
|
|
error: string | null
|
|
checked_at: string | null
|
|
}
|
|
|
|
interface AppEntry extends AppConfig {
|
|
id: string
|
|
state?: AppState
|
|
created_at?: string
|
|
}
|
|
|
|
interface SidecarResponse {
|
|
vmid: number
|
|
apps: AppEntry[]
|
|
dismissed_slugs?: string[]
|
|
created_at?: string
|
|
updated_at?: string
|
|
}
|
|
|
|
interface TrackingSuggestion {
|
|
installed_via: "dpkg" | "apk" | "file" | "binary"
|
|
package?: string
|
|
file_path?: string
|
|
file_regex?: string
|
|
binary_path?: string
|
|
repo?: string
|
|
github_source?: "releases" | "tags"
|
|
tag_regex?: string
|
|
}
|
|
|
|
interface Suggestions {
|
|
name_suggestion: string | null
|
|
helper_slug: string | null
|
|
port_suggestions: number[]
|
|
web_path_hint: string | null
|
|
tracking_suggestion?: TrackingSuggestion | null
|
|
default_ports?: number[]
|
|
logo_url?: string | null
|
|
extras?: DetectedApp[]
|
|
}
|
|
|
|
// Compact catalog entry — one row for every registerable app the
|
|
// picker can offer. Fetched once from /api/apps/catalog on panel
|
|
// mount, filtered client-side while the user types.
|
|
interface CatalogEntry {
|
|
slug: string
|
|
name: string
|
|
logo: string
|
|
default_port: number
|
|
has_tracking: boolean
|
|
}
|
|
|
|
// Full detail for a picked catalog entry — server merges catalog
|
|
// metadata + curated tracking_suggestion (when available) so the
|
|
// editor can pre-fill every field in one round-trip.
|
|
interface CatalogDetail {
|
|
slug: string
|
|
name: string
|
|
logo_url: string | null
|
|
website: string
|
|
default_ports: number[]
|
|
tracking_suggestion?: TrackingSuggestion | null
|
|
}
|
|
|
|
interface ManagedAppInfo {
|
|
managed_oci_app_id: string
|
|
name: string
|
|
installed_version?: string | null
|
|
latest_version?: string | null
|
|
update_available?: boolean | null
|
|
checked_at?: string | null
|
|
error?: string | null
|
|
}
|
|
|
|
interface Props {
|
|
vmid: number
|
|
ctIp?: string | null
|
|
onChange?: () => void
|
|
managed?: ManagedAppInfo | null
|
|
}
|
|
|
|
const EMPTY_APP: AppConfig = {
|
|
name: "",
|
|
installed_via: "", // no tracking by default — just a link
|
|
package: "",
|
|
upstream_type: "",
|
|
repo: "",
|
|
github_source: "releases",
|
|
upstream_url: "",
|
|
upstream_json_path: "",
|
|
docker_image: "",
|
|
tag_regex: "v?(\\d+\\.\\d+\\.\\d+)",
|
|
ports: [],
|
|
logo_url: "",
|
|
}
|
|
|
|
// Default scheme heuristic for freshly-added ports — only used to
|
|
// pre-select the dropdown. The user always has the final say via
|
|
// the http/https selector next to the port input.
|
|
const HTTPS_HINT_PORTS = new Set([443, 4443, 8443, 9443])
|
|
const defaultSchemeFor = (port: number | ""): "http" | "https" =>
|
|
HTTPS_HINT_PORTS.has(Number(port)) ? "https" : "http"
|
|
|
|
function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?: "http" | "https") {
|
|
if (!ip || ip === "DHCP" || !port) return null
|
|
return `${scheme || defaultSchemeFor(port)}://${ip}:${port}`
|
|
}
|
|
|
|
// Suggest a dpkg/apk package name from a friendly app name — lowercase,
|
|
// spaces and slashes to hyphens, drop punctuation. Only used as a
|
|
// placeholder / auto-fill; user can always override.
|
|
function suggestPackageName(name: string) {
|
|
return name
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s/]+/g, "-")
|
|
.replace(/[^a-z0-9._+@:-]/g, "")
|
|
.replace(/-{2,}/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
}
|
|
|
|
export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
|
|
const t = useT()
|
|
const [loading, setLoading] = useState(true)
|
|
const [sidecar, setSidecar] = useState<SidecarResponse | null>(null)
|
|
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
// Editor state
|
|
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
const [busyAppId, setBusyAppId] = useState<string | null>(null)
|
|
// Advanced section (version tracking) is collapsed by default so the
|
|
// basic Name + Ports flow stays approachable. Auto-expanded when
|
|
// editing an app that already has installed_via set, or when the
|
|
// user clicked Register on an auto-detected chip whose hint carries
|
|
// tracking metadata — the user sees the fields we auto-filled and
|
|
// can tweak or opt out before saving.
|
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
|
|
// Catalog picker: 700+ apps fetched once from /api/apps/catalog and
|
|
// filtered client-side while the user types in the Name input. The
|
|
// dropdown shows top 20 matches. Selecting one calls the detail
|
|
// endpoter to seed name / logo / ports / tracking_suggestion at once.
|
|
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
|
|
// "Register a different app" browse panel: when the user has hidden
|
|
// some detections we surface them here with a Restore button before
|
|
// falling through to the blank-form path. If there's nothing to
|
|
// restore, this panel is skipped entirely and the button opens the
|
|
// editor directly (fast path for the common case).
|
|
const [browseOpen, setBrowseOpen] = useState(false)
|
|
|
|
// Global "manage apps" mode. When ON, every app card grows a footer
|
|
// with Remove / Check / Edit fields actions. When OFF the cards are
|
|
// pure info — the only surfaced action is a hover-reveal Check icon
|
|
// on the LATEST UPSTREAM panel. Toggled from a single button next to
|
|
// "Add another application".
|
|
const [editMode, setEditMode] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
if (managed) { setLoading(false); return }
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps`)
|
|
setSidecar(r)
|
|
// Always fetch suggestions — used both by the empty-state form
|
|
// seed AND by the "Also detected on this container" chip strip
|
|
// that surfaces unregistered detections even after the CT already
|
|
// has ≥1 registered app. Previously gated on `!r.apps?.length`,
|
|
// which meant a CT with 1 registered + 1 detected-but-not-yet-
|
|
// registered app never showed the second app until the user
|
|
// opened + cancelled the editor (which triggered a re-load path
|
|
// that happened to fetch it).
|
|
try {
|
|
const s: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`)
|
|
setSuggestions(s)
|
|
} catch { /* non-fatal */ }
|
|
} catch (e: any) {
|
|
setError(e?.message || "Could not load app configuration")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [vmid, managed])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// Fetch the picker catalog once per panel mount. Best-effort — if
|
|
// the API is unreachable, the picker just stays empty and users
|
|
// type the app name manually (same as before this feature).
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
fetchApi("/api/apps/catalog")
|
|
.then((data: CatalogEntry[]) => {
|
|
if (!cancelled && Array.isArray(data)) setCatalog(data)
|
|
})
|
|
.catch(() => { /* non-fatal */ })
|
|
return () => { cancelled = true }
|
|
}, [])
|
|
|
|
// Derived state — computed here BEFORE any conditional early
|
|
// return so React sees the same hook order on every render.
|
|
// Rules of Hooks: `useMemo` after an `if (loading) return …`
|
|
// trips React error #310 the moment `loading` flips false.
|
|
const apps = sidecar?.apps || []
|
|
|
|
// Unified detection list — primary community-scripts install +
|
|
// every other app whose install signature was found on the CT
|
|
// (`extras[]` from the backend). Both use the same DetectedApp
|
|
// shape so the empty state renders them uniformly.
|
|
const detectedList: DetectedApp[] = useMemo(() => {
|
|
if (!suggestions) return []
|
|
const out: DetectedApp[] = []
|
|
if (suggestions.helper_slug && suggestions.name_suggestion) {
|
|
out.push({
|
|
slug: suggestions.helper_slug,
|
|
name: suggestions.name_suggestion,
|
|
logo_url: suggestions.logo_url,
|
|
default_ports: suggestions.default_ports,
|
|
tracking_suggestion: suggestions.tracking_suggestion,
|
|
})
|
|
}
|
|
const seen = new Set(out.map((d) => d.slug))
|
|
for (const e of suggestions.extras || []) {
|
|
if (!seen.has(e.slug)) {
|
|
out.push(e)
|
|
seen.add(e.slug)
|
|
}
|
|
}
|
|
return out
|
|
}, [suggestions])
|
|
|
|
// Registered slugs — used to filter the detection list down to
|
|
// what the user hasn't already registered on this CT.
|
|
const registeredSlugs = useMemo(
|
|
() => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]),
|
|
[apps],
|
|
)
|
|
// Dismissed slugs — persisted in the sidecar. Chips the user
|
|
// explicitly hid via the ✕ button stay hidden across reloads until
|
|
// they register the app (which also un-dismisses implicitly).
|
|
const dismissedSlugs = useMemo(
|
|
() => new Set(sidecar?.dismissed_slugs || []),
|
|
[sidecar],
|
|
)
|
|
const visibleDetected = detectedList.filter(
|
|
(d) => !registeredSlugs.has(d.slug) && !dismissedSlugs.has(d.slug),
|
|
)
|
|
// Alias for pre-existing consumers (post-registration chip strip).
|
|
const unregisteredDetected = visibleDetected
|
|
// Detections the user hid and could restore from the Register-a-
|
|
// different-app panel. Not affected by registration state.
|
|
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
|
|
|
|
// "Register a different app" behavior: if there are hidden slugs,
|
|
// surface them first (with Restore) so the user can bring one back
|
|
// instead of typing everything by hand. If nothing to restore, go
|
|
// straight to the blank editor.
|
|
const openBrowseOrEditor = () => {
|
|
if (hiddenDetections.length > 0) setBrowseOpen(true)
|
|
else openEditor()
|
|
}
|
|
|
|
const openEditor = useCallback(async (
|
|
existing?: AppEntry,
|
|
opts?: { withTracking?: boolean, preset?: DetectedApp },
|
|
) => {
|
|
let seed: AppConfig
|
|
if (existing) {
|
|
seed = {
|
|
name: existing.name,
|
|
installed_via: (existing.installed_via as InstalledVia) || "",
|
|
package: existing.package || "",
|
|
file_path: existing.file_path || "",
|
|
file_regex: existing.file_regex || "",
|
|
binary_path: existing.binary_path || "",
|
|
binary_args: existing.binary_args ? [...existing.binary_args] : [],
|
|
python_path: existing.python_path || "",
|
|
distribution: existing.distribution || "",
|
|
container_name: existing.container_name || "",
|
|
label: existing.label || "",
|
|
command_argv: existing.command_argv ? [...existing.command_argv] : [],
|
|
installed_version: existing.installed_version || "",
|
|
installed_regex: existing.installed_regex || "",
|
|
upstream_type: existing.upstream_type || (existing.repo ? "github" : ""),
|
|
repo: existing.repo || "",
|
|
github_source: existing.github_source || "releases",
|
|
upstream_url: existing.upstream_url || "",
|
|
upstream_json_path: existing.upstream_json_path || "",
|
|
docker_image: existing.docker_image || "",
|
|
tag_regex: existing.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
|
|
ports: existing.ports?.length ? existing.ports.map((p) => ({ ...p })) : [],
|
|
health_path: existing.health_path || "",
|
|
logo_url: existing.logo_url || "",
|
|
helper_slug: existing.helper_slug || "",
|
|
}
|
|
// Editing an existing app: expand Advanced when tracking is on
|
|
setShowAdvanced(!!seed.installed_via)
|
|
} else {
|
|
seed = { ...EMPTY_APP, ports: [] }
|
|
let s = suggestions
|
|
if (!s) {
|
|
try {
|
|
s = await fetchApi(`/api/vms/${vmid}/apps/suggestions`)
|
|
setSuggestions(s)
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
// Preset path: a chip in the empty state (primary OR extra) was
|
|
// clicked. Seed EVERYTHING from the preset so this works
|
|
// regardless of whether it's the first or Nth app on the CT.
|
|
// Primary detection is `{...suggestions}`-shaped, an extra is
|
|
// `DetectedApp`-shaped — both carry name/logo/ports/tracking.
|
|
if (opts?.preset) {
|
|
const p = opts.preset
|
|
seed.name = p.name
|
|
seed.logo_url = p.logo_url || ""
|
|
seed.helper_slug = p.slug
|
|
if (p.default_ports?.length) {
|
|
seed.ports = p.default_ports.map((port) => ({
|
|
port,
|
|
scheme: defaultSchemeFor(port),
|
|
web_path: s?.web_path_hint || "",
|
|
}))
|
|
}
|
|
if (opts.withTracking && p.tracking_suggestion) {
|
|
const t = p.tracking_suggestion
|
|
seed = {
|
|
...seed,
|
|
installed_via: t.installed_via,
|
|
package: t.package || "",
|
|
file_path: t.file_path || "",
|
|
file_regex: t.file_regex || "",
|
|
binary_path: t.binary_path || "",
|
|
upstream_type: (t as any).upstream_type || (t.repo ? "github" : ""),
|
|
repo: t.repo || "",
|
|
github_source: t.github_source || "releases",
|
|
upstream_url: (t as any).upstream_url || "",
|
|
upstream_json_path: (t as any).upstream_json_path || "",
|
|
docker_image: (t as any).docker_image || "",
|
|
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
|
|
}
|
|
setShowAdvanced(true)
|
|
} else {
|
|
setShowAdvanced(false)
|
|
}
|
|
} else {
|
|
// No preset (bare "+ Register application"): start empty so
|
|
// the user types name/ports/logo for a custom app the auto-
|
|
// detector doesn't know about.
|
|
setShowAdvanced(false)
|
|
}
|
|
}
|
|
setEditing({ appId: existing?.id || null, draft: seed })
|
|
setError(null)
|
|
}, [suggestions, vmid, sidecar])
|
|
|
|
const closeEditor = () => {
|
|
setEditing(null)
|
|
setError(null)
|
|
}
|
|
|
|
const save = async () => {
|
|
if (!editing) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
const url = editing.appId
|
|
? `/api/vms/${vmid}/apps/${editing.appId}`
|
|
: `/api/vms/${vmid}/apps`
|
|
const method = editing.appId ? "PUT" : "POST"
|
|
const r: SidecarResponse & { error?: string } = await fetchApi(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(editing.draft),
|
|
})
|
|
if ((r as any).error) throw new Error((r as any).error)
|
|
setSidecar(r)
|
|
setEditing(null)
|
|
onChange?.()
|
|
} catch (e: any) {
|
|
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const checkOne = async (appId: string) => {
|
|
setBusyAppId(appId)
|
|
setError(null)
|
|
try {
|
|
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${appId}/check`, {
|
|
method: "POST",
|
|
})
|
|
setSidecar(r)
|
|
onChange?.()
|
|
} catch (e: any) {
|
|
setError(e?.message || "Check failed")
|
|
} finally {
|
|
setBusyAppId(null)
|
|
}
|
|
}
|
|
|
|
const removeOne = async (appId: string) => {
|
|
if (!confirm("Remove this application from the CT's App tab?")) return
|
|
setBusyAppId(appId)
|
|
setError(null)
|
|
try {
|
|
await fetchApi(`/api/vms/${vmid}/apps/${appId}`, { method: "DELETE" })
|
|
// Reload from server so the empty state re-fetches suggestions
|
|
await load()
|
|
onChange?.()
|
|
} catch (e: any) {
|
|
setError(e?.message || "Delete failed")
|
|
} finally {
|
|
setBusyAppId(null)
|
|
}
|
|
}
|
|
|
|
// Hide an auto-detected chip. Optimistic UI: update the local
|
|
// sidecar state immediately so the chip disappears without
|
|
// waiting for the round-trip, then persist to the server. If the
|
|
// POST fails, reload from server to resync.
|
|
const dismissDetection = async (slug: string, name: string) => {
|
|
if (!confirm(t("vmLxc.appEditor.confirmHide", { name }))) return
|
|
setSidecar((prev) => prev
|
|
? { ...prev, dismissed_slugs: [...(prev.dismissed_slugs || []), slug] }
|
|
: prev,
|
|
)
|
|
try {
|
|
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/dismiss`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ slug, dismissed: true }),
|
|
})
|
|
setSidecar(r)
|
|
} catch (e: any) {
|
|
setError(e?.message || "Could not dismiss detection")
|
|
await load() // resync on failure
|
|
}
|
|
}
|
|
|
|
// Un-hide a previously dismissed slug. Used from the "Register a
|
|
// different app" panel. Optimistically drops the slug from local
|
|
// dismissed_slugs so the chip re-appears in the main list, then
|
|
// persists.
|
|
const restoreDetection = async (slug: string) => {
|
|
setSidecar((prev) => prev
|
|
? { ...prev, dismissed_slugs: (prev.dismissed_slugs || []).filter((s) => s !== slug) }
|
|
: prev,
|
|
)
|
|
try {
|
|
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/dismiss`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ slug, dismissed: false }),
|
|
})
|
|
setSidecar(r)
|
|
} catch (e: any) {
|
|
setError(e?.message || "Could not restore detection")
|
|
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 || "Managed app")
|
|
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 · managed" : "managed"
|
|
const hasUpdate = managed.update_available === true
|
|
const upToDate = managed.update_available === false && !!managed.installed_version
|
|
const showVersions = !!(managed.installed_version || managed.latest_version || repo)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-4">
|
|
{/* Block 1 — Secure Gateway identity (the ProxMenux
|
|
product). Big shield, title, catalog subtitle. */}
|
|
<div className="flex items-start gap-3">
|
|
<div className="h-14 w-14 flex-shrink-0 rounded-md bg-cyan-500/10 flex items-center justify-center">
|
|
<ShieldCheck className="h-11 w-11 text-cyan-500" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-xl font-semibold text-foreground truncate">{displayName}</h3>
|
|
{displaySubtitle && (
|
|
<div className="text-sm text-muted-foreground mt-0.5 truncate">{displaySubtitle}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Block 2 — Underlying engine (Tailscale). Same visual
|
|
pattern as a regular app card so it's clear this is
|
|
what version tracking is anchored to. Repo link goes
|
|
here (top-right on desktop / stacked on mobile) because
|
|
the repo is the engine's, not Secure Gateway's. */}
|
|
{upstreamName && (
|
|
<div className="mt-4 pt-4 border-t border-border/50">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
{upstreamLogoLightUrl || upstreamLogoDarkUrl ? (
|
|
<>
|
|
{upstreamLogoLightUrl && (
|
|
<img
|
|
src={upstreamLogoLightUrl}
|
|
alt=""
|
|
className="h-10 w-10 flex-shrink-0 rounded-md object-contain block dark:hidden"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
|
|
/>
|
|
)}
|
|
{upstreamLogoDarkUrl && (
|
|
<img
|
|
src={upstreamLogoDarkUrl}
|
|
alt=""
|
|
className="h-10 w-10 flex-shrink-0 rounded-md object-contain hidden dark:block"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="h-10 w-10 flex-shrink-0 rounded-md bg-muted flex items-center justify-center">
|
|
<Package className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-base font-semibold text-foreground truncate">{upstreamName}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">{methodLine}</div>
|
|
{managed.checked_at && (
|
|
<div className="text-[10px] text-muted-foreground/80 mt-0.5">
|
|
Checked {new Date(managed.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}
|
|
</div>
|
|
)}
|
|
{repo && (
|
|
<a
|
|
href={`https://github.com/${repo}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="md:hidden mt-1 text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 min-w-0"
|
|
>
|
|
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate">{repo}</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{repo && (
|
|
<a
|
|
href={`https://github.com/${repo}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hidden md:inline-flex text-xs text-muted-foreground hover:text-foreground items-center gap-1 flex-shrink-0 mt-1"
|
|
>
|
|
{repo}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showVersions && (
|
|
<div className={"mt-3 grid gap-3 " + (repo ? "grid-cols-2" : "grid-cols-1")}>
|
|
{managed.installed_version && (
|
|
<div className="p-3 rounded-md bg-muted/40">
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
|
|
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
|
|
{managed.installed_version}
|
|
{upToDate && (
|
|
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{repo && (
|
|
<div className="p-3 rounded-md bg-muted/40">
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">
|
|
Latest upstream
|
|
</div>
|
|
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
|
|
{managed.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
|
{hasUpdate && managed.latest_version && (
|
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{managed.error && (
|
|
<div className="mt-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-amber-300">{managed.error}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Managed banner — green translucent badge signalling this
|
|
CT's lifecycle is owned by ProxMenux (not user CRUD). */}
|
|
<div className="mt-4 p-2.5 rounded-md bg-green-500/10 border border-green-500/30 flex items-center gap-2 text-sm">
|
|
<ShieldCheck className="h-4 w-4 text-green-400 flex-shrink-0" />
|
|
<span className="text-green-300">{t("vmLxc.appEditor.installedManaged")}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
|
Loading applications…
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Editor ─────────────────────────────────────────────────────
|
|
if (editing) {
|
|
const draft = editing.draft
|
|
const method = draft.installed_via || ""
|
|
const isPackaged = method === "dpkg" || method === "apk"
|
|
const setField = (patch: Partial<AppConfig>) =>
|
|
setEditing({ ...editing, draft: { ...draft, ...patch } })
|
|
// Editing the Name auto-fills the Package field on packaged
|
|
// methods when it's still empty. Rationale: 90% of the time the
|
|
// dpkg/apk package name mirrors the friendly app name (jellyfin,
|
|
// adguardhome, portainer-ce). The user can still override.
|
|
const setName = (name: string) => {
|
|
const patch: Partial<AppConfig> = { name }
|
|
if (isPackaged && !draft.package?.trim()) {
|
|
patch.package = suggestPackageName(name)
|
|
}
|
|
setField(patch)
|
|
}
|
|
const setPort = (i: number, patch: Partial<PortEntry>) => {
|
|
const ports = draft.ports.map((p, idx) => (idx === i ? { ...p, ...patch } : p))
|
|
setField({ ports })
|
|
}
|
|
// "Add port" adds an EMPTY row for manual entry. Detected chips
|
|
// ("+5000", "+1984"…) add the port directly with the port
|
|
// pre-filled — no need for the user to open a row first.
|
|
const addEmptyPort = () =>
|
|
setField({ ports: [...draft.ports, { port: "", description: "", scheme: "http" }] })
|
|
const addDetectedPort = (port: number) => {
|
|
const scheme = defaultSchemeFor(port)
|
|
// If the last row is still empty, fill it instead of appending
|
|
// a duplicate. Avoids the two-lines-appear bug.
|
|
const last = draft.ports[draft.ports.length - 1]
|
|
if (last && last.port === "" && !last.description) {
|
|
const ports = [...draft.ports]
|
|
ports[ports.length - 1] = { port, description: "", scheme }
|
|
setField({ ports })
|
|
} else {
|
|
setField({ ports: [...draft.ports, { port, description: "", scheme }] })
|
|
}
|
|
}
|
|
const removePort = (i: number) =>
|
|
setField({ ports: draft.ports.filter((_, idx) => idx !== i) })
|
|
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
|
const suggestable = (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Editor card — opaque `bg-card` (not the `/50` used by
|
|
view-mode cards) so the darker `bg-background` inputs sit
|
|
visibly recessed against it. Matches the sunken-input
|
|
pattern used in the PBS setup wizard: card = surface,
|
|
fields = wells inside the surface. Reverts to `bg-card/50`
|
|
automatically because this render branch only fires when
|
|
`editing !== null`. */}
|
|
<Card className="border border-border bg-card">
|
|
<CardContent className="p-4 space-y-4">
|
|
<div className="relative">
|
|
<Label htmlFor="app-name">{t("vmLxc.appEditor.nameLabel")}</Label>
|
|
<Input
|
|
id="app-name"
|
|
value={draft.name}
|
|
onChange={(e) => { setName(e.target.value); setPickerOpen(true) }}
|
|
onFocus={() => setPickerOpen(true)}
|
|
onBlur={() => setTimeout(() => setPickerOpen(false), 150)}
|
|
placeholder={suggestions?.name_suggestion || "Type to search 700+ apps, or type your own"}
|
|
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") : `${matches.length} match${matches.length === 1 ? "" : "es"}`}
|
|
</div>
|
|
{matches.map((c) => (
|
|
<button
|
|
key={c.slug}
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={async () => {
|
|
setPickerOpen(false)
|
|
try {
|
|
const detail: CatalogDetail = await fetchApi(`/api/apps/catalog/${c.slug}`)
|
|
// Seed the entire form from the picker detail.
|
|
const patch: Partial<AppConfig> = {
|
|
name: detail.name,
|
|
helper_slug: detail.slug,
|
|
logo_url: detail.logo_url || "",
|
|
ports: detail.default_ports?.length
|
|
? detail.default_ports.map((p) => ({
|
|
port: p,
|
|
scheme: defaultSchemeFor(p),
|
|
web_path: "",
|
|
}))
|
|
: draft.ports,
|
|
}
|
|
if (detail.tracking_suggestion) {
|
|
const t = detail.tracking_suggestion as any
|
|
patch.installed_via = t.installed_via
|
|
patch.package = t.package || ""
|
|
patch.file_path = t.file_path || ""
|
|
patch.file_regex = t.file_regex || ""
|
|
patch.binary_path = t.binary_path || ""
|
|
patch.binary_args = t.binary_args || []
|
|
patch.python_path = t.python_path || ""
|
|
patch.distribution = t.distribution || ""
|
|
patch.container_name = t.container_name || ""
|
|
patch.label = t.label || ""
|
|
patch.installed_regex = t.installed_regex || ""
|
|
patch.upstream_type = (t as any).upstream_type || (t.repo ? "github" : "")
|
|
patch.repo = t.repo || ""
|
|
patch.github_source = t.github_source || "releases"
|
|
patch.upstream_url = (t as any).upstream_url || ""
|
|
patch.upstream_json_path = (t as any).upstream_json_path || ""
|
|
patch.docker_image = (t as any).docker_image || ""
|
|
patch.tag_regex = t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)"
|
|
setShowAdvanced(true)
|
|
}
|
|
setEditing((prev) => prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev)
|
|
} catch { /* non-fatal */ }
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
>
|
|
{c.logo && (
|
|
<img src={c.logo} alt="" className="h-6 w-6 flex-shrink-0 rounded object-contain"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }} />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm text-foreground truncate">{c.name}</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{c.slug}
|
|
{c.default_port ? ` · port ${c.default_port}` : ""}
|
|
{c.has_tracking ? " · tracking available" : ""}
|
|
</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">
|
|
Logo URL <span className="normal-case tracking-normal text-[10px] opacity-70">(optional)</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">
|
|
Web links
|
|
</Label>
|
|
<Button type="button" variant="ghost" size="sm" onClick={addEmptyPort}>
|
|
<PlusCircle className="h-3.5 w-3.5 mr-1" />
|
|
Add port
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Detected chips FIRST — one-click add. Only shown when
|
|
there are chips left to suggest, so empty states stay
|
|
clean. Click on a chip: fills the current empty row
|
|
or adds a new one; never duplicates. */}
|
|
{suggestable.length > 0 && (
|
|
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
|
<span className="text-[10px] text-muted-foreground pr-1">
|
|
Ports detected in the container — click to add:
|
|
</span>
|
|
{suggestable.map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => addDetectedPort(p)}
|
|
className="text-[10px] px-1.5 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 text-foreground font-mono"
|
|
>
|
|
+{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{draft.ports.length === 0 && suggestable.length === 0 && (
|
|
<div className="text-xs text-muted-foreground italic">
|
|
No web ports listening. Use "Add port" if you'd still
|
|
like to register one manually.
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{draft.ports.map((entry, i) => (
|
|
<div
|
|
key={i}
|
|
className="grid grid-cols-[86px_86px_1fr_auto] gap-2 items-center gap-y-1.5"
|
|
>
|
|
<Select
|
|
value={entry.scheme || defaultSchemeFor(entry.port)}
|
|
onValueChange={(v) => setPort(i, { scheme: v as "http" | "https" })}
|
|
>
|
|
<SelectTrigger className="text-sm h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="http">http</SelectItem>
|
|
<SelectItem value="https">https</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
type="number"
|
|
value={entry.port}
|
|
onChange={(e) => setPort(i, { port: e.target.value ? Number(e.target.value) : "" })}
|
|
placeholder={t("vmLxc.appEditor.portPortPlaceholder")}
|
|
min={1}
|
|
max={65535}
|
|
className="text-sm"
|
|
/>
|
|
<Input
|
|
value={entry.description || ""}
|
|
onChange={(e) => setPort(i, { description: e.target.value })}
|
|
placeholder={t("vmLxc.appEditor.portDescriptionPlaceholder")}
|
|
maxLength={64}
|
|
className="text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removePort(i)}
|
|
className="text-red-400 hover:text-red-300"
|
|
aria-label={t("vmLxc.appEditor.removePortTooltip")}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{/* Per-link logo URL — spans cols 1-3 so its
|
|
right edge lines up with the description input
|
|
above (never covers the trash column). */}
|
|
<Input
|
|
value={entry.logo_url || ""}
|
|
onChange={(e) => setPort(i, { logo_url: e.target.value })}
|
|
placeholder={t("vmLxc.appEditor.portLogoLabel")}
|
|
maxLength={512}
|
|
className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100"
|
|
type="url"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Advanced (Version tracking, optional) ──────────
|
|
Collapsed by default so casual users never see the
|
|
technical fields. Auto-expanded when editing an app
|
|
that already has tracking configured, or when the
|
|
user registered from an auto-detected chip whose
|
|
hint carried tracking metadata. */}
|
|
<div className="pt-2 border-t border-border/50">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="flex items-center gap-1.5 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground w-full"
|
|
>
|
|
{showAdvanced ? (
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
)}
|
|
Track upstream version (optional)
|
|
{!showAdvanced && !method && (
|
|
<span className="ml-auto text-[10px] normal-case tracking-normal">
|
|
Off — link only
|
|
</span>
|
|
)}
|
|
{!showAdvanced && method && (
|
|
<span className="ml-auto text-[10px] normal-case tracking-normal text-emerald-400/80">
|
|
On · {method}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="mt-3 space-y-3">
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
Optional. When enabled, ProxMenux checks the installed
|
|
version inside the container and compares it against
|
|
a GitHub repo — you'll see "update available" and get
|
|
an optional notification. Skip this if you only want
|
|
a clickable link.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<Label htmlFor="app-method">{t("vmLxc.appEditor.installedViaLabel")}</Label>
|
|
<Select
|
|
value={method || "none"}
|
|
onValueChange={(v) =>
|
|
setField({ installed_via: v === "none" ? "" : (v as InstalledVia) })
|
|
}
|
|
>
|
|
<SelectTrigger id="app-method"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">{t("vmLxc.appEditor.methodNone")}</SelectItem>
|
|
<SelectItem value="dpkg">{t("vmLxc.appEditor.methodDpkg")}</SelectItem>
|
|
<SelectItem value="apk">{t("vmLxc.appEditor.methodApk")}</SelectItem>
|
|
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
|
|
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
|
|
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
|
|
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
|
|
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
|
|
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
|
|
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{isPackaged && (
|
|
<div>
|
|
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
|
|
<Input
|
|
id="app-package"
|
|
value={draft.package || ""}
|
|
onChange={(e) => setField({ package: e.target.value })}
|
|
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground mt-1">
|
|
Auto-filled from Name. Verify with{" "}
|
|
<code className="text-foreground/70">
|
|
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
|
|
</code>{" "}
|
|
inside the CT.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{method === "binary" && (
|
|
<div>
|
|
<Label htmlFor="app-binary">{t("vmLxc.appEditor.binaryPathLabel")}</Label>
|
|
<Input
|
|
id="app-binary"
|
|
value={draft.binary_path || ""}
|
|
onChange={(e) => setField({ binary_path: e.target.value })}
|
|
placeholder={t("vmLxc.appEditor.binaryPathPlaceholder")}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground mt-1">
|
|
Absolute path. Find it with{" "}
|
|
<code className="text-foreground/70">which <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={(draft.binary_args || []).join(", ")}
|
|
onChange={(e) => setField({
|
|
binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
|
|
})}
|
|
placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground mt-1">
|
|
{t("vmLxc.appEditor.binaryArgsHintPrefix")} <code className="text-foreground/70">--version</code>. {t("vmLxc.appEditor.binaryArgsHintGrafana")} <code className="text-foreground/70">server, -v</code>.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{method === "command" && (
|
|
<div>
|
|
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
|
|
<Input
|
|
id="app-cmd-argv"
|
|
value={(draft.command_argv || []).join(", ")}
|
|
onChange={(e) => setField({
|
|
command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
|
|
})}
|
|
placeholder={t("vmLxc.appEditor.commandPlaceholder")}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground mt-1">
|
|
Runs argv-style inside the CT as root — never through a shell. You are responsible for what the command does. Use <code className="text-foreground/70">installed_regex</code> below to extract the version from the output.
|
|
</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">
|
|
Type the version you have installed. If <code className="text-foreground/70">repo</code> is set below, ProxMenux still checks upstream and notifies on updates — after upgrading the app, come back here and update this string.
|
|
</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">
|
|
How ProxMenux finds the latest version to compare against your installed one.
|
|
</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">
|
|
Public GitHub repository where releases or tags are published.
|
|
</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">
|
|
Public JSON endpoint that returns a version somewhere in the payload.
|
|
</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">
|
|
Dotted path with optional <code className="text-foreground/70">[N]</code> array indices.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{upstreamType === "docker_hub" && (
|
|
<div>
|
|
<Label htmlFor="app-docker-image">{t("vmLxc.appEditor.dockerImageLabel")}</Label>
|
|
<Input
|
|
id="app-docker-image"
|
|
value={draft.docker_image || ""}
|
|
onChange={(e) => setField({ docker_image: e.target.value })}
|
|
placeholder={t("vmLxc.appEditor.dockerImagePlaceholder")}
|
|
className="font-mono text-xs"
|
|
maxLength={255}
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground mt-1">
|
|
<code className="text-foreground/70">owner/name</code> — or bare
|
|
name for official images. ProxMenux picks the highest semver tag
|
|
matching the filter below.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{upstreamType && (
|
|
<div>
|
|
<Label htmlFor="app-tag-regex">
|
|
{upstreamType === "docker_hub" ? t("vmLxc.appEditor.tagFilterRegexOptional") : t("vmLxc.appEditor.versionRegexOptional")}
|
|
</Label>
|
|
<Input
|
|
id="app-tag-regex"
|
|
value={draft.tag_regex || ""}
|
|
onChange={(e) => setField({ tag_regex: e.target.value })}
|
|
placeholder={t("vmLxc.appEditor.tagRegexPlaceholder")}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground mt-1">
|
|
{upstreamType === "github" && "Extracts the version from the release tag name."}
|
|
{upstreamType === "http_json" && "Optional — extract a substring from the endpoint's value."}
|
|
{upstreamType === "docker_hub" && "Filter which tags qualify (e.g. only semver). Applied before picking the highest."}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
|
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 justify-end pt-2">
|
|
<Button variant="ghost" onClick={closeEditor} disabled={saving}>{t("vmLxc.appEditor.cancelButton")}</Button>
|
|
<Button
|
|
onClick={save}
|
|
disabled={saving || !draft.name.trim()}
|
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
|
>
|
|
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
|
|
{t("vmLxc.appEditor.saveButton")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Restore chip — same visual shell as detection chip but the
|
|
// action switches from Register/Hide to Restore. Used inside the
|
|
// Register-a-different-app panel when hidden slugs exist.
|
|
const renderRestoreChip = (d: DetectedApp) => (
|
|
<div key={d.slug} className="p-3 rounded-md border border-border/60 bg-background/40">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
{d.logo_url && (
|
|
<img
|
|
src={d.logo_url}
|
|
alt=""
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
|
|
/>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
|
|
<div className="text-xs text-muted-foreground">{t("vmLxc.appEditor.hiddenBadge")}</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => restoreDetection(d.slug)}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
|
|
Restore
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
// Uniform detection chip used in every context — empty state,
|
|
// post-registration "also detected" strip, and the Register-a-
|
|
// different-app panel. Actions layout responsive:
|
|
// • Desktop (sm+): Register + Hide side-by-side, both labeled
|
|
// • Mobile: same row, 3/4 Register (label+icon) + 1/4 Hide
|
|
// (eye icon only inside a red-translucent button)
|
|
//
|
|
// Single "Register" button covers both paths — with or without
|
|
// tracking hint — the editor opens pre-filled with whatever data
|
|
// we have, and the user can adjust in Advanced.
|
|
const renderDetectionChip = (d: DetectedApp) => (
|
|
<div key={d.slug} className="p-3 rounded-md border border-border/60 bg-background/40">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
{d.logo_url && (
|
|
<img
|
|
src={d.logo_url}
|
|
alt=""
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
|
|
/>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
|
|
<div className="text-xs text-emerald-400/90">{t("vmLxc.appEditor.detectedInContainer")}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => openEditor(undefined, { withTracking: !!d.tracking_suggestion, preset: d })}
|
|
className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white"
|
|
>
|
|
<PlusCircle className="h-3.5 w-3.5 mr-1" />
|
|
Register
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => dismissDetection(d.slug, d.name)}
|
|
aria-label={`Hide ${d.name} detection`}
|
|
title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
|
|
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
|
|
>
|
|
<EyeOff className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Browse panel — surfaces hidden detections with Restore
|
|
before falling through to the blank-form path. Rendered
|
|
before app cards / empty state so it takes precedence when
|
|
open. Closes automatically once all hidden slugs are
|
|
restored (nothing left to show → back to normal flow). */}
|
|
{browseOpen && (
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.appEditor.registerDifferent")}</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{t(hiddenDetections.length === 1 ? "vmLxc.appEditor.hiddenDetectionsHelpSingular" : "vmLxc.appEditor.hiddenDetectionsHelpPlural", { count: hiddenDetections.length })}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setBrowseOpen(false)}
|
|
aria-label={t("vmLxc.appEditor.closePanel")}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
{t("vmLxc.appEditor.cancelButton")}
|
|
</Button>
|
|
</div>
|
|
{hiddenDetections.length > 0 && (
|
|
<div className="space-y-2">
|
|
{hiddenDetections.map(renderRestoreChip)}
|
|
</div>
|
|
)}
|
|
<div className="pt-1 flex justify-center">
|
|
<Button
|
|
onClick={() => { setBrowseOpen(false); openEditor() }}
|
|
className="bg-blue-500 hover:bg-blue-600 text-white"
|
|
>
|
|
<PlusCircle className="h-4 w-4 mr-1.5" />
|
|
{t("vmLxc.appEditor.registerCustom")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Empty state — always uniform chip list regardless of how
|
|
many detections there are (0, 1, or many). Below the chips,
|
|
a single "Register a different app" button lets the user
|
|
add something the auto-detector doesn't know about. */}
|
|
{apps.length === 0 && (
|
|
<Card className="border border-border bg-card/50">
|
|
<CardContent className="p-6 space-y-3">
|
|
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
|
|
<Package className="h-5 w-5 text-emerald-400" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-foreground text-center">
|
|
{t("vmLxc.appEditor.noAppsTitle")}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
|
|
{t("vmLxc.appEditor.noAppsBody")}
|
|
</p>
|
|
{visibleDetected.length > 0 && (
|
|
<div className="space-y-2 pt-2">
|
|
{visibleDetected.map(renderDetectionChip)}
|
|
</div>
|
|
)}
|
|
<div className="pt-1 flex justify-center">
|
|
<Button onClick={openBrowseOrEditor} variant={visibleDetected.length > 0 ? "outline" : "default"}
|
|
className={visibleDetected.length > 0 ? "" : "bg-blue-500 hover:bg-blue-600 text-white"}>
|
|
<PlusCircle className="h-4 w-4 mr-1.5" />
|
|
{visibleDetected.length > 0 ? t("vmLxc.appEditor.registerDifferent") : t("vmLxc.appEditor.registerApplication")}
|
|
{hiddenDetections.length > 0 && (
|
|
<span className="ml-2 text-[10px] opacity-70">
|
|
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* App cards */}
|
|
{apps.map((app) => {
|
|
const st = app.state
|
|
// Version tracking is on when installed_via is set. Without a
|
|
// method the app is register-only — no cards, no warnings.
|
|
const tracking = !!app.installed_via
|
|
const hasUpdate = tracking && st?.update_available === true
|
|
const upToDate = tracking && st?.update_available === false && !!st?.installed_version
|
|
return (
|
|
<Card key={app.id} className="border border-border bg-card/50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
{app.logo_url && (
|
|
<img
|
|
src={app.logo_url}
|
|
alt=""
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-lg font-semibold text-foreground truncate">{app.name}</h3>
|
|
{tracking && (
|
|
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5 flex-wrap">
|
|
{app.installed_via === "dpkg" && app.package && <>dpkg · <code className="text-foreground/80">{app.package}</code></>}
|
|
{app.installed_via === "apk" && app.package && <>apk · <code className="text-foreground/80">{app.package}</code></>}
|
|
{app.installed_via === "file" && app.file_path && <>file · <code className="text-foreground/80">{app.file_path}</code></>}
|
|
{app.installed_via === "binary" && app.binary_path && <>binary · <code className="text-foreground/80">{app.binary_path}</code></>}
|
|
</div>
|
|
)}
|
|
{tracking && st?.checked_at && (
|
|
<div className="text-[10px] text-muted-foreground/80 mt-0.5">
|
|
Checked {new Date(st.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}
|
|
</div>
|
|
)}
|
|
{/* Mobile-only repo link: falls into the metadata
|
|
stack below Checked, full-width so long repo
|
|
names wrap cleanly instead of competing with
|
|
the top-right on narrow screens. */}
|
|
{app.repo && tracking && (
|
|
<a
|
|
href={`https://github.com/${app.repo}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="md:hidden mt-1 text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 min-w-0"
|
|
>
|
|
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate">{app.repo}</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Desktop-only repo link: same row as the title on md+,
|
|
hidden on mobile where the stacked variant above
|
|
handles it. */}
|
|
{app.repo && tracking && (
|
|
<a
|
|
href={`https://github.com/${app.repo}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hidden md:inline-flex text-xs text-muted-foreground hover:text-foreground items-center gap-1 flex-shrink-0 mt-1"
|
|
>
|
|
{app.repo}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{/* Version panels — always same-line (grid-cols-2), on
|
|
every viewport when ANY upstream source is configured
|
|
(github repo, http_json endpoint, or docker_hub
|
|
image). The signal that an update exists is the
|
|
LATEST UPSTREAM number turning purple + a small
|
|
ArrowUpCircle next to it, matching the project-wide
|
|
update button convention. Absence of purple = up to
|
|
date; no colored banners, no green/orange noise.
|
|
Labels stay muted so numbers keep visual priority.
|
|
When no upstream is configured, the LATEST panel is
|
|
omitted entirely and INSTALLED takes the full width. */}
|
|
{(() => {
|
|
const hasUpstream = !!(app.repo || app.upstream_type)
|
|
if (!tracking || !(st?.installed_version || hasUpstream)) return null
|
|
return (
|
|
<div className={"mb-3 grid gap-3 " + (hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
|
|
{st?.installed_version && (
|
|
<div className="p-3 rounded-md bg-muted/40">
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
|
|
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
|
|
{st.installed_version}
|
|
{upToDate && (
|
|
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{hasUpstream && (
|
|
<div className="p-3 rounded-md bg-muted/40">
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">
|
|
Latest upstream
|
|
</div>
|
|
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
|
|
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
|
|
{hasUpdate && st?.latest_version && (
|
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{tracking && st?.error && (
|
|
<div className="mb-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-amber-300">{st.error}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Web links — one row per port. Each row:
|
|
[logo 56px] Description or app name
|
|
↗ http://IP:PORT
|
|
Logo is optional (per-port `logo_url`); when absent
|
|
the row indents naturally to align with the text.
|
|
If we can't resolve an IP for the CT we hide the row. */}
|
|
{app.ports && app.ports.length > 0 && (
|
|
<div className="mb-3 space-y-4">
|
|
{app.ports.map((p) => {
|
|
const url = buildWebUrl(ctIp, p.port, p.scheme)
|
|
if (!url) return null
|
|
const label = p.description || app.name
|
|
return (
|
|
<div key={p.port} className="flex items-start gap-3 min-w-0">
|
|
{p.logo_url && (
|
|
<img
|
|
src={p.logo_url}
|
|
alt=""
|
|
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex flex-col">
|
|
<span className="text-sm font-medium text-foreground truncate">{label}</span>
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1.5 min-w-0"
|
|
title={url}
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
|
|
<span className="font-mono text-sm truncate">{url}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer with per-card actions — only rendered in the
|
|
global edit mode (toggled from the "Edit" button next
|
|
to Add another application). View mode keeps cards
|
|
chrome-free; Check is still reachable via the
|
|
hover-reveal icon on the LATEST panel. Buttons match
|
|
the Settings-page section style (h-8, outline,
|
|
small icon + label) for visual consistency across
|
|
the app. */}
|
|
{editMode && (
|
|
<div className="flex flex-wrap gap-2 items-center mt-3 pt-3 border-t border-border/50">
|
|
<button
|
|
type="button"
|
|
onClick={() => removeOne(app.id)}
|
|
disabled={busyAppId === app.id}
|
|
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Remove
|
|
</button>
|
|
<div className="ml-auto flex flex-wrap gap-2">
|
|
{tracking && (
|
|
<button
|
|
type="button"
|
|
onClick={() => checkOne(app.id)}
|
|
disabled={busyAppId === app.id}
|
|
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
{busyAppId === app.id
|
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
: <RefreshCw className="h-3.5 w-3.5" />}
|
|
Check
|
|
</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" />
|
|
Edit fields
|
|
</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">
|
|
Also detected on this container
|
|
</div>
|
|
{unregisteredDetected.map(renderDetectionChip)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add-more + Edit toggle. Edit is a global toggle that reveals
|
|
the per-card action footer (Remove / Check / Edit fields).
|
|
Add-more is disabled while editing so the two flows don't
|
|
overlap. Routes through the browse panel if there are hidden
|
|
detections, so the user gets one-click Restore before hand-
|
|
typing a custom app. */}
|
|
{apps.length > 0 && (
|
|
<div className="flex justify-end items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={openBrowseOrEditor}
|
|
disabled={editMode}
|
|
>
|
|
<PlusCircle className="h-4 w-4 mr-1.5" />
|
|
Add another application
|
|
{hiddenDetections.length > 0 && (
|
|
<span className="ml-2 text-[10px] opacity-70">
|
|
· {hiddenDetections.length} hidden
|
|
</span>
|
|
)}
|
|
</Button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditMode((v) => !v)}
|
|
className="h-9 px-3 text-sm rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
|
|
>
|
|
{editMode ? (
|
|
<>
|
|
<Check className="h-3.5 w-3.5" />
|
|
Done
|
|
</>
|
|
) : (
|
|
<>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
|
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|