refactor: move service administration into settings
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user