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
|
||||
registry; every run is recorded in `service_task_runs` as history.
|
||||
|
||||
Multiple instances per service type are supported. Services are managed from the
|
||||
**Services** page (`/services`) and each instance has a detail page at
|
||||
`/services/:serviceType/:serviceId`.
|
||||
Multiple instances per service type are supported. Services are managed from
|
||||
**Settings → Services**, which provides a **List** subtab for creating and editing
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NavLink,
|
||||
useLocation,
|
||||
Outlet,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
QueryClient,
|
||||
@@ -19,7 +20,6 @@ import { NamedDashboardPage } from "./pages/NamedDashboardPage";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { ServicePage } from "./pages/ServicePage";
|
||||
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
||||
import { ServicesPage } from "./pages/ServicesPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Boxes,
|
||||
LayoutTemplate,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -112,7 +111,6 @@ function useNavItems() {
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
...dashboardEntries,
|
||||
...serviceEntries,
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
}, [services, dashboards]);
|
||||
@@ -469,7 +467,7 @@ function AppInner() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/services" element={<Navigate to="/settings?tab=services" replace />} />
|
||||
<Route
|
||||
path="/services/:serviceType"
|
||||
element={<ServiceTypePage />}
|
||||
@@ -497,7 +495,7 @@ function AppInner() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/services" element={<Navigate to="/settings?tab=services" replace />} />
|
||||
<Route
|
||||
path="/services/:serviceType"
|
||||
element={<ServiceTypePage />}
|
||||
|
||||
@@ -183,7 +183,7 @@ function ServiceSecretFields({
|
||||
);
|
||||
}
|
||||
|
||||
function CreateServiceDialog({
|
||||
export function CreateServiceDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
@@ -339,7 +339,10 @@ function CreateServiceDialog({
|
||||
) : null}
|
||||
{draft ? (
|
||||
<DialogFooter
|
||||
onCancel={reset}
|
||||
onCancel={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={save}
|
||||
confirmLabel="Create service"
|
||||
confirmDisabled={
|
||||
@@ -354,7 +357,7 @@ function CreateServiceDialog({
|
||||
|
||||
// --- Named dashboards management (Slice 10.3) ---
|
||||
|
||||
function DashboardManagementCard() {
|
||||
export function DashboardManagementCard() {
|
||||
const { data: dashboards = [] } = useDashboards();
|
||||
const saveDashboard = useSaveDashboard();
|
||||
const deleteDashboard = useDeleteDashboard();
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useTestMonitoringMachineSSH,
|
||||
} from "../hooks/useSettings";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { CreateServiceDialog, DashboardManagementCard } from "./ServicesPage";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
@@ -1353,6 +1354,8 @@ function ServicesAdminCard({
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
|
||||
const [serviceSubtab, setServiceSubtab] = useState<"list" | "dashboards">("list");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const sortedServices = useMemo(
|
||||
() =>
|
||||
@@ -1376,16 +1379,30 @@ function ServicesAdminCard({
|
||||
? types.find((t) => t.service_type === selectedService.service_type)
|
||||
: 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 (
|
||||
<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
|
||||
title="Services"
|
||||
description="Select a service to edit its configuration."
|
||||
@@ -1441,7 +1458,17 @@ function ServicesAdminCard({
|
||||
</p>
|
||||
)}
|
||||
</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() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboards", () => ({
|
||||
useDashboards: () => ({ data: [] }),
|
||||
useSaveDashboard: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeleteDashboard: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceTypes: () => ({
|
||||
data: [
|
||||
@@ -140,4 +146,20 @@ describe("Settings > Services editor", () => {
|
||||
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