feat(services): cleanup, services admin UI, docs
PR 4a of the runtime service registry change. - Remove addon pages (/addons/:addonId, AddonPage, addons/*) superseded by service pages. - Remove grafana_url/prometheus_url from backend config, compose, .env.example, and README (URLs now live on service records; VITE_ frontend deep-link vars retained). - Add Services page (/services) with create/list/delete + sidebar nav, so services are configurable in the tool itself and service pages are reachable. - Update docs/REQUIREMENTS.md service-registry section; add CHANGELOG.md with the breaking-upgrade note (MANAGE_ENCRYPTION_KEY required; grafana/prometheus env vars removed; default widget seeding removed). Verification: backend ruff clean, pytest 222 passed; frontend lint 0 errors, build success, 70 tests passed.
This commit is contained in:
+7
-11
@@ -22,8 +22,8 @@ import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { AddonPage } from "./pages/AddonPage";
|
||||
import { ServicePage } from "./pages/ServicePage";
|
||||
import { ServicesPage } from "./pages/ServicesPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Boxes,
|
||||
} from "lucide-react";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -90,6 +91,7 @@ const navItems = [
|
||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
{ path: "/actions", label: "Actions", icon: Zap },
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
|
||||
@@ -451,11 +453,8 @@ function AppInner() {
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -487,11 +486,8 @@ function AppInner() {
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function GrafanaAddonPage() {
|
||||
const grafanaUrl =
|
||||
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
|
||||
"http://localhost:3000";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Grafana</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics & logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open the full Grafana instance for dashboards, metrics, and log
|
||||
exploration.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={grafanaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function PrometheusAddonPage() {
|
||||
const prometheusUrl =
|
||||
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
|
||||
"http://localhost:9090";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Prometheus</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics explorer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={prometheusUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Open Prometheus
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function SshTasksAddonPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">SSH tasks</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Saved actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create, edit, and run saved shell or Python tasks against local or
|
||||
remote machines.
|
||||
</p>
|
||||
<Button onClick={() => navigate("/actions")}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Open Actions
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { GrafanaAddonPage } from "./GrafanaAddonPage";
|
||||
export { PrometheusAddonPage } from "./PrometheusAddonPage";
|
||||
export { SshTasksAddonPage } from "./SshTasksAddonPage";
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
GrafanaAddonPage,
|
||||
PrometheusAddonPage,
|
||||
SshTasksAddonPage,
|
||||
} from "../addons";
|
||||
|
||||
const ADDON_PAGES: Record<string, React.ComponentType> = {
|
||||
grafana: GrafanaAddonPage,
|
||||
prometheus: PrometheusAddonPage,
|
||||
"ssh-tasks": SshTasksAddonPage,
|
||||
};
|
||||
|
||||
export function AddonPage() {
|
||||
const { addonId } = useParams<{ addonId: string }>();
|
||||
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
|
||||
|
||||
if (!Page) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return <Page />;
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
} from "../hooks/useServices";
|
||||
import { useServiceTypes } from "../hooks/useServices";
|
||||
import type {
|
||||
SecretFieldInfo,
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
|
||||
interface CreateDraft {
|
||||
serviceType: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function emptyDraft(serviceType: string): CreateDraft {
|
||||
return { serviceType, name: "", config: {}, secrets: {}, enabled: true };
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
helper,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
helper?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={htmlFor}>{label}</Label>
|
||||
{children}
|
||||
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceConfigFields({
|
||||
type,
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
type: ServiceTypeInfo;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const properties = (type.config_schema as { properties?: Record<string, { type?: string; description?: string }> }).properties ?? {};
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(properties).map(([key, schema]) => {
|
||||
const isNumber = schema.type === "integer" || schema.type === "number";
|
||||
return (
|
||||
<Field key={key} label={key} htmlFor={`cfg-${key}`} helper={schema.description}>
|
||||
<Input
|
||||
id={`cfg-${key}`}
|
||||
type={isNumber ? "number" : "text"}
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
[key]: isNumber
|
||||
? e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceSecretFields({
|
||||
fields,
|
||||
secrets,
|
||||
onChange,
|
||||
}: {
|
||||
fields: SecretFieldInfo[];
|
||||
secrets: Record<string, string>;
|
||||
onChange: (secrets: Record<string, string>) => void;
|
||||
}) {
|
||||
if (fields.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{fields.map((field) => (
|
||||
<Field
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
htmlFor={`secret-${field.key}`}
|
||||
helper={field.helper ?? (field.required ? "Required" : undefined)}
|
||||
>
|
||||
<Input
|
||||
id={`secret-${field.key}`}
|
||||
type="password"
|
||||
value={secrets[field.key] ?? ""}
|
||||
onChange={(e) => onChange({ ...secrets, [field.key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateServiceDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const saveService = useSaveServiceInstance();
|
||||
const [draft, setDraft] = useState<CreateDraft | null>(null);
|
||||
|
||||
function reset() {
|
||||
setDraft(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) return;
|
||||
const input: ServiceInstanceInput = {
|
||||
service_type: draft.serviceType,
|
||||
name: draft.name.trim(),
|
||||
config: draft.config,
|
||||
secrets: draft.secrets,
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
await saveService.mutateAsync(input);
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
const selectedType = types.find((t) => t.service_type === draft?.serviceType);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New service</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
{!draft ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{types.map((t) => (
|
||||
<Button
|
||||
key={t.service_type}
|
||||
variant="outline"
|
||||
onClick={() => setDraft(emptyDraft(t.service_type))}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
{t.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{selectedType?.description}</p>
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
{selectedType ? (
|
||||
<ServiceConfigFields
|
||||
type={selectedType}
|
||||
config={draft.config}
|
||||
onChange={(config) => setDraft({ ...draft, config })}
|
||||
/>
|
||||
) : null}
|
||||
{selectedType ? (
|
||||
<ServiceSecretFields
|
||||
fields={selectedType.secret_fields}
|
||||
secrets={draft.secrets}
|
||||
onChange={(secrets) => setDraft({ ...draft, secrets })}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => setDraft({ ...draft, enabled: checked })}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{draft ? (
|
||||
<DialogFooter
|
||||
onCancel={reset}
|
||||
onConfirm={save}
|
||||
confirmLabel="Create service"
|
||||
confirmDisabled={!draft.name.trim() || saveService.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ServiceInstance[]>();
|
||||
for (const s of services) {
|
||||
const list = map.get(s.service_type) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.service_type, list);
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [services]);
|
||||
|
||||
const typeName = (t: string) =>
|
||||
types.find((x) => x.service_type === t)?.name ?? getServiceBinding(t)?.name ?? t;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Services"
|
||||
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add service
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{services.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud, or SSH task runner.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{grouped.map(([serviceType, instances]) => (
|
||||
<div key={serviceType} className="flex flex-col gap-2">
|
||||
<div className="text-sm font-medium">{typeName(serviceType)}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{instances.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<Badge variant="outline">{s.service_type}</Badge>
|
||||
{!s.enabled ? <Badge variant="secondary">disabled</Badge> : null}
|
||||
{Object.entries(s.secrets_set).some(([, v]) => v) ? (
|
||||
<Badge variant="outline">secrets set</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/services/${s.service_type}/${s.id}`)}
|
||||
>
|
||||
Open <ExternalLink className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(s.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<CreateServiceDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteId)}
|
||||
title="Delete service?"
|
||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setDeleteId(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteId) deleteService.mutate(deleteId);
|
||||
setDeleteId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user