refactor: move service administration into settings

This commit is contained in:
Developer
2026-07-14 15:47:54 +00:00
parent a9488af0b4
commit 70511d97f9
5 changed files with 73 additions and 21 deletions
+5 -3
View File
@@ -285,9 +285,11 @@ values missing an `http://` or `https://` schema with a clear validation error
passphrase; provides a task-output widget. Tasks stay in the global saved-task passphrase; provides a task-output widget. Tasks stay in the global saved-task
registry; every run is recorded in `service_task_runs` as history. registry; every run is recorded in `service_task_runs` as history.
Multiple instances per service type are supported. Services are managed from the Multiple instances per service type are supported. Services are managed from
**Services** page (`/services`) and each instance has a detail page at **Settings → Services**, which provides a **List** subtab for creating and editing
`/services/:serviceType/:serviceId`. instances and a **Dashboards** subtab for named dashboard management. Each
instance retains its operational detail page at `/services/:serviceType/:serviceId`;
legacy `/services` navigation redirects to Settings.
### Built-in widgets ### Built-in widgets
+3 -5
View File
@@ -5,6 +5,7 @@ import {
NavLink, NavLink,
useLocation, useLocation,
Outlet, Outlet,
Navigate,
} from "react-router-dom"; } from "react-router-dom";
import { import {
QueryClient, QueryClient,
@@ -19,7 +20,6 @@ import { NamedDashboardPage } from "./pages/NamedDashboardPage";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
import { ServicePage } from "./pages/ServicePage"; import { ServicePage } from "./pages/ServicePage";
import { ServiceTypePage } from "./pages/ServiceTypePage"; import { ServiceTypePage } from "./pages/ServiceTypePage";
import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client"; import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version"; import { FRONTEND_VERSION_LABEL } from "./version";
@@ -51,7 +51,6 @@ import {
LogOut, LogOut,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Boxes,
LayoutTemplate, LayoutTemplate,
} from "lucide-react"; } from "lucide-react";
@@ -112,7 +111,6 @@ function useNavItems() {
{ path: "/", label: "Dashboard", icon: LayoutDashboard }, { path: "/", label: "Dashboard", icon: LayoutDashboard },
...dashboardEntries, ...dashboardEntries,
...serviceEntries, ...serviceEntries,
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon }, { path: "/settings", label: "Settings", icon: SettingsIcon },
]; ];
}, [services, dashboards]); }, [services, dashboards]);
@@ -469,7 +467,7 @@ function AppInner() {
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} /> <Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<Navigate to="/settings?tab=services" replace />} />
<Route <Route
path="/services/:serviceType" path="/services/:serviceType"
element={<ServiceTypePage />} element={<ServiceTypePage />}
@@ -497,7 +495,7 @@ function AppInner() {
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} /> <Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<Navigate to="/settings?tab=services" replace />} />
<Route <Route
path="/services/:serviceType" path="/services/:serviceType"
element={<ServiceTypePage />} element={<ServiceTypePage />}
+6 -3
View File
@@ -183,7 +183,7 @@ function ServiceSecretFields({
); );
} }
function CreateServiceDialog({ export function CreateServiceDialog({
open, open,
onClose, onClose,
}: { }: {
@@ -339,7 +339,10 @@ function CreateServiceDialog({
) : null} ) : null}
{draft ? ( {draft ? (
<DialogFooter <DialogFooter
onCancel={reset} onCancel={() => {
reset();
onClose();
}}
onConfirm={save} onConfirm={save}
confirmLabel="Create service" confirmLabel="Create service"
confirmDisabled={ confirmDisabled={
@@ -354,7 +357,7 @@ function CreateServiceDialog({
// --- Named dashboards management (Slice 10.3) --- // --- Named dashboards management (Slice 10.3) ---
function DashboardManagementCard() { export function DashboardManagementCard() {
const { data: dashboards = [] } = useDashboards(); const { data: dashboards = [] } = useDashboards();
const saveDashboard = useSaveDashboard(); const saveDashboard = useSaveDashboard();
const deleteDashboard = useDeleteDashboard(); const deleteDashboard = useDeleteDashboard();
+37 -10
View File
@@ -19,6 +19,7 @@ import {
useTestMonitoringMachineSSH, useTestMonitoringMachineSSH,
} from "../hooks/useSettings"; } from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile"; import { useIsMobile } from "../hooks/useIsMobile";
import { CreateServiceDialog, DashboardManagementCard } from "./ServicesPage";
import { SheetForm } from "@/components/ui/sheet-form"; import { SheetForm } from "@/components/ui/sheet-form";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton"; import { HoverEditButton } from "../components/HoverEditButton";
@@ -1353,6 +1354,8 @@ function ServicesAdminCard({
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes(); const { data: types = [] } = useServiceTypes();
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId); const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
const [serviceSubtab, setServiceSubtab] = useState<"list" | "dashboards">("list");
const [createOpen, setCreateOpen] = useState(false);
const sortedServices = useMemo( const sortedServices = useMemo(
() => () =>
@@ -1376,16 +1379,30 @@ function ServicesAdminCard({
? types.find((t) => t.service_type === selectedService.service_type) ? types.find((t) => t.service_type === selectedService.service_type)
: undefined; : undefined;
if (sortedServices.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No service instances configured. Create one from the Services page.
</p>
);
}
return ( return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]"> <>
<TabbedCard
value={serviceSubtab}
onChange={(value) => setServiceSubtab(value as "list" | "dashboards")}
tabs={[
<TabsTrigger key="list" value="list">List</TabsTrigger>,
<TabsTrigger key="dashboards" value="dashboards">Dashboards</TabsTrigger>,
]}
contentSx={{}}
>
{serviceSubtab === "list" && (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button variant="outline" onClick={() => setCreateOpen(true)}>
Add service
</Button>
</div>
{sortedServices.length === 0 ? (
<p className="text-sm text-muted-foreground">
No service instances configured yet.
</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard <SelectionRailCard
title="Services" title="Services"
description="Select a service to edit its configuration." description="Select a service to edit its configuration."
@@ -1441,7 +1458,17 @@ function ServicesAdminCard({
</p> </p>
)} )}
</SectionCard> </SectionCard>
</div> </div>
)}
</div>
)}
{serviceSubtab === "dashboards" && <DashboardManagementCard />}
</TabbedCard>
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
</>
); );
} }
@@ -35,6 +35,12 @@ vi.mock("../../hooks/useSettings", () => ({
useDeleteSSHKey: () => ({ mutate: vi.fn() }), useDeleteSSHKey: () => ({ mutate: vi.fn() }),
})); }));
vi.mock("../../hooks/useDashboards", () => ({
useDashboards: () => ({ data: [] }),
useSaveDashboard: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteDashboard: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../../hooks/useServices", () => ({ vi.mock("../../hooks/useServices", () => ({
useServiceTypes: () => ({ useServiceTypes: () => ({
data: [ data: [
@@ -140,4 +146,20 @@ describe("Settings > Services editor", () => {
timeout_seconds: 60, timeout_seconds: 60,
}); });
}); });
it("offers service creation and dashboard management as Services subtabs", async () => {
render(
<MemoryRouter initialEntries={["/settings?tab=services"]}>
<Settings />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Add service" }));
expect(screen.getByText("New service")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
expect(screen.getByRole("heading", { name: "Dashboards" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "New dashboard" })).toBeInTheDocument();
});
}); });