From 26ccff643e6ad524fdf2171395db6792e0f4b3bd Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 14:02:19 +0100 Subject: [PATCH 1/4] feat(admin): compact inventories and scope platform settings --- .../(full-admin)/system/backups/page.tsx | 9 +- .../(full-admin)/system/deployment/page.tsx | 19 +- .../(full-admin)/system/updates/page.tsx | 11 +- .../admin/AdminLayout.navigation.test.tsx | 67 ++ apps/web/src/components/admin/AdminLayout.tsx | 181 +++-- apps/web/src/components/admin/AdminTopBar.tsx | 4 + .../admin/integrations/IntegrationDetail.tsx | 87 +-- .../admin/overview/AdminOverview.tsx | 428 ++++++------ .../admin/services/BackupPruneControls.tsx | 2 +- .../admin/services/ComposePreview.tsx | 3 +- .../services/ServiceAdministration.test.tsx | 124 +++- .../admin/services/ServiceBulkActions.tsx | 261 ++++++++ .../admin/services/ServiceCatalog.tsx | 623 +++--------------- .../admin/services/ServiceDetail.test.tsx | 5 +- .../admin/services/ServiceDetail.tsx | 294 +++------ .../admin/services/ServiceLogsDrawer.tsx | 87 ++- .../admin/services/ServiceSelectionEditor.tsx | 232 +++++++ .../admin/services/ServiceSessions.test.tsx | 26 +- .../__tests__/BackupPruneControls.test.tsx | 2 +- .../settings/AuthPolicySettings.test.tsx | 23 + .../admin/settings/AuthPolicySettings.tsx | 58 +- .../settings/EmailSettingsSection.test.tsx | 88 +++ .../admin/settings/EmailSettingsSection.tsx | 88 +++ .../settings/PlatformConfiguration.test.tsx | 93 ++- .../admin/settings/PlatformConfiguration.tsx | 384 ++++++++--- .../admin/settings/SettingsWorkspace.test.tsx | 2 +- .../admin/settings/SettingsWorkspace.tsx | 13 +- .../admin/settings/SystemSettings.tsx | 381 ++++++----- .../admin/settings/platformScope.test.ts | 86 +++ .../admin/settings/platformScope.ts | 50 ++ .../admin/shared/AdminNavigationContext.tsx | 9 + .../admin/shared/AdminPageHeader.tsx | 11 +- .../admin/shared/AdminThemeProvider.tsx | 48 +- .../admin/shared/AdminTopicNavigation.tsx | 7 +- .../src/components/admin/shared/MetaRow.tsx | 11 +- .../admin/system/SystemMaintenance.tsx | 40 +- apps/web/src/lib/admin/adminSearch.test.ts | 4 +- apps/web/src/lib/admin/adminSearch.ts | 4 +- docs/docs/administration/admin-panel.md | 19 +- docs/docs/administration/monitoring.md | 2 +- .../administration/services-administration.md | 36 +- packages/i18n/locales/de.json | 4 + packages/i18n/locales/en.json | 4 + 43 files changed, 2471 insertions(+), 1459 deletions(-) create mode 100644 apps/web/src/components/admin/services/ServiceBulkActions.tsx create mode 100644 apps/web/src/components/admin/services/ServiceSelectionEditor.tsx create mode 100644 apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx create mode 100644 apps/web/src/components/admin/settings/EmailSettingsSection.tsx create mode 100644 apps/web/src/components/admin/settings/platformScope.test.ts create mode 100644 apps/web/src/components/admin/settings/platformScope.ts create mode 100644 apps/web/src/components/admin/shared/AdminNavigationContext.tsx diff --git a/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx b/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx index 126b161c7..c538ee1d0 100644 --- a/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/system/backups/page.tsx @@ -1,6 +1,13 @@ +import Stack from "@mui/material/Stack"; import type { Metadata } from "next"; import { BackupsPage } from "@/components/admin/services/BackupsPage"; +import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration"; export const metadata: Metadata = { title: "Backups — Admin — OpenMapX" }; export default function Page() { - return ; + return ( + + + + + ); } diff --git a/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx b/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx index 5fd81e44a..b9fa3bd19 100644 --- a/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/system/deployment/page.tsx @@ -1,11 +1,26 @@ +import Accordion from "@mui/material/Accordion"; +import AccordionDetails from "@mui/material/AccordionDetails"; +import AccordionSummary from "@mui/material/AccordionSummary"; import Stack from "@mui/material/Stack"; import { ComposePreview } from "@/components/admin/services/ComposePreview"; +import { ServiceSelectionEditor } from "@/components/admin/services/ServiceSelectionEditor"; import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration"; +import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader"; export default function Page() { return ( - - + + + + + Compose and host recovery + + + + ); } diff --git a/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx b/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx index 67f344009..4253c379c 100644 --- a/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/system/updates/page.tsx @@ -1,4 +1,13 @@ +import Stack from "@mui/material/Stack"; +import { PlatformConfiguration } from "@/components/admin/settings/PlatformConfiguration"; +import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader"; import { SystemMaintenance } from "@/components/admin/system/SystemMaintenance"; export default function Page() { - return ; + return ( + + + + + + ); } diff --git a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx index 81955218a..66af255de 100644 --- a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx +++ b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx @@ -1,8 +1,11 @@ import { en } from "@openmapx/i18n"; import { NextIntlClientProvider } from "next-intl"; +import { useContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, expect, it, vi } from "vitest"; +import { AdminDraftContext, AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; import { act, cleanup, createQueryWrapper, fireEvent, render, screen } from "@/test"; +import { adminTestHistory } from "@/test/adminNavigation"; import { AdminLayout } from "./AdminLayout"; vi.mock("@/integration-api/runtime/EnvProvider", () => ({ @@ -16,6 +19,70 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); +it.each([false, null])("guards direct host-topic entry for deployment %s", (selfHosted) => { + adminTestHistory.reset("/admin/system/deployment"); + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + })); + render( + + + + + , + { wrapper: createQueryWrapper() }, + ); + if (selfHosted === false) { + expect(screen.queryByRole("button", { name: "Host mutation" })).toBeNull(); + expect(screen.getAllByRole("link", { name: "Cache" }).length).toBeGreaterThan(0); + } else expect(screen.getByRole("button", { name: "Host mutation" })).toBeInTheDocument(); +}); +it("retires the admin view and navigation after a confirmed authority change", async () => { + function Child() { + const store = useContext(AdminDraftContext); + return ( + + ); + } + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + })); + render( + + + + + + + , + { wrapper: createQueryWrapper() }, + ); + fireEvent.click(await screen.findByRole("button", { name: "Change authority" })); + expect(screen.queryByRole("button", { name: "Change authority" })).toBeNull(); + expect(screen.queryByRole("link", { name: "System" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Administration jobs" })).toBeNull(); + expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); +}); it("keeps the desktop sidebar preference independent of mobile drawer navigation", () => { let wide = true; const listeners = new Set<() => void>(); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index 703773b5d..9ec9ea2a4 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -1,18 +1,25 @@ "use client"; +import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; +import Stack from "@mui/material/Stack"; import useMediaQuery from "@mui/material/useMediaQuery"; +import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback, useState } from "react"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; -import { useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider"; +import { useAdminDraftAuthority, useBindAdminDraftOwner } from "@/lib/admin/AdminDraftProvider"; import { AdminJobEffectsProvider } from "@/lib/admin/adminJobEffects"; -import { adminWorkspaceForPath } from "@/lib/admin/adminNavigation"; +import { adminDestinationForPath, adminWorkspaceForPath } from "@/lib/admin/adminNavigation"; import { useHydrated } from "@/lib/useHydrated"; import { AdminSidebar } from "./AdminSidebar"; import { AdminTopBar } from "./AdminTopBar"; import { TOPBAR_HEIGHT } from "./layoutConstants"; +import { AdminNavigationContext } from "./shared/AdminNavigationContext"; +import { AdminPageHeader } from "./shared/AdminPageHeader"; import { AdminThemeProvider } from "./shared/AdminThemeProvider"; import { AdminToastProvider } from "./shared/AdminToast"; import { AdminTopicNavigation } from "./shared/AdminTopicNavigation"; @@ -41,8 +48,19 @@ export function AdminLayout(props: AdminLayoutProps) { } function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutProps) { const { apiUrl } = useEnv(); - const workspace = adminWorkspaceForPath(usePathname()); + const pathname = usePathname(); + const workspace = adminWorkspaceForPath(pathname); + const destination = adminDestinationForPath(pathname); + const hostUnavailable = destination?.hostOnly && selfHosted === false; + const t = useTranslations("adminNavigation"); useBindAdminDraftOwner({ apiUrl, actorId: user.id, role: user.role ?? "admin" }); + const authority = useAdminDraftAuthority(); + const authorized = + !authority.present || + (authority.owner?.apiUrl === apiUrl && + authority.owner?.actorId === user.id && + authority.owner?.role === (user.role ?? "admin")); + const visibleRole = authorized ? (user.role ?? "admin") : ""; const hydrated = useHydrated(); const desktop = useMediaQuery("(min-width:900px)", { defaultMatches: true }); const [desktopOpen, setDesktopOpen] = useState(true); @@ -54,83 +72,116 @@ function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutP }, [desktop]); return ( - - - - - setMobileOpen(false)} - selfHosted={selfHosted} - role={user.role} - /> + + + + - setMobileOpen(false)} selfHosted={selfHosted} - sidebarOpen={sidebarOpen} - onToggleSidebar={toggleSidebar} + role={visibleRole} /> + - {hydrated ? ( - - {workspace && ( - - )} - {children} - - ) : ( - - - - )} + + {hydrated ? ( + + {workspace && ( + + )} + + {!authorized ? ( + window.location.reload()}> + {t("reload")} + + } + > + {t("authorityChanged")} + + ) : hostUnavailable && destination ? ( + + + {t("hostUnavailable")} + + + + + + ) : ( + children + )} + + + ) : ( + + + + )} + - - - - + + + + ); } diff --git a/apps/web/src/components/admin/AdminTopBar.tsx b/apps/web/src/components/admin/AdminTopBar.tsx index f48a12318..2042ce936 100644 --- a/apps/web/src/components/admin/AdminTopBar.tsx +++ b/apps/web/src/components/admin/AdminTopBar.tsx @@ -92,6 +92,10 @@ export function AdminTopBar({ user, selfHosted, sidebarOpen, onToggleSidebar }: flex: 1, minWidth: 0, "& .MuiBreadcrumbs-ol": { flexWrap: "nowrap" }, + "& .MuiBreadcrumbs-li": { minWidth: 0 }, + "& .MuiBreadcrumbs-li:not(:last-child), & .MuiBreadcrumbs-separator": { + display: { xs: "none", sm: "block" }, + }, }} > {breadcrumbs.map((crumb, i) => { diff --git a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx index ba8c496c7..aad25a966 100644 --- a/apps/web/src/components/admin/integrations/IntegrationDetail.tsx +++ b/apps/web/src/components/admin/integrations/IntegrationDetail.tsx @@ -9,6 +9,9 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew"; import RefreshIcon from "@mui/icons-material/Refresh"; +import Accordion from "@mui/material/Accordion"; +import AccordionDetails from "@mui/material/AccordionDetails"; +import AccordionSummary from "@mui/material/AccordionSummary"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; @@ -164,27 +167,6 @@ function OverviewTab({ data }: { data: IntegrationDetailData }) { ))} - {data.description && ( - - - Description - - {data.description} - - )} @@ -347,18 +329,32 @@ function ConfigTab({ gap: 3, }} > + {/* Editable config form */} + {data.manifest.configSchema && ( + + + + Edit Configuration + + + + + )} {/* Raw resolved config table */} - - - - Resolved Values - + + Effective values and sources + {entries.length === 0 ? ( No configuration keys found. @@ -414,30 +410,8 @@ function ConfigTab({ )} - - - {/* Editable config form */} - {data.manifest.configSchema && ( - - - - Edit Configuration - - - - - )} + + ); } @@ -1147,6 +1121,7 @@ export function IntegrationDetail({ id }: IntegrationDetailProps) { size={14} /> - {/* Summary cards */} - - - } href="/status"> - - {data.systemHealth.status === "pass" ? "OK" : data.systemHealth.unhealthyCount} - - - {data.systemHealth.status === "pass" - ? "All systems operational" - : `${data.systemHealth.unhealthyCount} unhealthy`} - - - - - - } href="/admin/users"> - - {data.users.total} - - - - {data.users.banned > 0 && ( - - )} - - - - - - } href="/admin/integrations"> - - {data.integrations.total} - - - - {data.integrations.unhealthy > 0 && ( - - )} - {data.integrations.unconfigured > 0 && ( - - )} - - - - - - {data.services ? ( - } href="/admin/system/services"> - - {data.services.running} - - - - - {data.services.unhealthy > 0 && ( - - )} - - - ) : ( - } href="/status"> - - N/A - - - Cloud deployment - - - )} - - - - } href="/admin/integrations"> - 0 ? "warning.main" : "success.main"} - sx={{ - fontWeight: 700, - }} - > - {totalMissingCredentials} - - - - 0 - ? `${missingCredentialIntegrations.length} missing` - : "all configured" - } - size="small" - color={totalMissingCredentials > 0 ? "warning" : "success"} - variant="outlined" - /> - - - - {/* Attention list */} {generalAttention.length > 0 && ( @@ -620,7 +412,13 @@ export function AdminOverview() { variant="outlined" sx={{ fontSize: 10, height: 18, minWidth: 60 }} /> - + {job.type} {job.status === "running" && job.progress != null && ( @@ -648,6 +446,216 @@ export function AdminOverview() { + + {/* Summary cards */} + + + } href="/status"> + + {data.systemHealth.status === "pass" ? "OK" : data.systemHealth.unhealthyCount} + + + {data.systemHealth.status === "pass" + ? "All systems operational" + : `${data.systemHealth.unhealthyCount} unhealthy`} + + + + + + } href="/admin/users"> + + {data.users.total} + + + + {data.users.banned > 0 && ( + + )} + + + + + + } href="/admin/integrations"> + + {data.integrations.total} + + + + {data.integrations.unhealthy > 0 && ( + + )} + {data.integrations.unconfigured > 0 && ( + + )} + + + + + + {data.services ? ( + } href="/admin/system/services"> + + {data.services.running} + + + + + {data.services.unhealthy > 0 && ( + + )} + + + ) : ( + } href="/status"> + + N/A + + + Cloud deployment + + + )} + + + + } href="/admin/integrations"> + 0 ? "warning.main" : "success.main"} + sx={{ + fontWeight: 700, + }} + > + {totalMissingCredentials} + + + + 0 + ? `${missingCredentialIntegrations.length} missing` + : "all configured" + } + size="small" + color={totalMissingCredentials > 0 ? "warning" : "success"} + variant="outlined" + /> + + + + ); } diff --git a/apps/web/src/components/admin/services/BackupPruneControls.tsx b/apps/web/src/components/admin/services/BackupPruneControls.tsx index 9f3ba64b2..15c567d74 100644 --- a/apps/web/src/components/admin/services/BackupPruneControls.tsx +++ b/apps/web/src/components/admin/services/BackupPruneControls.tsx @@ -84,7 +84,7 @@ export function BackupPruneControls() { > Preview expired backups - diff --git a/apps/web/src/components/admin/services/ComposePreview.tsx b/apps/web/src/components/admin/services/ComposePreview.tsx index ee104fa43..47379351f 100644 --- a/apps/web/src/components/admin/services/ComposePreview.tsx +++ b/apps/web/src/components/admin/services/ComposePreview.tsx @@ -123,7 +123,8 @@ function ComposePreviewView({ client, apiUrl }: ReturnType setRenderIntent(null)} /> (await import("@/test/adminNavigation")).adminNavigationMock, +); + +import { ServiceSelectionEditor } from "./ServiceSelectionEditor"; + +async function chooseAction(label: string) { + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Action" })); + fireEvent.click(await screen.findByRole("option", { name: label })); +} const availableActions = Object.fromEntries( SERVICE_ACTIONS.map((action) => [ action, { available: true, execution: "direct", reason: null, workflow: null }, ]), ); +it("starts the service inventory with rows and keeps persistent roots on Deployment", async () => { + setup(); + mount(); + expect(await screen.findByRole("link", { name: "Demo" })).toBeVisible(); + expect(screen.queryByLabelText("Selected root service IDs")).toBeNull(); + expect(screen.queryByText("Bulk Actions")).toBeNull(); + expect(screen.getByRole("link", { name: "Deployment" })).toHaveAttribute( + "href", + "/admin/system/deployment", + ); +}); +it("mounts the inline log pane from an addressable tab and disposes it on a tab switch", async () => { + setup(); + adminTestHistory.reset("/admin/system/services/demo?tab=logs"); + mount(); + expect(await screen.findByText("Inline service logs")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Logs" })).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByRole("tab", { name: "Overview" })); + expect(screen.queryByText("Inline service logs")).toBeNull(); +}); +it("does not expand reviewed Build All targets when the inventory refreshes", async () => { + let expanded = false; + const service = { + id: "demo", + name: "Demo", + version: "1", + quality: "built-in", + provides: [], + consumes: [], + enabled: true, + isBuiltIn: true, + status: "running", + actions: availableActions, + }; + const requests = setup((url, init) => + url.pathname === "/api/admin/services" + ? Response.json({ + acquisition: { state: "available", reason: null }, + services: expanded ? [service, { ...service, id: "late", name: "Late" }] : [service], + capabilityInventory: { recognized: [], unrecognized: [] }, + summary: { running: expanded ? 2 : 1, stopped: 0, unknown: 0, total: expanded ? 2 : 1 }, + }) + : url.pathname.endsWith("/bulk-action") && init.method === "POST" + ? Response.json({ ok: true, jobId: "job-1" }) + : undefined, + ); + const { qc } = mount(); + await screen.findByRole("link", { name: "Demo" }); + fireEvent.click(screen.getByRole("button", { name: "Build All" })); + await screen.findByRole("button", { name: "Build reviewed targets" }); + expanded = true; + await act(() => qc.invalidateQueries({ queryKey: ["admin", "services", apiUrl, "list"] })); + fireEvent.click(screen.getByRole("button", { name: "Build reviewed targets" })); + await waitFor(() => + expect(requests.some((request) => request.url.pathname.endsWith("/bulk-action"))).toBe(true), + ); + const request = requests.find((entry) => entry.url.pathname.endsWith("/bulk-action")); + expect(JSON.parse(String(request?.init.body))).toEqual({ + action: "build", + serviceIds: ["demo"], + continueOnError: true, + }); +}); it("confirms immutable image staging and reuses the request key after a lost response", async () => { let attempts = 0; @@ -31,6 +106,7 @@ it("confirms immutable image staging and reuses the request key after a lost res mount(); await screen.findByRole("link", { name: "Demo" }); fireEvent.click(screen.getByRole("checkbox", { name: "Select demo" })); + await chooseAction("Stage selected images"); fireEvent.click(screen.getByRole("button", { name: "Stage selected images" })); expect( await screen.findByText(/Configured tags and running containers stay unchanged/), @@ -58,7 +134,10 @@ const toast = vi.fn(); vi.mock("@/integration-api/runtime/EnvProvider", () => ({ useEnv: () => ({ apiUrl }) })); vi.mock("../shared/AdminToast", () => ({ useAdminToast: () => toast })); vi.mock("./ManagedDawarichSetup", () => ({ ManagedDawarichSetup: () => null })); -vi.mock("./ServiceLogsDrawer", () => ({ ServiceLogsDrawer: () => null })); +vi.mock("./ServiceLogsDrawer", () => ({ + ServiceLogsDrawer: () => null, + ServiceLogsPanel: () =>
Inline service logs
, +})); const caches: ReturnType[] = []; function pending() { let resolve!: (v: T) => void; @@ -363,12 +442,11 @@ describe("service administration visible behavior with the generated SDK", () => ? Response.json({ ...selection, source: "env", envVarValue: "demo" }) : undefined, ); - mount(); + mount(); expect(await screen.findByText("Requested: 1")).toBeInTheDocument(); expect(screen.getByText("Effective: 2")).toBeInTheDocument(); expect(screen.getByText("Synthetic warning")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save Selection" })).toBeDisabled(); - expect(await screen.findByText("routing")).toBeInTheDocument(); }); it("fences a catalog health completion toast after origin replacement", async () => { const deferred = pending(); @@ -637,22 +715,24 @@ describe("service administration visible behavior with the generated SDK", () => fireEvent.click(screen.getByRole("button", { name: "Start Selected" })); await waitFor(() => expect(toast).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole("button", { name: "Build All" })); + expect(requests.filter((r) => r.url.pathname.endsWith("/bulk-action"))).toHaveLength(1); + fireEvent.click(await screen.findByRole("button", { name: "Build reviewed targets" })); await waitFor(() => expect(toast).toHaveBeenCalledTimes(2)); const bodies = requests .filter((r) => r.url.pathname.endsWith("/bulk-action")) .map((r) => JSON.parse(String(r.init.body))); expect(bodies).toEqual([ { action: "start", serviceIds: ["demo"] }, - { action: "build", all: true, continueOnError: true }, + { action: "build", serviceIds: ["demo"], continueOnError: true }, ]); - expect(screen.getByText("0 running")).toBeInTheDocument(); + expect(screen.getByText(/1 total · 0 running · 1 stopped/)).toBeInTheDocument(); revision = 2; await act(() => qc.invalidateQueries({ queryKey: ["admin", "jobs", "detail", apiUrl, "application", "job-1"], }), ); - expect(await screen.findByText("1 running")).toBeInTheDocument(); + expect(await screen.findByText(/1 total · 1 running · 0 stopped/)).toBeInTheDocument(); revision = 3; await act(() => qc.invalidateQueries({ @@ -660,7 +740,7 @@ describe("service administration visible behavior with the generated SDK", () => }), ); expect(await screen.findByText("3.0.0")).toBeInTheDocument(); - expect(screen.getByText("0 running")).toBeInTheDocument(); + expect(screen.getByText(/1 total · 0 running · 1 stopped/)).toBeInTheDocument(); }); it("saves deliberate empty selection and refreshes its full server summary", async () => { let cleared = false; @@ -679,7 +759,7 @@ describe("service administration visible behavior with the generated SDK", () => } return Response.json(cleared ? summary : selection); }); - mount(); + mount(); await screen.findByDisplayValue("demo"); fireEvent.change(screen.getByLabelText("Selected root service IDs"), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "Save Selection" })); @@ -711,8 +791,15 @@ it("uses capabilities for deselected running service cleanup", async () => { ); mount(); expect(await screen.findByRole("button", { name: "Apply changes" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Stop" })).not.toBeDisabled(); - expect(screen.getByRole("button", { name: "Logs" })).not.toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "More actions" })); + expect(screen.getByRole("menuitem", { name: "Stop" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + expect(screen.getByRole("menuitem", { name: "Logs" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); }); it("disables unavailable controls and keeps outage guidance visible", async () => { const actions = Object.fromEntries( @@ -735,8 +822,12 @@ it("disables unavailable controls and keeps outage guidance visible", async () = : undefined, ); mount(); - expect(await screen.findByRole("button", { name: "Restart" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Logs" })).toBeDisabled(); + fireEvent.click(await screen.findByRole("button", { name: "More actions" })); + expect(screen.getByRole("menuitem", { name: /Restart/ })).toHaveAttribute( + "aria-disabled", + "true", + ); + expect(screen.getByRole("menuitem", { name: /Logs/ })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText(/Operations agent is unavailable/)).toBeInTheDocument(); }); @@ -783,6 +874,7 @@ it("disables a mixed bulk action and lists ineligible IDs", async () => { mount(); await screen.findByRole("link", { name: "Demo" }); fireEvent.click(screen.getByRole("checkbox", { name: "Select visible services" })); + await chooseAction("Restart Selected"); const button = screen.getByRole("button", { name: "Restart Selected" }); expect(button).toBeDisabled(); expect(button).toHaveAttribute("title", expect.stringContaining("app-api")); @@ -861,7 +953,8 @@ it("removes only an explicit root through the existing selection producer", asyn }) : undefined, ); - mount(); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Services and root controls" })); await screen.findByRole("link", { name: "Demo" }); fireEvent.click(screen.getByRole("button", { name: "Remove demo root" })); await waitFor(() => @@ -912,6 +1005,7 @@ it("sends update dependency options and MOTIS import only on their corresponding mount(); await screen.findByRole("link", { name: "MOTIS" }); fireEvent.click(screen.getByRole("checkbox", { name: "Select motis" })); + await chooseAction("Update selected"); fireEvent.click(screen.getByRole("checkbox", { name: "Update without dependency recreation" })); fireEvent.click(screen.getByRole("button", { name: "Update selected" })); await waitFor(() => @@ -919,6 +1013,7 @@ it("sends update dependency options and MOTIS import only on their corresponding requests.filter((request) => request.url.pathname.endsWith("/bulk-action")), ).toHaveLength(1), ); + await chooseAction("Build Selected"); fireEvent.click(screen.getByRole("checkbox", { name: "Import MOTIS after build" })); fireEvent.click(screen.getByRole("button", { name: "Build Selected" })); await waitFor(() => @@ -992,7 +1087,8 @@ it.each(["file", "env"])( }); return undefined; }); - mount(); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Services and root controls" })); await screen.findByRole("link", { name: "Dependency" }); expect(screen.getByRole("button", { name: "Required dependency" })).toBeDisabled(); const enable = screen.getByRole("button", { name: "Enable valhalla" }); diff --git a/apps/web/src/components/admin/services/ServiceBulkActions.tsx b/apps/web/src/components/admin/services/ServiceBulkActions.tsx new file mode 100644 index 000000000..2f2486cdb --- /dev/null +++ b/apps/web/src/components/admin/services/ServiceBulkActions.tsx @@ -0,0 +1,261 @@ +"use client"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import type { AdminServiceSummary, AdminServicesBulkAction } from "@openmapx/core/api-contracts"; +import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; +import { serviceActionReason } from "@/lib/admin/serviceAdmin"; + +type Action = AdminServicesBulkAction["action"] | "stage"; +const LABELS: Record = { + start: "Start Selected", + stop: "Stop Selected", + restart: "Restart Selected", + update: "Update selected", + build: "Build Selected", + stage: "Stage selected images", +}; +export function ServiceBulkActions({ + services, + selectedIds, + busy, + onQueue, + onStage, + onClear, +}: { + services: AdminServiceSummary[]; + selectedIds: ReadonlySet; + busy: boolean; + onQueue: (payload: AdminServicesBulkAction) => void; + onStage: (ids: string[]) => void; + onClear: () => void; +}) { + const [action, setAction] = useState("start"); + const [region, setRegion] = useState(""); + const [continueOnError, setContinueOnError] = useState(true); + const [noDeps, setNoDeps] = useState(false); + const [motisImport, setMotisImport] = useState(false); + const ids = [...selectedIds]; + const denied = ids.filter((id) => { + const decision = services.find((service) => service.id === id)?.actions?.[ + action === "stage" ? "pull" : action + ]; + return Boolean(serviceActionReason(decision)) || decision?.workflow === "core-restart"; + }); + const reason = denied.length + ? `Unavailable for: ${denied.join(", ")}. Use individual core Restart where available.` + : action === "build" && motisImport && !selectedIds.has("motis") + ? "MOTIS import requires selecting MOTIS" + : action === "stage" && ids.length > 64 + ? "Stage at most 64 services at once" + : undefined; + function submit() { + if (reason || !ids.length || busy) return; + if (action === "stage") { + onStage([...ids]); + return; + } + onQueue( + action === "build" + ? { + action, + serviceIds: [...ids], + region: region.trim() || undefined, + continueOnError, + ...(motisImport ? { motisImport: true } : {}), + } + : action === "update" + ? { action, serviceIds: [...ids], noDeps } + : { action, serviceIds: [...ids] }, + ); + } + return ( + + + + + {ids.length} selected + + setAction(event.target.value as Action)} + sx={{ minWidth: 175 }} + > + {(Object.keys(LABELS) as Action[]).map((value) => ( + + {LABELS[value]} + + ))} + + + + + {action === "build" && ( + + )} + {action === "update" && ( + setNoDeps(event.target.checked)} + /> + } + label="Update without dependency recreation" + /> + )} + + {reason ?? + `Targets: ${ids.join(", ")}. These are action targets; persistent roots stay unchanged.`} + + + + ); +} +function BuildOptions({ + region, + setRegion, + continueOnError, + setContinueOnError, + motisImport, + setMotisImport, + disabled, +}: { + region: string; + setRegion: (value: string) => void; + continueOnError: boolean; + setContinueOnError: (value: boolean) => void; + motisImport: boolean; + setMotisImport: (value: boolean) => void; + disabled: boolean; +}) { + return ( + + setRegion(event.target.value)} + sx={{ width: { xs: "100%", sm: 220 } }} + /> + setContinueOnError(event.target.checked)} + /> + } + label="Continue on build errors" + /> + setMotisImport(event.target.checked)} + /> + } + label="Import MOTIS after build" + /> + + ); +} +/** Target IDs are captured once at opening; list/filter refreshes cannot expand them. */ +export function ServiceBuildAllDialog({ + ids, + busy, + onQueue, + onClose, +}: { + ids: string[]; + busy: boolean; + onQueue: (payload: AdminServicesBulkAction) => Promise; + onClose: () => void; +}) { + const [region, setRegion] = useState(""); + const [continueOnError, setContinueOnError] = useState(true); + const [motisImport, setMotisImport] = useState(false); + return ( + + Build all enabled services + + + + Build these {ids.length} captured targets: {ids.join(", ")}. The server enforces each + service's build prerequisites. Newly enabled services will not be added to this request. + + + + + + + + + + ); +} diff --git a/apps/web/src/components/admin/services/ServiceCatalog.tsx b/apps/web/src/components/admin/services/ServiceCatalog.tsx index 110739019..78408176d 100644 --- a/apps/web/src/components/admin/services/ServiceCatalog.tsx +++ b/apps/web/src/components/admin/services/ServiceCatalog.tsx @@ -1,21 +1,16 @@ "use client"; import SearchIcon from "@mui/icons-material/Search"; -import StorageIcon from "@mui/icons-material/Storage"; -import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import Chip from "@mui/material/Chip"; -import CircularProgress from "@mui/material/CircularProgress"; import FormControl from "@mui/material/FormControl"; -import FormControlLabel from "@mui/material/FormControlLabel"; import InputAdornment from "@mui/material/InputAdornment"; import MenuItem from "@mui/material/MenuItem"; import Paper from "@mui/material/Paper"; import Select from "@mui/material/Select"; import Skeleton from "@mui/material/Skeleton"; import Stack from "@mui/material/Stack"; -import Switch from "@mui/material/Switch"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -29,20 +24,18 @@ import { listAdminServicePresets, unwrapAdminResponse } from "@openmapx/core/api import type { AdminServiceSummary, AdminServicesBulkAction } from "@openmapx/core/api-contracts"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useServiceHealthCheck, useServiceImageStage, - useServiceSelection, - useServiceSelectionSave, useServicesBulkAction, useServicesList, } from "@/hooks/useServices"; import { adminJobHref } from "@/lib/admin/adminJobNavigation"; -import { serviceActionReason } from "@/lib/admin/serviceAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; import { useActivityClient } from "../activity/activityQueries"; import { StatusBadge } from "../integrations/StatusBadge"; +import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { AdminTableSurface } from "../shared/AdminTableSurface"; import { useAdminToast } from "../shared/AdminToast"; @@ -51,10 +44,10 @@ import { ConfirmDialog } from "../shared/ConfirmDialog"; import { ServiceStatusChip } from "../shared/ServiceStatusChip"; import { TableEmptyState } from "../shared/TableEmptyState"; import { useClientPagination } from "../shared/tableHooks"; +import { ServiceBuildAllDialog, ServiceBulkActions } from "./ServiceBulkActions"; type QualityFilter = "all" | AdminServiceSummary["quality"]; -type BulkAction = AdminServicesBulkAction["action"]; const emptyServices: AdminServiceSummary[] = []; function ProvidesCell({ provides }: { provides: string[] }) { @@ -161,13 +154,6 @@ function filterServices( }); } -function parseRootInput(raw: string): string[] { - return raw - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0); -} - export function ServiceCatalog() { const { apiUrl } = useActivityClient(); return ; @@ -183,13 +169,10 @@ function ServiceCatalogView() { const [search, setSearch] = useState(""); const [quality, setQuality] = useState("all"); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [buildRegion, setBuildRegion] = useState(""); - const [buildContinueOnError, setBuildContinueOnError] = useState(true); const [enabledFilter, setEnabledFilter] = useState<"all" | "enabled" | "disabled">("all"); const [capability, setCapability] = useState(""); const [preset, setPreset] = useState(""); - const [noDeps, setNoDeps] = useState(false); - const [motisImport, setMotisImport] = useState(false); + const [buildAllTargets, setBuildAllTargets] = useState(null); const [stageIntent, setStageIntent] = useState<{ serviceIds: string[]; idempotencyKey: string; @@ -200,15 +183,6 @@ function ServiceCatalogView() { retry: false, }); - const selectionQuery = useServiceSelection(); - - const [selectionDraft, setSelectionDraft] = useState(""); - - useEffect(() => { - const roots = selectionQuery.data?.selectedRoots ?? []; - setSelectionDraft(roots.join(", ")); - }, [selectionQuery.data?.selectedRoots]); - const services = data?.services ?? emptyServices; const summary = data?.summary; const filtered = useMemo( @@ -217,17 +191,6 @@ function ServiceCatalogView() { ); const { paged, paginationProps } = useClientPagination(filtered); - useEffect(() => { - setSelectedIds((prev) => { - const validIds = new Set(services.map((service) => service.id)); - const next = new Set(); - for (const id of prev) { - if (validIds.has(id)) next.add(id); - } - return next; - }); - }, [services]); - const allVisibleSelected = paged.length > 0 && paged.every((svc) => selectedIds.has(svc.id)); const toggleSelected = (id: string) => { @@ -253,19 +216,7 @@ function ServiceCatalogView() { }); }; - const deniedIds = (action: BulkAction) => - [...selectedIds].filter((id) => { - const decision = services.find((service) => service.id === id)?.actions?.[action]; - return Boolean(serviceActionReason(decision)) || decision?.workflow === "core-restart"; - }); - const bulkReason = (action: BulkAction) => { - const denied = deniedIds(action); - return denied.length - ? `Unavailable for: ${denied.join(", ")}. Use individual core Restart where available.` - : undefined; - }; const healthCheckMutation = useServiceHealthCheck(); - const saveSelectionMutation = useServiceSelectionSave(); const bulkMutation = useServicesBulkAction(); const stageMutation = useServiceImageStage(); async function stageImages() { @@ -294,22 +245,6 @@ function ServiceCatalogView() { showToast(error instanceof Error ? error.message : "Check failed", "error"); } } - async function persistRoots(roots: string[]) { - const execution = owner; - try { - const result = await saveSelectionMutation.mutateAsync(roots); - if (execution.active) - showToast( - "jobId" in result - ? `Queued service selection (${result.jobId})` - : "Service selection saved", - ); - } catch (error) { - if (execution.active) - showToast(error instanceof Error ? error.message : "Save failed", "error"); - } - } - const saveSelection = () => persistRoots(parseRootInput(selectionDraft)); const selectPreset = (id: string) => { setPreset(id); const choice = presets.data?.presets.find((preset) => preset.id === id); @@ -327,53 +262,14 @@ function ServiceCatalogView() { const result = await bulkMutation.mutateAsync(payload); if (execution.active) setLastAcceptedJobId(result.jobId); if (execution.active) showToast(`Queued ${payload.action} (${result.jobId})`); + return execution.active; } catch (error) { if (execution.active) showToast(error instanceof Error ? error.message : "Bulk action failed", "error"); + return false; } } - const queueBulkAction = (action: BulkAction) => { - const ids = [...selectedIds]; - if (deniedIds(action).length) { - showToast(bulkReason(action) ?? "Action unavailable", "warning"); - return; - } - if (action !== "build" && ids.length === 0) { - showToast("Select one or more services first", "warning"); - return; - } - - if (action === "build") { - if (motisImport && !ids.includes("motis")) { - showToast("MOTIS import requires selecting MOTIS", "warning"); - return; - } - void queueBulk({ - action, - serviceIds: ids, - region: buildRegion.trim() || undefined, - continueOnError: buildContinueOnError, - ...(motisImport ? { motisImport: true } : {}), - }); - return; - } - - void queueBulk( - action === "update" ? { action, serviceIds: ids, noDeps } : { action, serviceIds: ids }, - ); - }; - - const queueBuildAll = () => { - void queueBulk({ - action: "build", - all: true, - region: buildRegion.trim() || undefined, - continueOnError: buildContinueOnError, - ...(motisImport ? { motisImport: true } : {}), - }); - }; - return ( void stageImages()} onCancel={() => setStageIntent(null)} /> - {data?.acquisition.state === "unavailable" && ( - - Operations agent is unavailable. Service state is unknown.{" "} - - - )} - - Action presets - - Choose root services for the action checkboxes. This does not save service selection; - dependency expansion and action availability still apply. - - selectPreset(e.target.value)} - size="small" - sx={{ mt: 1, minWidth: 240 }} - > - Custom selection - {(presets.data?.presets ?? []).map((item) => ( - - {item.id} ({item.rootIds.join(", ")}) - {item.missingIds.length ? " — missing services" : ""} - - ))} - - {presets.isError && ( - - Presets are unavailable; service selection remains unchanged. - - )} - - - - - Service Selection - - - Manage persisted service roots from service-selection.json. Effective - services include dependencies of selected roots. Removing a root can keep it enabled as - another root’s dependency. - - - {selectionQuery.data?.source === "env" && selectionQuery.data.envVarValue && ( - - Selection is currently controlled by environment variable{" "} - {selectionQuery.data.envVarName}. File edits are disabled until the env - override is removed. - - )} - - {selectionQuery.isLoading ? ( - - ) : selectionQuery.isError ? ( - - Failed to load service selection - - ) : ( - - setSelectionDraft(e.target.value)} - placeholder="app-api, valhalla, osrm" - disabled={selectionQuery.data?.source === "env" || saveSelectionMutation.isPending} - /> - - - - - - - - - {!!selectionQuery.data?.warnings.length && ( - - {selectionQuery.data.warnings.map((warning) => ( - - {warning} - - ))} - - )} - - {!!selectionQuery.data?.missingIds.length && ( - - Missing service IDs: {selectionQuery.data.missingIds.join(", ")} - - )} - - )} - - - - - - - Bulk Actions - - - + - - - - setBuildRegion(e.target.value)} - sx={{ minWidth: { xs: "100%", sm: 220 } }} - /> - setBuildContinueOnError(e.target.checked)} - size="small" - /> - } - label="Continue on build errors" - /> - - - - setNoDeps(e.target.checked)} - disabled={bulkMutation.isPending} - /> - } - label="Update without dependency recreation" - /> - setMotisImport(e.target.checked)} - disabled={bulkMutation.isPending} - /> - } - label="Import MOTIS after build" - /> - - - - - - - - - {selectedIds.size > 0 && - ["start", "stop", "restart", "update", "build"].some( - (action) => deniedIds(action as BulkAction).length > 0, - ) && ( - - Unavailable targets:{" "} - {["start", "stop", "restart", "update", "build"] - .map((action) => { - const ids = deniedIds(action as BulkAction); - return ids.length ? `${action}: ${ids.join(", ")}` : null; - }) - .filter(Boolean) - .join("; ")} - . Check individual service controls for the available workflow. - + {lastAcceptedJobId && ( + )} - - - - + } + /> + {buildAllTargets && ( + setBuildAllTargets(null)} + /> + )} + {data?.acquisition.state === "unavailable" && ( + + Operations agent is unavailable. Service state is unknown.{" "} + + + )} + {selectedIds.size > 0 && ( + void queueBulk(payload)} + onStage={(ids) => + setStageIntent({ serviceIds: [...ids], idempotencyKey: crypto.randomUUID() }) + } + onClear={() => { + setSelectedIds(new Set()); + setPreset(""); }} - > - - - Service Catalog - - {!isLoading && summary && ( - - - - - - )} - - {isLoading && } - + /> + )} + {presets.isError && ( + + Presets are unavailable; choose action targets with the checkboxes. + + )} {!!data?.capabilityInventory?.unrecognized.length && (
@@ -835,12 +392,29 @@ function ServiceCatalogView() { flexWrap: "wrap", }} > + selectPreset(event.target.value)} + size="small" + sx={{ minWidth: 200 }} + > + Custom selection + {(presets.data?.presets ?? []).map((item) => ( + + {item.id} ({item.rootIds.join(", ")}) + {item.missingIds.length ? " — missing services" : ""} + + ))} + setSearch(e.target.value)} slotProps={{ + htmlInput: { "aria-label": "Search services" }, input: { startAdornment: ( @@ -1015,40 +589,7 @@ function ServiceCatalogView() { {svc.enabled ? "Enabled" : "Disabled"} - {selectionQuery.data?.selectedRoots.includes(svc.id) - ? " · root" - : svc.enabled - ? " · dependency" - : ""} - )) diff --git a/apps/web/src/components/admin/services/ServiceDetail.test.tsx b/apps/web/src/components/admin/services/ServiceDetail.test.tsx index 0faad7bef..69df204c0 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.test.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.test.tsx @@ -43,7 +43,10 @@ vi.mock("./ManagedDawarichSetup", () => ({ ManagedDawarichSetup: () =>
Managed Dawarich provisioning
, })); vi.mock("./ServiceCredentials", () => ({ ServiceCredentials: () => null })); -vi.mock("./ServiceLogsDrawer", () => ({ ServiceLogsDrawer: () => null })); +vi.mock("./ServiceLogsDrawer", () => ({ + ServiceLogsDrawer: () => null, + ServiceLogsPanel: () =>
Inline service logs
, +})); describe("ServiceDetail managed Dawarich setup", () => { it("shows the provisioning card only on the Dawarich app service", async () => { diff --git a/apps/web/src/components/admin/services/ServiceDetail.tsx b/apps/web/src/components/admin/services/ServiceDetail.tsx index 5c9284eba..6c9ef7ebe 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.tsx @@ -1,10 +1,10 @@ "use client"; -import ArticleIcon from "@mui/icons-material/Article"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import StopIcon from "@mui/icons-material/Stop"; +import Accordion from "@mui/material/Accordion"; +import AccordionDetails from "@mui/material/AccordionDetails"; +import AccordionSummary from "@mui/material/AccordionSummary"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -12,6 +12,8 @@ import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; +import ListItemText from "@mui/material/ListItemText"; +import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; @@ -25,6 +27,7 @@ import type { } from "@openmapx/core/api-contracts"; import Link from "next/link"; import { useState } from "react"; +import Menu from "@/components/ui/Menu"; import { useServiceAction, useServiceConfig, @@ -37,6 +40,7 @@ import { useAdminTab } from "@/lib/admin/useAdminQueryState"; import { AdminJobLink } from "../activity/AdminJobLink"; import { useActivityClient } from "../activity/activityQueries"; import { StatusBadge } from "../integrations/StatusBadge"; +import { AdminPageHeader } from "../shared/AdminPageHeader"; import { useAdminToast } from "../shared/AdminToast"; import { CompactAlert } from "../shared/CompactAlert"; import { MetaRow } from "../shared/MetaRow"; @@ -48,7 +52,7 @@ import { statusColor, statusLabel } from "../shared/ServiceStatusChip"; import { ManagedDawarichSetup } from "./ManagedDawarichSetup"; import { ServiceConfigForm } from "./ServiceConfigForm"; import { ServiceCredentials } from "./ServiceCredentials"; -import { ServiceLogsDrawer } from "./ServiceLogsDrawer"; +import { ServiceLogsPanel } from "./ServiceLogsDrawer"; function OverviewTab({ data }: { data: ServiceDetailData }) { const { manifest } = data; @@ -56,20 +60,12 @@ function OverviewTab({ data }: { data: ServiceDetailData }) { return ( - - - - Manifest - + + Technical metadata + - - - {manifest.description && ( - - - - Description - - {manifest.description} - - - )} + + {manifest.provides && manifest.provides.length > 0 && ( @@ -239,17 +219,9 @@ function OverviewTab({ data }: { data: ServiceDetailData }) { )} {manifest.envVars && manifest.envVars.length > 0 && ( - - - - Environment Variables - + + Environment variables + ( @@ -311,8 +284,8 @@ function OverviewTab({ data }: { data: ServiceDetailData }) { ))} - - + + )} {manifest.exposure && ( @@ -533,7 +506,7 @@ function ServiceDetailView({ id }: ServiceDetailProps) { "logs", "manifest", ]); - const [logsOpen, setLogsOpen] = useState(false); + const [actionMenu, setActionMenu] = useState(null); const [lastActionJobId, setLastActionJobId] = useState(null); const showToast = useAdminToast(); @@ -588,162 +561,86 @@ function ServiceDetailView({ id }: ServiceDetailProps) { function handleTabChange(_: React.SyntheticEvent, value: number) { setTab(value); - // Auto-open the logs drawer when the Logs tab is selected. - if (TABS[value] === "Logs" && !reason("logs")) { - setLogsOpen(true); - } } return ( - - - - - {manifest.name} - + - - {!data.enabled && ( - - )} - - - - - - + {!data.enabled && } + + } + actions={ + <> - - - - - {lastActionJobId && } - - - - {manifest.id} · v{manifest.version} + + } + /> + {manifest.description && ( + + {manifest.description} - {manifest.description && ( - setActionMenu(null)} + > + {(["stop", "restart", "logs"] as const).map((action) => ( + { + setActionMenu(null); + if (action === "logs") setTab(3); + else void runAction(action).catch(() => {}); }} > - {manifest.description} - - )} - + + + ))} + {[ ...new Set( [reason(startAction), reason("stop"), reason("restart"), reason("logs")].filter(Boolean), @@ -805,44 +702,15 @@ function ServiceDetailView({ id }: ServiceDetailProps) { /> )} {tab === 2 && } - {tab === 3 && ( - - - Click "Logs" or switch to this tab to open the live log stream. - - - - )} + {tab === 3 && + (reason("logs") ? ( + {reason("logs")} + ) : ( + + ))} {tab === 4 && } - setLogsOpen(false)} - serviceId={id} - serviceName={manifest.name} - /> ); } diff --git a/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx b/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx index 0abd74a41..ecd8b2834 100644 --- a/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx +++ b/apps/web/src/components/admin/services/ServiceLogsDrawer.tsx @@ -31,17 +31,47 @@ export function ServiceLogsDrawer({ serviceId, serviceName, }: ServiceLogsDrawerProps) { - const { client, apiUrl } = useActivityClient(); return ( - + + + ); +} +export function ServiceLogsPanel({ + active, + serviceId, + serviceName, + onClose, +}: Omit & { active: boolean; onClose?: () => void }) { + const { client, apiUrl } = useActivityClient(); + return active ? ( + - ); + ) : null; } function ServiceLogsSession({ @@ -50,7 +80,10 @@ function ServiceLogsSession({ serviceId, serviceName, client, -}: ServiceLogsDrawerProps & Pick, "client">) { +}: Omit & { onClose?: () => void } & Pick< + ReturnType, + "client" + >) { const [output, setOutput] = useState({ lines: [] as string[], truncated: false }); const [streaming, setStreaming] = useState(open), [error, setError] = useState(null); @@ -64,7 +97,7 @@ function ServiceLogsSession({ () => ({ tail, follow, duration, connection, idempotencyKey: crypto.randomUUID() }), [tail, follow, duration, connection], ); - const bottomRef = useRef(null), + const outputRef = useRef(null), abortRef = useRef(null); useEffect(() => { if (!open || paused) return; @@ -115,7 +148,7 @@ function ServiceLogsSession({ useEffect(() => { if (open && lines.length > 0) { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; } }, [lines, open]); @@ -124,22 +157,20 @@ function ServiceLogsSession({ setOutput({ lines: [], truncated: false }); setError(null); setStreaming(false); - onClose(); + onClose?.(); } return ( - )} - - - + {onClose && ( + + + + )} )} {lines.join("\n")} - -
- + ); } diff --git a/apps/web/src/components/admin/services/ServiceSelectionEditor.tsx b/apps/web/src/components/admin/services/ServiceSelectionEditor.tsx new file mode 100644 index 000000000..4c650c3a9 --- /dev/null +++ b/apps/web/src/components/admin/services/ServiceSelectionEditor.tsx @@ -0,0 +1,232 @@ +"use client"; +import Accordion from "@mui/material/Accordion"; +import AccordionDetails from "@mui/material/AccordionDetails"; +import AccordionSummary from "@mui/material/AccordionSummary"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import Link from "next/link"; +import { useState } from "react"; +import { useServiceSelection, useServiceSelectionSave, useServicesList } from "@/hooks/useServices"; +import { useAdminDraft } from "@/lib/admin/useAdminDraft"; +import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; +import { AdminJobLink } from "../activity/AdminJobLink"; +import { useActivityClient } from "../activity/activityQueries"; +import { useAdminToast } from "../shared/AdminToast"; + +const parseRoots = (value: string) => + value + .split(",") + .map((id) => id.trim()) + .filter(Boolean); +export function ServiceSelectionEditor() { + const { apiUrl } = useActivityClient(); + return ; +} +function SelectionEditor() { + const selection = useServiceSelection(); + const services = useServicesList(); + const save = useServiceSelectionSave(); + const { client } = useActivityClient(); + const owner = useAdminMutationOwner(client); + const toast = useAdminToast(); + const [accepted, setAccepted] = useState(); + const baseline = (selection.data?.selectedRoots ?? []).join(", "); + const locked = selection.data?.source === "env"; + const draft = useAdminDraft({ + id: "service-selection", + baseline: { roots: baseline }, + editableKeys: locked ? [] : ["roots"], + signature: "roots-v1", + ready: !!selection.data, + }); + const disabled = !selection.data || selection.isError || locked || save.isPending; + async function persist(roots: string[], submitted: string) { + try { + const result = await save.mutateAsync(roots); + draft.acknowledge({ roots: submitted }); + if (!owner.active) return; + if ("jobId" in result) setAccepted(String(result.jobId)); + toast( + "jobId" in result + ? `Queued service selection (${result.jobId})` + : "Service selection saved", + ); + } catch (error) { + if (owner.active) toast(error instanceof Error ? error.message : "Save failed", "error"); + } + } + return ( + + + + Service selection + + + Persistent roots determine enabled services and their dependencies. Removing a root can + keep it enabled as another root's dependency. Inventory checkboxes select temporary action + targets. + + {locked && ( + + Selection is controlled by {selection.data?.envVarName}. Remove that host override + before editing the selection file. + + )} + {selection.isError && ( + void selection.refetch()}>Retry} + > + Failed to load service selection + + )} + draft.setValues({ roots: event.target.value })} + disabled={disabled} + placeholder="app-api, valhalla, osrm" + /> + {draft.conflicts.length > 0 && ( + draft.keepEdits()}>Keep my edits} + > + Saved service roots changed. Review or discard your edits before saving. + + )} + + + + + + {draft.dirty && ( + + )} + + {!!selection.data?.warnings.length && ( + {selection.data.warnings.join("; ")} + )} + {!!selection.data?.missingIds.length && ( + + Missing service IDs: {selection.data.missingIds.join(", ")} + + )} + {accepted && } + + Services and root controls + + {services.isError && ( + + Service membership could not be loaded. The root editor remains available. + + )} + + + + + Service + Membership + Root control + + + + {(services.data?.services ?? []).map((service) => { + const root = selection.data?.selectedRoots.includes(service.id); + const dependency = service.enabled && !root; + const reason = dependency + ? "Required by a selected root. Edit the roots to remove that dependency." + : draft.dirty + ? "Save or discard root edits before changing one service." + : undefined; + return ( + + + + + {service.id} + + + + {root ? "Root" : dependency ? "Required dependency" : "Disabled"} + + + + + + ); + })} + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/admin/services/ServiceSessions.test.tsx b/apps/web/src/components/admin/services/ServiceSessions.test.tsx index 6419a4cd5..4158addb9 100644 --- a/apps/web/src/components/admin/services/ServiceSessions.test.tsx +++ b/apps/web/src/components/admin/services/ServiceSessions.test.tsx @@ -6,7 +6,7 @@ import { act, cleanup, createTestQueryClient, fireEvent, render, screen, waitFor import { job } from "../activity/__tests__/fixtures"; import { ComposePreview } from "./ComposePreview"; import { ManagedDawarichSetup } from "./ManagedDawarichSetup"; -import { ServiceLogsDrawer } from "./ServiceLogsDrawer"; +import { ServiceLogsDrawer, ServiceLogsPanel } from "./ServiceLogsDrawer"; let apiUrl = "http://admin.test"; const toast = vi.fn(); @@ -211,6 +211,30 @@ describe("Compose generated consumers", () => { }); }); describe("generated log sessions", () => { + it("owns one inline stream, aborts on tab disposal and starts a fresh read on return", async () => { + const signals: AbortSignal[] = []; + vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => { + signals.push(init.signal as AbortSignal); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("inline output\n")); + }, + }), + { headers: { "content-type": "text/plain" } }, + ); + }); + const view = mount(); + await screen.findByText("inline output"); + expect(signals).toHaveLength(1); + expect(screen.queryByRole("dialog")).toBeNull(); + view.replace(); + await waitFor(() => expect(signals[0].aborted).toBe(true)); + expect(screen.queryByText("inline output")).toBeNull(); + view.replace(); + await screen.findByText("inline output"); + expect(signals).toHaveLength(2); + }); it("decodes split UTF8/newlines, preserves blank output and keeps the last1000 lines", async () => { const encoder = new TextEncoder(); const bytes = encoder.encode("α\n\nline\nlast😀"); diff --git a/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx b/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx index 3bb059fab..a30bc809c 100644 --- a/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx +++ b/apps/web/src/components/admin/services/__tests__/BackupPruneControls.test.tsx @@ -90,7 +90,7 @@ it("refuses an expired preview and links to the single source-aware policy edito setup(true); expect(screen.getByRole("link", { name: "Edit backup retention policy" })).toHaveAttribute( "href", - "/admin/system/deployment#backup-retention", + "/admin/system/backups#backup-retention", ); fireEvent.click(screen.getByRole("button", { name: "Preview expired backups" })); await screen.findByText(/30 days.*database/); diff --git a/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx b/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx index fa02cb0f5..7223fe478 100644 --- a/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx +++ b/apps/web/src/components/admin/settings/AuthPolicySettings.test.tsx @@ -1,5 +1,6 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; import { cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; const mocks = vi.hoisted(() => ({ get: vi.fn(), apply: vi.fn() })); @@ -68,6 +69,28 @@ it("focuses the addressed policy field without applying it", async () => { await waitFor(() => expect(screen.getByLabelText("Minimum password length")).toHaveFocus()); expect(mocks.apply).not.toHaveBeenCalled(); }); +it("retains ordinary policy edits across topic disposal and clears confirmation", async () => { + const { AuthPolicySettings } = await import("./AuthPolicySettings"); + const cache = createTestQueryClient(); + caches.push(cache); + const view = (show: boolean) => ( + + + {show ? :
Another topic
} +
+
+ ); + const mounted = render(view(true)); + fireEvent.change(await screen.findByLabelText("Minimum password length"), { + target: { value: "12" }, + }); + fireEvent.click(screen.getByRole("checkbox", { name: /I have reviewed/ })); + mounted.rerender(view(false)); + mounted.rerender(view(true)); + expect(await screen.findByLabelText("Minimum password length")).toHaveValue(12); + expect(screen.getByRole("checkbox", { name: /I have reviewed/ })).not.toBeChecked(); + expect(mocks.apply).not.toHaveBeenCalled(); +}); it("keeps effective defaults separate from legacy false drafts and rejects invalid bounds", async () => { await mount(); expect(await screen.findByText(/safe defaults are active/)).toBeVisible(); diff --git a/apps/web/src/components/admin/settings/AuthPolicySettings.tsx b/apps/web/src/components/admin/settings/AuthPolicySettings.tsx index df5570214..4070eb641 100644 --- a/apps/web/src/components/admin/settings/AuthPolicySettings.tsx +++ b/apps/web/src/components/admin/settings/AuthPolicySettings.tsx @@ -11,6 +11,7 @@ import { authPolicySettingsSchema, type authPolicyViewSchema } from "@openmapx/c import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; import { useAccountAction, useAccountAdmin } from "@/lib/admin/accountAdmin"; +import { useAdminDraft } from "@/lib/admin/useAdminDraft"; import { useAdminFieldFocus } from "@/lib/admin/useAdminFieldFocus"; export function AuthPolicySettings({ focusKey }: { focusKey?: string } = {}) { const scope = useAccountAdmin(), @@ -44,17 +45,42 @@ export function AuthPolicySettings({ focusKey }: { focusKey?: string } = {}) { ); } function PolicyForm({ view }: { view: ReturnType }) { - const { active, pending, ownership } = view, - [email, setEmail] = useState(active.emailVerificationRequired), - [hours, setHours] = useState(String(active.sessionDurationHours)), - [twoFa, setTwoFa] = useState(active.twoFaEnabled), - [length, setLength] = useState(String(active.passwordMinLength)), - [confirmed, setConfirmed] = useState(false), + const { active, pending, ownership } = view; + const draft = useAdminDraft({ + id: "authentication-policy", + signature: "policy-v1", + baseline: { + emailVerificationRequired: active.emailVerificationRequired, + sessionDurationHours: String(active.sessionDurationHours), + twoFaEnabled: active.twoFaEnabled, + passwordMinLength: String(active.passwordMinLength), + }, + editableKeys: [ + "sessionDurationHours", + "twoFaEnabled", + "passwordMinLength", + ...(ownership.emailVerificationRequired === "env" ? [] : ["emailVerificationRequired"]), + ], + }); + const submitted = useRef>({}); + const email = Boolean(draft.values.emailVerificationRequired ?? active.emailVerificationRequired), + hours = String(draft.values.sessionDurationHours), + twoFa = Boolean(draft.values.twoFaEnabled), + length = String(draft.values.passwordMinLength); + const setEmail = (value: boolean) => + draft.setValues((old) => ({ ...old, emailVerificationRequired: value })); + const setHours = (value: string) => + draft.setValues((old) => ({ ...old, sessionDurationHours: value })); + const setTwoFa = (value: boolean) => draft.setValues((old) => ({ ...old, twoFaEnabled: value })); + const setLength = (value: string) => + draft.setValues((old) => ({ ...old, passwordMinLength: value })); + const [confirmed, setConfirmed] = useState(false), [saved, setSaved] = useState(false), key = useRef(null), mutation = useAccountAction("auth-policy", { viewKey: active.revision, onSuccess: () => { + draft.acknowledge(submitted.current); setConfirmed(false); setSaved(true); }, @@ -206,6 +232,23 @@ function PolicyForm({ view }: { view: ReturnType + {!!draft.conflicts.length && ( + edit(() => draft.keepEdits())}>Keep my edits} + > + The active policy changed. Review or discard your draft before applying. + + )} + {draft.dirty && ( + + )} {mutation.error && {mutation.error.message}} {saved && ( @@ -215,11 +258,12 @@ function PolicyForm({ view }: { view: ReturnType { if (!parsed.success) return; key.current ??= crypto.randomUUID(); const requestKey = key.current; + submitted.current = { ...draft.values }; mutation.mutate((client) => client.applyPolicy(parsed.data, active.revision, requestKey)); }} > diff --git a/apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx b/apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx new file mode 100644 index 000000000..147fe8409 --- /dev/null +++ b/apps/web/src/components/admin/settings/EmailSettingsSection.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, createQueryWrapper, fireEvent, render, screen, waitFor } from "@/test"; +import { savedEmailProvider } from "./EmailSettingsSection"; +import { SystemSettings } from "./SystemSettings"; + +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://api.test" }), +})); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +const fields = [ + ["smtpFromAddress", "common", "From Address", "sender@example.test", false], + ["emailLabsAppKey", "emaillabs", "App Key", "***", true], + ["emailLabsSecretKey", "emaillabs", "Secret Key", "***", true], + ["emailLabsSmtpAccount", "emaillabs", "SMTP Account", "account", false], + ["lettermintApiToken", "lettermint", "API Token", "***", true], + ["smtpHost", "smtp", "Host", "smtp.example.test", false], +].map(([key, subgroup, label, value, secret]) => ({ + group: "email", + key, + subgroup, + label, + value, + secret, + type: "string", + source: "database", + envOverride: false, +})); +function mount(focusKey?: string) { + const fetch = vi.fn(async (_input: unknown, init: unknown) => + (init as RequestInit | undefined)?.method === "PATCH" + ? Response.json({ ok: true }) + : Response.json({ groups: [{ id: "email", label: "Email", settings: fields }] }), + ); + vi.stubGlobal("fetch", fetch); + render( + , + { wrapper: createQueryWrapper() }, + ); + return fetch; +} +it("keeps the existing provider priority without an exclusive provider setting", () => { + expect( + savedEmailProvider({ + emailLabsAppKey: "***", + emailLabsSecretKey: "***", + emailLabsSmtpAccount: "account", + lettermintApiToken: "***", + }), + ).toBe("emaillabs"); + expect( + savedEmailProvider({ + emailLabsAppKey: "***", + emailLabsSecretKey: "***", + lettermintApiToken: "***", + }), + ).toBe("lettermint"); + expect(savedEmailProvider({})).toBe("smtp"); +}); +it("opens a collapsed SMTP provider and focuses its search result", async () => { + mount("smtpHost"); + await waitFor(() => expect(screen.getByLabelText("Host")).toHaveFocus()); + expect(screen.getByText(/Saved provider: EmailLabs/)).toBeInTheDocument(); +}); +it("preserves explicit edits when a provider closes and skips unchanged masked credentials", async () => { + const fetch = mount(); + fireEvent.change(await screen.findByLabelText("App Key"), { target: { value: "typed-key" } }); + fireEvent.click(screen.getByText("EmailLabs (priority 1)")); + fireEvent.click(screen.getByRole("button", { name: "Save Email" })); + await waitFor(() => + expect( + fetch.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === "PATCH"), + ).toBe(true), + ); + const request = fetch.mock.calls.find( + (call) => (call[1] as RequestInit | undefined)?.method === "PATCH", + ); + expect(JSON.parse(String((request?.[1] as RequestInit).body))).toEqual({ + emailLabsAppKey: "typed-key", + }); +}); diff --git a/apps/web/src/components/admin/settings/EmailSettingsSection.tsx b/apps/web/src/components/admin/settings/EmailSettingsSection.tsx new file mode 100644 index 000000000..251a25bf6 --- /dev/null +++ b/apps/web/src/components/admin/settings/EmailSettingsSection.tsx @@ -0,0 +1,88 @@ +"use client"; +import Box from "@mui/material/Box"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { type ReactNode, useEffect, useState } from "react"; +export type EmailProvider = "emaillabs" | "lettermint" | "smtp"; +export const EMAIL_PROVIDER_NAMES = { + emaillabs: "EmailLabs", + lettermint: "Lettermint", + smtp: "SMTP", +}; +const present = (value: unknown) => typeof value === "string" && value.length > 0; +export function configuredEmailProvider( + provider: string | undefined, + values: Record, +) { + if (provider === "emaillabs") + return ["emailLabsAppKey", "emailLabsSecretKey", "emailLabsSmtpAccount"].every((key) => + present(values[key]), + ); + if (provider === "lettermint") return present(values.lettermintApiToken); + return provider === "smtp" && present(values.smtpHost); +} +/** Mirrors saved-field precedence, never a delivery-health claim or a provider switch. */ +export function savedEmailProvider(values: Record): EmailProvider { + return configuredEmailProvider("emaillabs", values) + ? "emaillabs" + : configuredEmailProvider("lettermint", values) + ? "lettermint" + : "smtp"; +} +export function EmailSettingsSection({ + provider, + label, + description, + focused, + selected, + configured, + children, +}: { + provider?: string; + label?: string; + description?: string; + focused: boolean; + selected: boolean; + configured: boolean; + children: ReactNode; +}) { + const [expanded, setExpanded] = useState(selected); + useEffect(() => { + if (focused) setExpanded(true); + }, [focused]); + if (!provider || !["emaillabs", "lettermint", "smtp"].includes(provider)) + return {children}; + return ( + setExpanded((event.currentTarget as HTMLDetailsElement).open)} + sx={{ border: 1, borderColor: "divider", borderRadius: 1.5, p: 1.5 }} + > + + {label}{" "} + + + {description && ( + + {description} + + )} + {children} + + ); +} diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx index 55b96930c..199fc9b98 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx @@ -2,6 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; import { PlatformConfiguration } from "./PlatformConfiguration"; +import type { PlatformScope } from "./platformScope"; const mocks = vi.hoisted(() => ({ get: vi.fn(), preview: vi.fn(), apply: vi.fn(), job: vi.fn() })); vi.mock("@openmapx/core", async () => ({ @@ -77,12 +78,12 @@ const preview = { previewFingerprint: "b".repeat(64), }; const caches: ReturnType[] = []; -function mount() { +function mount(scope: PlatformScope = "deployment") { const cache = createTestQueryClient(); caches.push(cache); return render( - + , ); } @@ -107,7 +108,7 @@ afterEach(() => { }); }); it("locks host fields until explicit adoption, previews without saving and binds apply to that preview", async () => { - mount(); + mount("retention"); const retention = await screen.findByLabelText("Backup retention (days)"); expect(retention).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "Manage Backup retention (days) in UI" })); @@ -129,6 +130,92 @@ it("locks host fields until explicit adoption, previews without saving and binds }); expect(await screen.findByText("Job original-job")).toBeVisible(); }); +it("exposes only the fields owned by retention and refuses a hidden domain change", async () => { + mocks.preview.mockResolvedValue({ + status: 200, + data: { + preview: { ...preview, values: { ...values, domain: "unexpected.example.test" } }, + expiresAt: new Date(Date.now() + 600000).toISOString(), + }, + }); + mount("retention"); + await screen.findByLabelText("Backup retention (days)"); + expect(screen.queryByLabelText("Domain")).toBeNull(); + expect(screen.queryByLabelText("Release selector")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Preview changes" })); + await screen.findByText(/Preview unavailable/); + expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).toBeNull(); +}); +it("does not preview an empty adopted default region", async () => { + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Manage Default region in UI" })); + expect(screen.getByLabelText("Default region")).toHaveValue(""); + expect(screen.getByRole("button", { name: "Preview changes" })).toBeDisabled(); + expect(mocks.preview).not.toHaveBeenCalled(); +}); +it("removes an old unscoped preview instead of loading it in a scoped owner", async () => { + const key = "openmapx-platform-preview:http://admin.test:admin-a"; + sessionStorage.setItem( + key, + JSON.stringify({ preview, expiresAt: new Date(Date.now() + 600000).toISOString() }), + ); + mount("retention"); + await screen.findByLabelText("Backup retention (days)"); + expect(sessionStorage.getItem(key)).toBeNull(); + expect(screen.queryByText("Review platform changes")).toBeNull(); +}); +it("reuses the same apply intent after an unconfirmed transport outcome", async () => { + mocks.apply.mockRejectedValueOnce(Error("offline")); + mount(); + await screen.findByLabelText("Domain"); + fireEvent.click(screen.getByRole("button", { name: "Preview changes" })); + fireEvent.click(await screen.findByRole("button", { name: "Apply reviewed changes" })); + await screen.findByText(/Apply was not confirmed/); + fireEvent.click(screen.getByRole("button", { name: "Apply reviewed changes" })); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(2)); + expect(mocks.apply.mock.calls[0][1]).toEqual(mocks.apply.mock.calls[1][1]); +}); +it("copies the fresh full baseline before overlaying a retention edit", async () => { + const fresh = { + ...initial, + pending: [], + configuration: { version: 1, domain: { source: "database", value: "fresh.example.test" } }, + values: { ...values, domain: "fresh.example.test" }, + sources: { ...sources, domain: "database" }, + }; + let reads = 0; + mocks.get.mockImplementation(async () => ({ + status: 200, + data: ++reads === 1 ? { ...initial, pending: [] } : fresh, + })); + mocks.preview.mockResolvedValue({ + status: 200, + data: { + preview: { + ...preview, + ...fresh, + configuration: { + ...fresh.configuration, + backupRetentionDays: { source: "database", value: 60 }, + }, + oldOrigin: "https://fresh.example.test", + newOrigin: "https://fresh.example.test", + }, + expiresAt: new Date(Date.now() + 600000).toISOString(), + }, + }); + mount("retention"); + fireEvent.click( + await screen.findByRole("button", { name: "Manage Backup retention (days) in UI" }), + ); + fireEvent.change(screen.getByLabelText("Backup retention (days)"), { target: { value: "60" } }); + fireEvent.click(screen.getByRole("button", { name: "Preview changes" })); + await screen.findByText("Review platform changes"); + expect((mocks.preview.mock.calls[0][0] as { changes: unknown }).changes).toEqual({ + ...fresh.configuration, + backupRetentionDays: { source: "database", value: 60 }, + }); +}); it("invalidates a preview on edit and preserves the draft on conflict", async () => { mocks.preview.mockResolvedValue({ status: 409, data: { error: "changed" } }); mount(); diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx index 5d83eb015..e1b67a70d 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx @@ -1,5 +1,8 @@ "use client"; +import Accordion from "@mui/material/Accordion"; +import AccordionDetails from "@mui/material/AccordionDetails"; +import AccordionSummary from "@mui/material/AccordionSummary"; import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; @@ -16,6 +19,7 @@ import { } from "@openmapx/core/api-admin-sdk"; import { type AdminPlatformPreview, + adminPlatformPreviewInputSchema, adminPlatformPreviewRetainedSchema, } from "@openmapx/core/api-contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -23,14 +27,28 @@ import { useEffect, useRef, useState } from "react"; import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import { useAccountAdmin } from "@/lib/admin/accountAdmin"; import { useOptionalAdminJobRegistration } from "@/lib/admin/adminJobEffects"; -import { activityJobQueryOptions, useActivityClient } from "../activity/activityQueries"; +import { useAdminDraft } from "@/lib/admin/useAdminDraft"; +import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; +import { + activityJobQueryOptions, + hasUnresolvedJobObservation, + useActivityClient, +} from "../activity/activityQueries"; import { JobDetail } from "../activity/JobDetail"; import { AdminReauthenticationDialog } from "../users/AdminReauthenticationDialog"; +import { + buildPlatformCandidate, + PLATFORM_SCOPE_FIELDS, + PLATFORM_SCOPE_HREF, + type PlatformDraft, + type PlatformScope, + platformPreviewWithinScope, +} from "./platformScope"; type View = Awaited> & { status: 200 }; type Configuration = AdminPlatformPreview["configuration"]; type Field = keyof AdminPlatformPreview["values"]; -const fields: { key: Field; label: string; help: string }[] = [ +const FIELD_DEFINITIONS: { key: Field; label: string; help: string }[] = [ { key: "domain", label: "Domain", @@ -69,7 +87,13 @@ const emptyConfirmations = { }; /** Effective ownership and retained job links stay available after a browser reconnect. */ -export function PlatformConfiguration() { +class PlatformScopeError extends Error {} +const TITLES = { + deployment: "Deployment configuration", + release: "Release selection", + retention: "Backup retention", +}; +export function PlatformConfiguration({ scope = "deployment" }: { scope?: PlatformScope } = {}) { const { actorId } = useAccountAdmin(), { client, apiUrl } = useActivityClient(); const query = useQuery({ @@ -80,8 +104,10 @@ export function PlatformConfiguration() { queryFn: async () => unwrapAdminResponse(await getAdminPlatformConfiguration({ client })), }); return ( - - Platform configuration + + + {TITLES[scope]} + {query.isError && ( void query.refetch()} /> ) : ( !query.isError && Loading platform configuration… @@ -103,31 +131,91 @@ export function PlatformConfiguration() { ); } -function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }) { +function PlatformForm({ + view, + actorId, + scope, + onReload, +}: { + view: View["data"]; + actorId: string; + scope: PlatformScope; + onReload: () => void; +}) { const register = useOptionalAdminJobRegistration(); const queryClient = useQueryClient(); const { client, apiUrl } = useActivityClient(), - storageKey = `openmapx-platform-preview:${apiUrl}:${actorId}`; + storageKey = `openmapx-platform-preview:${apiUrl}:${actorId}:${scope}`; + const owner = useAdminMutationOwner( + client, + JSON.stringify([actorId, scope, view.expectedCurrentRevisionId]), + ); + const scopeFields: readonly Field[] = PLATFORM_SCOPE_FIELDS[scope]; + useEffect(() => { + const id = window.location.hash.slice(1); + if ( + !( + (scope === "retention" && id === "backup-retention") || + (scope === "release" && id === "release-selector") + ) + ) + return; + const timer = setTimeout(() => { + const field = document.getElementById(id); + field?.scrollIntoView?.({ block: "center" }); + ( + field?.querySelector("input:not(:disabled),button:not(:disabled)") ?? field + )?.focus(); + }, 0); + return () => clearTimeout(timer); + }, [scope]); const [retained, setRetained] = useState | null>(() => { try { + sessionStorage.removeItem(`openmapx-platform-preview:${apiUrl}:${actorId}`); const raw = sessionStorage.getItem(storageKey); if (!raw) return null; const parsed = adminPlatformPreviewRetainedSchema.parse(JSON.parse(raw)); - return parsed.preview.actorId === actorId && + const valid = + parsed.preview.actorId === actorId && parsed.preview.expectedCurrentRevisionId === view.expectedCurrentRevisionId && - Date.parse(parsed.expiresAt) > Date.now() - ? parsed - : null; + Date.parse(parsed.expiresAt) > Date.now() && + platformPreviewWithinScope( + parsed.preview, + view, + buildPlatformCandidate(view.configuration, parsed.preview.configuration, scope), + scope, + ); + if (!valid) sessionStorage.removeItem(storageKey); + return valid ? parsed : null; } catch { return null; } }); - const [draft, setDraft] = useState( - retained?.preview.configuration ?? view.configuration, - ), - [confirmations, setConfirmations] = useState(emptyConfirmations), + const ordinaryDraft = useAdminDraft({ + id: `platform:${scope}`, + baseline: Object.fromEntries(scopeFields.map((key) => [key, view.configuration[key] ?? null])), + editableKeys: scopeFields, + signature: "platform-v1", + }); + const draft = buildPlatformCandidate( + view.configuration, + ordinaryDraft.values as PlatformDraft, + scope, + ); + const initialized = useRef(false); + useEffect(() => { + if (initialized.current) return; + initialized.current = true; + if (retained && !ordinaryDraft.dirty) + ordinaryDraft.setValues( + Object.fromEntries( + scopeFields.map((key) => [key, retained.preview.configuration[key] ?? null]), + ), + ); + }, [retained, ordinaryDraft, scopeFields]); + const [confirmations, setConfirmations] = useState(emptyConfirmations), [proof, setProof] = useState(), [reauth, setReauth] = useState(false), [accepted, setAccepted] = useState(), @@ -140,7 +228,8 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } }); const observedTerminal = !!acceptedJob.data?.finishedAt && - ["success", "failed", "canceled"].includes(acceptedJob.data.status); + ["success", "failed", "canceled"].includes(acceptedJob.data.status) && + !hasUnresolvedJobObservation(acceptedJob.data.result); const pending = view.pending ?? [], busy = pending.length > 0 || (!!accepted && !observedTerminal); const clearPreview = () => { @@ -152,22 +241,61 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } previewKey.current = null; applyKey.current = null; }; + const freshView = async () => { + let fresh: View["data"]; + try { + fresh = unwrapAdminResponse(await getAdminPlatformConfiguration({ client })); + } catch { + throw new PlatformScopeError( + "Platform state could not be refreshed. Reload before reviewing changes.", + ); + } + if (fresh.expectedCurrentRevisionId !== view.expectedCurrentRevisionId) + throw new PlatformScopeError( + "Saved platform configuration changed. Reload and review your draft.", + ); + if (fresh.pending.length) + throw new PlatformScopeError( + "An original platform operation is still pending. Inspect its outcome first.", + ); + return fresh; + }; const previewMutation = useMutation({ retry: false, mutationFn: async () => { + const fresh = await freshView(); + const edits = Object.fromEntries( + ordinaryDraft.dirtyKeys.map((key) => [key, ordinaryDraft.values[key]]), + ) as PlatformDraft; + const candidate = buildPlatformCandidate(fresh.configuration, edits, scope); + const parsed = adminPlatformPreviewInputSchema.safeParse({ + expectedCurrentRevisionId: fresh.expectedCurrentRevisionId, + changes: candidate, + }); + if (!parsed.success) + throw new PlatformScopeError( + "Complete the visible fields with valid values before previewing.", + ); previewKey.current ??= crypto.randomUUID(); - return unwrapAdminResponse( + const value = unwrapAdminResponse( await previewAdminPlatformConfiguration( - { expectedCurrentRevisionId: view.expectedCurrentRevisionId, changes: draft }, + parsed.data, { "idempotency-key": previewKey.current }, { client }, ), ); + if (!platformPreviewWithinScope(value.preview, fresh, candidate, scope)) + throw new PlatformScopeError( + "The preview changes a field owned by another topic. Reload before continuing.", + ); + return { value, fresh }; }, onError: () => { - previewKey.current = null; + if (owner.active) previewKey.current = null; }, - onSuccess: (value) => { + onSuccess: ({ value, fresh }) => { + if (!owner.active) return; + queryClient.setQueryData(["admin", "platform", apiUrl, actorId], fresh); setRetained(value); sessionStorage.setItem(storageKey, JSON.stringify(value)); applyKey.current = null; @@ -177,6 +305,16 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } retry: false, mutationFn: async () => { if (!retained || Date.parse(retained.expiresAt) <= Date.now()) throw Error("Preview expired"); + const fresh = await freshView(); + const candidate = buildPlatformCandidate( + fresh.configuration, + retained.preview.configuration, + scope, + ); + if (!platformPreviewWithinScope(retained.preview, fresh, candidate, scope)) + throw new PlatformScopeError( + "The preview no longer preserves another topic's configuration. Reload and review again.", + ); applyKey.current ??= crypto.randomUUID(); const accepted = await unwrapAdminResponse( await applyAdminPlatformConfiguration( @@ -196,12 +334,13 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } jobId: accepted.jobId, label: "Platform configuration", kind: "platform", - returnTo: "/admin/system/deployment", + returnTo: PLATFORM_SCOPE_HREF[scope], onSettled: () => queryClient.invalidateQueries({ queryKey: ["admin", "platform", apiUrl] }), }); return accepted; }, onSuccess: (value) => { + if (!owner.active) return; setAccepted(value.jobId); clearPreview(); }, @@ -219,8 +358,20 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } clearPreview(); previewMutation.reset(); applyMutation.reset(); - setDraft((old) => ({ ...old, [key]: value })); + ordinaryDraft.setValues((old) => ({ ...old, [key]: value })); }; + const validInput = adminPlatformPreviewInputSchema.safeParse({ + expectedCurrentRevisionId: view.expectedCurrentRevisionId, + changes: draft, + }).success; + const previewInScope = + !retained || + platformPreviewWithinScope( + retained.preview, + view, + buildPlatformCandidate(view.configuration, retained.preview.configuration, scope), + scope, + ); const preview = retained?.preview, expired = !!retained && Date.parse(retained.expiresAt) <= Date.now(); return ( @@ -229,70 +380,115 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } Host-owned values are locked until you explicitly adopt them. Returning a field to the host uses its current environment value. Previewing does not save or restart services. - {fields.map(({ key, label, help }) => { - const field = draft[key], - managed = field?.source === "database"; - const value = managed ? field.value : view.values[key]; - return ( - - - edit(key, { - source: "database", - value: key === "backupRetentionDays" ? Number(e.target.value) : e.target.value, - } as Configuration[Field]) - } - /> - - - ); - })} + + edit(key, { + source: "database", + value: key === "backupRetentionDays" ? Number(e.target.value) : e.target.value, + } as Configuration[Field]) + } + /> + + + ); + }, + )} + {ordinaryDraft.conflicts.length > 0 && ( + ordinaryDraft.keepEdits()}>Keep my edits} + > + Saved ownership changed for {ordinaryDraft.conflicts.join(", ")}. Review or discard your + edits before previewing. + + )} + {!validInput && ( + + Complete the managed fields before previewing. A default region must name a valid, + nonempty region. + + )} + {!previewInScope && ( + Reload platform state}> + This preview no longer preserves configuration owned by another topic. Reload before + applying. + + )} {previewMutation.isError && ( - + Reload platform state}> Preview unavailable. The revision, input or source may have changed. Reload before - retrying; your draft is retained. + retrying; your draft is retained.{" "} + {previewMutation.error instanceof PlatformScopeError ? previewMutation.error.message : ""} )} {applyMutation.isError && ( - - Apply was not confirmed. Inspect Activity before retrying with the same request. If the - preview or policy changed, reload and review a new preview. + Reload platform state}> + {applyMutation.error instanceof PlatformScopeError + ? `Apply was not started. ${applyMutation.error.message}` + : "Apply was not confirmed. Inspect Activity before retrying with the same request. If the preview or policy changed, reload and review a new preview."} )} - + + + {ordinaryDraft.dirty && ( + + )} + {preview && ( Review platform changes @@ -349,6 +545,7 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string } working || busy || expired || + !previewInScope || Object.entries(preview.requiredConfirmation).some( ([key, required]) => required && !confirmations[key as keyof typeof confirmations], ) || @@ -386,18 +583,25 @@ function PlatformForm({ view, actorId }: { view: View["data"]; actorId: string }
), )} - - - Host recovery: inspect the retained operation in Activity and the host ops-agent journal. Do - not delete locks or replay an uncertain update. Bootstrap secrets, Docker installation and - offline agent recovery require host access. - + + Host recovery commands + + + + Host recovery: inspect the retained operation in Activity and the host ops-agent + journal. Do not delete locks or replay an uncertain update. Bootstrap secrets, Docker + installation and offline agent recovery require host access. + + +
); } diff --git a/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx b/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx index 5df52795c..5c9a5a250 100644 --- a/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx +++ b/apps/web/src/components/admin/settings/SettingsWorkspace.test.tsx @@ -80,7 +80,7 @@ it("migrates the legacy retention fragment to the field's current owner", async const replace = vi.spyOn(adminTestHistory, "replace"); render(); await waitFor(() => - expect(replace).toHaveBeenCalledWith("/admin/system/deployment#backup-retention"), + expect(replace).toHaveBeenCalledWith("/admin/system/backups#backup-retention"), ); window.history.replaceState(null, "", "/admin"); }); diff --git a/apps/web/src/components/admin/settings/SettingsWorkspace.tsx b/apps/web/src/components/admin/settings/SettingsWorkspace.tsx index b3807b74c..8ddac6b98 100644 --- a/apps/web/src/components/admin/settings/SettingsWorkspace.tsx +++ b/apps/web/src/components/admin/settings/SettingsWorkspace.tsx @@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect } from "react"; import type { SETTINGS_TOPICS } from "@/lib/admin/adminNavigation"; +import { useAdminNavigationAccess } from "../shared/AdminNavigationContext"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { SystemSettings } from "./SystemSettings"; @@ -19,6 +20,7 @@ const GROUPS = { }; export function SettingsWorkspace({ topic }: { topic: (typeof SETTINGS_TOPICS)[number] }) { const t = useTranslations("adminNavigation"); + const { selfHosted } = useAdminNavigationAccess(); const params = useSearchParams(); const field = params.get("field"); return ( @@ -31,15 +33,18 @@ export function SettingsWorkspace({ topic }: { topic: (typeof SETTINGS_TOPICS)[n - {t("destinations.deployment.label")} - + selfHosted !== false ? ( + + ) : undefined } > {t("applicationUrlHelp")} )} { router.replace( window.location.hash === "#backup-retention" - ? "/admin/system/deployment#backup-retention" + ? "/admin/system/backups#backup-retention" : "/admin/settings/general", ); }, [router]); diff --git a/apps/web/src/components/admin/settings/SystemSettings.tsx b/apps/web/src/components/admin/settings/SystemSettings.tsx index 437cfce92..fdfb68408 100644 --- a/apps/web/src/components/admin/settings/SystemSettings.tsx +++ b/apps/web/src/components/admin/settings/SystemSettings.tsx @@ -40,6 +40,12 @@ import { useActivityClient } from "../activity/activityQueries"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AuthPolicySettings } from "./AuthPolicySettings"; import { ConfigurationMigrationPanel } from "./ConfigurationMigrationPanel"; +import { + configuredEmailProvider, + EMAIL_PROVIDER_NAMES, + EmailSettingsSection, + savedEmailProvider, +} from "./EmailSettingsSection"; export interface ResolvedSetting { group: string; @@ -409,6 +415,7 @@ function SettingsGroupPanel({ anchorId, defaultExpanded, focusKey, + flat = false, subgroupMeta = SUBGROUP_META, text, }: { @@ -420,6 +427,7 @@ function SettingsGroupPanel({ anchorId?: string; defaultExpanded?: boolean; focusKey?: string; + flat?: boolean; subgroupMeta?: Record; text: SettingsEditorText; }) { @@ -502,6 +510,8 @@ function SettingsGroupPanel({ }, }); + const emailGroup = group.settings.some((setting) => setting.group === "email"); + const emailProvider = savedEmailProvider(serverValues); const visibleSettings = group.settings.filter((s) => isVisible(s, renderedValues)); // Reflect only the settings the operator can actually see: a setting hidden // by its showWhen predicate (e.g. the MapTiler key while the provider is @@ -509,6 +519,201 @@ function SettingsGroupPanel({ // contradicts an otherwise-editable field. const hasEnvOverrides = visibleSettings.some((s) => s.envOverride); + const contents = ( + <> + {focusKey && + group.settings.some( + (setting) => setting.key === focusKey && !isVisible(setting, renderedValues), + ) && ( + + This setting is hidden by the current policy or provider selection. Change that + selection only if you intend to change the effective configuration. + + )} + + {conflicts.length > 0 && ( + draft.keepEdits(group.settings.map((setting) => setting.key))} + disabled={save.isPending} + > + Keep my edits + + } + > + Saved values changed for {conflicts.join(", ")}. Review or discard your edits before + saving. + + )} + {emailGroup && ( + + Saved provider: {EMAIL_PROVIDER_NAMES[emailProvider]}. Priority: EmailLabs → Lettermint + → SMTP. Other configured providers remain available as fallbacks; send a test message to + check delivery. + + )} + {groupBySubgroup(visibleSettings).map((section) => ( + setting.key === focusKey)} + selected={emailGroup && section.subgroup === emailProvider} + configured={configuredEmailProvider(section.subgroup, serverValues)} + > + {(!emailGroup || + !["emaillabs", "lettermint", "smtp"].includes(section.subgroup ?? "")) && + section.subgroup && + subgroupMeta[section.subgroup] && ( + + + {subgroupMeta[section.subgroup].label} + + {subgroupMeta[section.subgroup].description && ( + + {subgroupMeta[section.subgroup].description} + + )} + + )} + + {section.settings.map((s) => ( + + + + s.secret + ? setSecretValues((previous) => ({ ...previous, [s.key]: v })) + : draft.setValues((previous) => ({ ...previous, [s.key]: v })) + } + onValidityChange={(valid) => + setInvalidKeys((current) => { + const next = new Set(current); + if (valid) next.delete(s.key); + else next.add(s.key); + return next; + }) + } + text={text} + /> + {s.envOverride && s.envVar && ( + + )} + + {s.source === "database" && !s.envOverride && ( + + {text.databaseSource} + + )} + + ))} + + + ))} + + + + {dirtyKeys.length > 0 && ( + + )} + {extra} + {save.isError && ( + + {text.saveFailed} + + )} + + + + ); + if (flat) + return ( + + {contents} + + ); return ( - {focusKey && - group.settings.some( - (setting) => setting.key === focusKey && !isVisible(setting, renderedValues), - ) && ( - - This setting is hidden by the current policy or provider selection. Change that - selection only if you intend to change the effective configuration. - - )} - - {conflicts.length > 0 && ( - draft.keepEdits(group.settings.map((setting) => setting.key))} - disabled={save.isPending} - > - Keep my edits - - } - > - Saved values changed for {conflicts.join(", ")}. Review or discard your edits before - saving. - - )} - {groupBySubgroup(visibleSettings).map((section) => ( - - {section.subgroup && subgroupMeta[section.subgroup] && ( - - - {subgroupMeta[section.subgroup].label} - - {subgroupMeta[section.subgroup].description && ( - - {subgroupMeta[section.subgroup].description} - - )} - - )} - - {section.settings.map((s) => ( - - - - s.secret - ? setSecretValues((previous) => ({ ...previous, [s.key]: v })) - : draft.setValues((previous) => ({ ...previous, [s.key]: v })) - } - onValidityChange={(valid) => - setInvalidKeys((current) => { - const next = new Set(current); - if (valid) next.delete(s.key); - else next.add(s.key); - return next; - }) - } - text={text} - /> - {s.envOverride && s.envVar && ( - - )} - - {s.source === "database" && !s.envOverride && ( - - {text.databaseSource} - - )} - - ))} - - - ))} - - - - {dirtyKeys.length > 0 && ( - - )} - {extra} - {save.isError && ( - - {text.saveFailed} - - )} - - + {contents} ); @@ -956,6 +990,7 @@ export function SystemSettings({ text = DEFAULT_TEXT, onSettingsSaved, focusKey, + flatSections = false, }: { sections?: readonly SettingsEditorSection[]; showHeader?: boolean; @@ -965,6 +1000,7 @@ export function SystemSettings({ text?: SettingsEditorText; onSettingsSaved?: () => void | Promise; focusKey?: string; + flatSections?: boolean; } = {}) { const env = useEnv(); const draftStore = useContext(AdminDraftContext); @@ -1082,6 +1118,7 @@ export function SystemSettings({ description={section?.description} defaultExpanded={section?.defaultExpanded} focusKey={focusKey} + flat={flatSections} subgroupMeta={subgroupMeta} text={text} extra={ diff --git a/apps/web/src/components/admin/settings/platformScope.test.ts b/apps/web/src/components/admin/settings/platformScope.test.ts new file mode 100644 index 000000000..27c84e64c --- /dev/null +++ b/apps/web/src/components/admin/settings/platformScope.test.ts @@ -0,0 +1,86 @@ +import type { AdminPlatformPreview } from "@openmapx/core/api-contracts"; +import { expect, it } from "vitest"; +import { buildPlatformCandidate, platformPreviewWithinScope } from "./platformScope"; + +const baseline = { + configuration: { version: 1, domain: { source: "database", value: "maps.example.test" } }, + values: { + domain: "maps.example.test", + acmeEmail: "admin@example.test", + releaseSelector: "main", + defaultRegion: null, + backupRetentionDays: 30, + }, + sources: { + domain: "database", + acmeEmail: "host", + releaseSelector: "host", + defaultRegion: "host", + backupRetentionDays: "host", + }, +} as const; +const candidate = buildPlatformCandidate( + baseline.configuration, + { + backupRetentionDays: { source: "database", value: 60 }, + domain: { source: "database", value: "evil.test" }, + }, + "retention", +); +const preview = { + ...baseline, + configuration: candidate, + values: { ...baseline.values, backupRetentionDays: 60 }, + sources: { ...baseline.sources, backupRetentionDays: "database" }, + oldOrigin: "https://maps.example.test", + newOrigin: "https://maps.example.test", +} as AdminPlatformPreview; +it("overlays only a scope's edited keys onto the freshly captured full configuration", () => { + expect(candidate).toEqual({ + ...baseline.configuration, + backupRetentionDays: { source: "database", value: 60 }, + }); + expect(Object.hasOwn(candidate, "defaultRegion")).toBe(false); + expect(platformPreviewWithinScope(preview, baseline, candidate, "retention")).toBe(true); +}); +it.each(["configuration", "values", "sources"] as const)( + "refuses hidden-field changes in %s", + (part) => { + const changed = structuredClone(preview); + if (part === "configuration") changed.configuration.domain = { source: "host" }; + if (part === "values") changed.values.domain = "other.example.test"; + if (part === "sources") changed.sources.domain = "host"; + expect(platformPreviewWithinScope(changed, baseline, candidate, "retention")).toBe(false); + }, +); +it("refuses an unrelated origin change or release selection in retention", () => { + expect( + platformPreviewWithinScope( + { ...preview, newOrigin: "https://other.test" }, + baseline, + candidate, + "retention", + ), + ).toBe(false); + expect( + platformPreviewWithinScope( + { ...preview, release: {} } as AdminPlatformPreview, + baseline, + candidate, + "retention", + ), + ).toBe(false); +}); +it("keeps omitted fields omitted and supports explicitly returning only retention to the host", () => { + expect( + buildPlatformCandidate( + { ...candidate, defaultRegion: { source: "database", value: "europe/germany" } }, + { backupRetentionDays: { source: "host" } }, + "retention", + ), + ).toEqual({ + ...baseline.configuration, + defaultRegion: { source: "database", value: "europe/germany" }, + backupRetentionDays: { source: "host" }, + }); +}); diff --git a/apps/web/src/components/admin/settings/platformScope.ts b/apps/web/src/components/admin/settings/platformScope.ts new file mode 100644 index 000000000..0cf32159c --- /dev/null +++ b/apps/web/src/components/admin/settings/platformScope.ts @@ -0,0 +1,50 @@ +import type { AdminPlatformPreview } from "@openmapx/core/api-contracts"; +export type PlatformScope = "deployment" | "release" | "retention"; +export type PlatformFieldName = keyof AdminPlatformPreview["values"]; +export type PlatformConfigurationValue = AdminPlatformPreview["configuration"]; +export type PlatformDraft = Partial< + Record +>; +export const PLATFORM_SCOPE_FIELDS = { + deployment: ["domain", "acmeEmail", "defaultRegion"], + release: ["releaseSelector"], + retention: ["backupRetentionDays"], +} as const satisfies Record; +export const PLATFORM_SCOPE_HREF = { + deployment: "/admin/system/deployment", + release: "/admin/system/updates", + retention: "/admin/system/backups", +} as const; +export function buildPlatformCandidate( + baseline: PlatformConfigurationValue, + edits: PlatformDraft, + scope: PlatformScope, +): PlatformConfigurationValue { + const candidate = { ...baseline }; + for (const key of PLATFORM_SCOPE_FIELDS[scope]) + if (Object.hasOwn(edits, key) && edits[key] != null) + Object.assign(candidate, { [key]: edits[key] }); + return candidate; +} +const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +/** Every hidden ownership/value remains exactly as captured, even if the server normalizes input. */ +export function platformPreviewWithinScope( + preview: AdminPlatformPreview, + baseline: Pick, + candidate: PlatformConfigurationValue, + scope: PlatformScope, +): boolean { + const fields: readonly PlatformFieldName[] = PLATFORM_SCOPE_FIELDS[scope]; + for (const key of Object.keys(baseline.values) as PlatformFieldName[]) { + if (fields.includes(key)) continue; + if ( + !equal(preview.configuration[key], candidate[key]) || + !equal(preview.values[key], baseline.values[key]) || + !equal(preview.sources[key], baseline.sources[key]) + ) + return false; + } + if (scope !== "deployment" && preview.oldOrigin !== preview.newOrigin) return false; + if (scope !== "release" && preview.release !== undefined) return false; + return true; +} diff --git a/apps/web/src/components/admin/shared/AdminNavigationContext.tsx b/apps/web/src/components/admin/shared/AdminNavigationContext.tsx new file mode 100644 index 000000000..f3e02c695 --- /dev/null +++ b/apps/web/src/components/admin/shared/AdminNavigationContext.tsx @@ -0,0 +1,9 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { AdminNavigationAccess } from "@/lib/admin/adminNavigation"; +/** Server-resolved presentation availability; API guards still authorize every operation. */ +export const AdminNavigationContext = createContext({ + role: "admin", + selfHosted: null, +}); +export const useAdminNavigationAccess = () => useContext(AdminNavigationContext); diff --git a/apps/web/src/components/admin/shared/AdminPageHeader.tsx b/apps/web/src/components/admin/shared/AdminPageHeader.tsx index 9fea6a04b..04dfb1289 100644 --- a/apps/web/src/components/admin/shared/AdminPageHeader.tsx +++ b/apps/web/src/components/admin/shared/AdminPageHeader.tsx @@ -10,6 +10,7 @@ interface AdminPageHeaderProps { subtitle?: ReactNode; /** Right-aligned action(s): buttons, refresh icon, status chip. */ actions?: ReactNode; + badges?: ReactNode; /** Optional parent route for detail and workflow pages. */ backHref?: string; backLabel?: string; @@ -19,6 +20,7 @@ export function AdminPageHeader({ title, subtitle, actions, + badges, backHref, backLabel = "Back", }: AdminPageHeaderProps) { @@ -40,9 +42,12 @@ export function AdminPageHeader({ )} - - {title} - + + + {title} + + {badges} + {subtitle && ( {subtitle} diff --git a/apps/web/src/components/admin/shared/AdminThemeProvider.tsx b/apps/web/src/components/admin/shared/AdminThemeProvider.tsx index b8e4b0288..7aace6b23 100644 --- a/apps/web/src/components/admin/shared/AdminThemeProvider.tsx +++ b/apps/web/src/components/admin/shared/AdminThemeProvider.tsx @@ -1,5 +1,6 @@ "use client"; +import GlobalStyles from "@mui/material/GlobalStyles"; import { alpha, createTheme, type Theme, ThemeProvider, useTheme } from "@mui/material/styles"; import { type ReactNode, useMemo } from "react"; @@ -29,7 +30,12 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) { MuiButton: { defaultProps: { size: "small", disableElevation: true }, styleOverrides: { - root: { minHeight: 34, borderRadius: 8, paddingInline: 12 }, + root: { + minHeight: 34, + borderRadius: 8, + paddingInline: 12, + "@media (pointer: coarse)": { minHeight: 44 }, + }, }, }, MuiCard: { @@ -66,9 +72,30 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) { styleOverrides: { root: { padding: "18px 20px 8px", fontSize: "1.125rem" } }, }, MuiFormControl: { defaultProps: { size: "small" } }, + MuiFormControlLabel: { + styleOverrides: { root: { "@media (pointer: coarse)": { minHeight: 44 } } }, + }, + MuiInputBase: { + styleOverrides: { root: { "@media (pointer: coarse)": { minHeight: 44 } } }, + }, + MuiListItemButton: { + styleOverrides: { root: { "@media (pointer: coarse)": { minHeight: 44 } } }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + "@media (prefers-reduced-motion: reduce)": { transition: "none !important" }, + }, + }, + }, MuiIconButton: { defaultProps: { size: "small" }, - styleOverrides: { root: { borderRadius: 8 } }, + styleOverrides: { + root: { + borderRadius: 8, + "@media (pointer: coarse)": { minHeight: 44, minWidth: 44 }, + }, + }, }, MuiPaper: { defaultProps: { elevation: 0 }, @@ -153,5 +180,20 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) { [parentTheme], ); - return {children}; + return ( + + + {children} + + ); } diff --git a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx index 20646d045..c163d052e 100644 --- a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx +++ b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx @@ -39,10 +39,15 @@ export function AdminTopicNavigation({ size="small" fullWidth label={t("topic")} - value={entries.find((entry) => entry.id === active)?.href ?? entries[0].href} + value={entries.find((entry) => entry.id === active)?.href ?? ""} onChange={(event) => router.push(event.target.value)} sx={{ display: { lg: "none" }, mb: 1 }} > + {!entries.some((entry) => entry.id === active) && ( + + {t("chooseTopic")} + + )} {entries.map((entry) => ( {t(`destinations.${entry.id}.label`)} diff --git a/apps/web/src/components/admin/shared/MetaRow.tsx b/apps/web/src/components/admin/shared/MetaRow.tsx index 65268aa85..7198226a0 100644 --- a/apps/web/src/components/admin/shared/MetaRow.tsx +++ b/apps/web/src/components/admin/shared/MetaRow.tsx @@ -15,23 +15,26 @@ export function MetaRow({ label, value, labelWidth = 130 }: MetaRowProps) { if (value === null || value === undefined || value === "") return null; return ( {label} - + {typeof value === "string" ? {value} : value} diff --git a/apps/web/src/components/admin/system/SystemMaintenance.tsx b/apps/web/src/components/admin/system/SystemMaintenance.tsx index 1adc67d19..199703991 100644 --- a/apps/web/src/components/admin/system/SystemMaintenance.tsx +++ b/apps/web/src/components/admin/system/SystemMaintenance.tsx @@ -208,7 +208,13 @@ export function SystemMaintenanceProvider({ children }: PropsWithChildren) { {children} ); } -export function SystemMaintenance({ section }: { section?: "updates" | "diagnostics" } = {}) { +export function SystemMaintenance({ + section, + showHeader = true, +}: { + section?: "updates" | "diagnostics"; + showHeader?: boolean; +} = {}) { const { apiUrl, client } = useActivityClient(); const queryClient = useQueryClient(); const register = useOptionalAdminJobRegistration(); @@ -503,21 +509,23 @@ export function SystemMaintenance({ section }: { section?: "updates" | "diagnost return ( - - View activity - - } - /> + {showHeader && ( + + View activity + + } + /> + )} {status && !status.deployment.maintenanceReady && ( diff --git a/apps/web/src/lib/admin/adminSearch.test.ts b/apps/web/src/lib/admin/adminSearch.test.ts index 284fb9522..79d6deb80 100644 --- a/apps/web/src/lib/admin/adminSearch.test.ts +++ b/apps/web/src/lib/admin/adminSearch.test.ts @@ -13,8 +13,8 @@ it("addresses the owning legal section without creating a duplicate policy edito it.each([ ["SMTP", "/admin/settings/email?field=smtpHost"], ["GTFS", "/admin/data#transit-sources"], - ["backup expiry", "/admin/system/deployment#backup-retention"], - ["release channel", "/admin/system/deployment#release-selector"], + ["backup expiry", "/admin/system/backups#backup-retention"], + ["release channel", "/admin/system/updates#release-selector"], ])("finds the current owning control for %s", (query, href) => { expect(searchAdminEntries(buildAdminSearchEntries(access), query)[0]?.href).toBe(href); }); diff --git a/apps/web/src/lib/admin/adminSearch.ts b/apps/web/src/lib/admin/adminSearch.ts index f1469def7..64b4538d1 100644 --- a/apps/web/src/lib/admin/adminSearch.ts +++ b/apps/web/src/lib/admin/adminSearch.ts @@ -71,14 +71,14 @@ export function buildAdminSearchEntries( id: "backup-retention", title: "Backup retention", description: "Backup expiry and retention days", - href: "/admin/system/deployment#backup-retention", + href: "/admin/system/backups#backup-retention", keywords: ["backup expiry", "Aufbewahrung", "Sicherung"], }, { id: "release-selector", title: "Release channel", description: "Release selector and update channel", - href: "/admin/system/deployment#release-selector", + href: "/admin/system/updates#release-selector", keywords: ["release channel", "Version", "Kanal"], }, ); diff --git a/docs/docs/administration/admin-panel.md b/docs/docs/administration/admin-panel.md index 7a1aea77f..e34f1c89d 100644 --- a/docs/docs/administration/admin-panel.md +++ b/docs/docs/administration/admin-panel.md @@ -47,8 +47,9 @@ they do not use this HTTP shortcut. ## Self-hosted vs. hosted -Services and Maintenance appear when the deployment exposes Docker host control -through the operations agent. On a deployment without that control plane, use +Host-operation topics in System appear for self-hosted or unknown deployments. +Explicitly hosted deployments retain Cache and Configuration transfer, while direct +entry into host topics shows an availability explanation. On a deployment without that control plane, use the host's deployment tooling for container operations. Docker sockets and registry credentials belong to the operations agent. @@ -164,19 +165,19 @@ See [Monitoring](./monitoring.md). ### Settings -Edit instance details, email, map settings, data-use policy and legal/controller -facts. Valid environment values take precedence over saved values, then defaults. +Edit instance details, email, map settings and data-use policy by topic. Legal and +controller facts belong to Privacy setup. Valid environment values take precedence over saved values, then defaults. Environment overrides appear read-only; empty or invalid environment values do not necessarily override a valid saved setting. JSON export excludes secrets. -The Authentication group exposes saved policy fields, but the current Better -Auth configuration does not consume its email-verification, session-duration, -two-factor or minimum-password-length values. Do not rely on those controls to -change enforcement. See [Authentication methods](./users-and-access.md#authentication-methods). +Authentication edits the effective four-field policy as one reviewed change. +Existing sessions keep their original expiry; disabling new 2FA enrollment keeps +existing factors and sign-in challenges. Environment-owned email verification is +read-only. See [Authentication methods](./users-and-access.md#authentication-methods). ### Privacy setup -Full administrators use `/admin/privacy-setup` for controller details, email, +Full administrators use `/admin/privacy-setup` for controller details, email readiness, retention, source declarations and independent reviews. Privacy administrators handle cases and inspect readiness through privacy casework. A release install does not approve generation for the deployment. See diff --git a/docs/docs/administration/monitoring.md b/docs/docs/administration/monitoring.md index c7a4798cd..355f73159 100644 --- a/docs/docs/administration/monitoring.md +++ b/docs/docs/administration/monitoring.md @@ -187,7 +187,7 @@ There are two log surfaces, and they answer different questions. ### Service container logs For "what is this backend service doing right now," open a service's **Logs** tab -(or the **Logs** button) under `/admin/system/services/`. It streams that container's +(or **More actions → Logs**) under `/admin/system/services/`. It streams that container's output live, tailing the most recent lines. It uses the same output as the equivalent `services logs` CLI command, in the browser. This is the place to watch a build finish or diagnose a container that won't come up. Full coverage is in diff --git a/docs/docs/administration/services-administration.md b/docs/docs/administration/services-administration.md index a9a79338f..f339fbea0 100644 --- a/docs/docs/administration/services-administration.md +++ b/docs/docs/administration/services-administration.md @@ -58,6 +58,7 @@ Container state is not a functional health check. Use **Run Service Check** or ### Service selection +Open **System → Deployment** (`/admin/system/deployment`) to edit persistent roots. The selection contains the requested root services and their expanded dependencies. It is shared with `openmapx services selected` and the CLI's `services enable` workflow. @@ -81,7 +82,7 @@ Choose an **action preset** to select the same root group as the CLI `--preset` option. Presets show missing installed members and never save selection by themselves. The server expands and validates preset roots; normal action restrictions still apply. -Each row identifies an enabled root, a required dependency, or a disabled service. +The Deployment editor’s **Services and root controls** disclosure identifies each enabled root, required dependency or disabled service. **Enable** adds only that service to the existing roots; **Remove root** removes only its explicit root entry. It may remain enabled when another root requires it. Required dependencies cannot be removed independently, and host-controlled selections stay @@ -91,8 +92,11 @@ locked. Selection writes use the same reviewed generation producer as the root e Tick the checkboxes beside one or more services and the bulk-action bar lets you **Start**, **Stop**, **Restart**, **Update**, or **Build** the selection in one -go, plus **Build All** and a **Run Service Check** that refreshes container -states. Updates offer **without dependency recreation**, corresponding to `--no-deps`. +go. Choose the action first; its Build or Update options appear alongside it. +**Run Service Check** stays available in the inventory header. **Build All** opens +a separate review with a captured list of enabled, buildable services and its build +options. Confirmation submits those exact IDs; a later inventory refresh cannot add +new targets to the request. Updates offer **without dependency recreation**, corresponding to `--no-deps`. Builds take an optional region, a "continue on errors" toggle, and MOTIS-only import (`--import`) for a selected MOTIS build or Build All with enabled, buildable MOTIS. Other actions cannot carry these options. Whole-stack starts explicitly name the @@ -121,12 +125,14 @@ request. Apply an update separately when you want services to use newer images. ## A single service in detail Open `/admin/system/services/`. Its header shows container state, selection and -lifecycle actions. The detail page has **Overview**, **Configuration**, **Credentials**, +a primary Start/Apply action and a labeled **More actions** menu for Stop, Restart +and Logs. The detail page has **Overview**, **Configuration**, **Credentials**, **Logs** and **Manifest** tabs. ### Overview -Review the service ID, version, author, licence, platform and source directory. +Open **Technical metadata** to inspect the ID, version, author, licence, platform +and source directory. Environment variables have a separate disclosure. The page also shows: | Field | Operator use | @@ -182,7 +188,7 @@ Opening the Logs tab (or the **Logs** button) shows the service's container output. Choose the last **100, 200, 500, or 1000 lines**, then use **Snapshot** for a completed read or **Follow** for live output. Follow duration ranges from 30 seconds to 15 minutes. Pause stops the current reader; Resume and Reconnect -start a fresh read with the selected options. Closing the drawer stops its reader. +start a fresh read with the selected options. Switching away from the Logs tab or closing the legacy drawer stops its reader. The browser retains at most **1000 lines and 256 KiB**, including an incomplete line, and reports when earlier output is discarded. Snapshot output also has a @@ -213,7 +219,7 @@ rather than trying to update the running API as an ordinary service job. ## Compose preview -Open `/admin/system/deployment` to inspect the generated Compose YAML for the +Open **Compose and host recovery** on `/admin/system/deployment` to inspect the generated Compose YAML for the current selection and resolved configuration. Do not edit generated files. Use the form or source configuration, then render again. @@ -495,3 +501,19 @@ If cleanup reports a busy or unrecoverable writer after a crash: Clock rollback, mismatched signed files or missing completion evidence need separate supervised recovery. Do not repair those by editing signatures, copying records onto new inodes or repeatedly deleting guards. + +## Platform configuration topics + +Deployment owns domain, ACME contact and the default acquisition region. Updates +owns the release selector; Backups owns backup retention. Each editor overlays +only its edited fields onto freshly read full configuration and rejects a preview +that changes another topic's ownership, effective values or origin. Host-owned +values require explicit adoption. Returning a field to the host keeps its current +host source; an empty managed region is invalid. + +Previewing does not save or restart services. Review the affected services and +origin, then satisfy any required acknowledgments and alternate-login proof. +Pending platform work blocks every topic. Unknown outcomes retain the original +job and request identity; inspect Activity and the disclosed host recovery commands +before retrying. Ordinary drafts survive topic changes; confirmations and proof do +not transfer between topics. diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 5dcc3564e..7b4b5209f 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -5026,6 +5026,10 @@ "retry": "Erneut versuchen", "partialSearch": "Einige Metadaten konnten nicht geladen werden. Die Seitenziele bleiben verfügbar.", "noSearchResults": "Kein passendes Verwaltungsziel.", + "hostUnavailable": "Host-Vorgänge sind bei dieser gehosteten Bereitstellung nicht verfügbar. Cache und Konfigurationsübertragung bleiben verfügbar.", + "chooseTopic": "Thema auswählen", + "authorityChanged": "Der Administratorzugriff hat sich geändert. Lade die Seite neu, um Konto und Berechtigungen zu bestätigen.", + "reload": "Neu laden", "hostedData": "Hostverwaltete Datenaktionen sind in dieser gehosteten Bereitstellung nicht verfügbar. Transit und Abdeckung bleiben verfügbar." }, "adminDrafts": { diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 84ab01996..7c61949d3 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -5026,6 +5026,10 @@ "retry": "Retry", "partialSearch": "Some metadata could not be loaded. Page destinations are still available.", "noSearchResults": "No matching administration destination.", + "hostUnavailable": "Host operations are unavailable on this hosted deployment. Cache and configuration transfer remain available.", + "chooseTopic": "Choose a topic", + "authorityChanged": "Administrator access changed. Reload to confirm the current account and permissions.", + "reload": "Reload", "hostedData": "Host-managed data operations are unavailable on this hosted deployment. Transit and Coverage remain available." }, "adminDrafts": { From bd0f15cd1a795620017d643e2ed9cad4e2788e2d Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 14:15:15 +0100 Subject: [PATCH 2/4] fix(admin): stop deferred platform writes after leaving the form --- .../settings/PlatformConfiguration.test.tsx | 17 +++++++++++++++++ .../admin/settings/PlatformConfiguration.tsx | 2 ++ 2 files changed, 19 insertions(+) diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx index 199fc9b98..832c30b58 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx @@ -153,6 +153,23 @@ it("does not preview an empty adopted default region", async () => { expect(screen.getByRole("button", { name: "Preview changes" })).toBeDisabled(); expect(mocks.preview).not.toHaveBeenCalled(); }); +it("does not start a platform write after leaving during the fresh-baseline read", async () => { + let resolve!: (value: unknown) => void; + mocks.get.mockResolvedValueOnce({ status: 200, data: { ...initial, pending: [] } }); + mocks.get.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const view = mount("retention"); + fireEvent.click(await screen.findByRole("button", { name: "Preview changes" })); + await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2)); + view.unmount(); + resolve({ status: 200, data: { ...initial, pending: [] } }); + await new Promise((done) => setTimeout(done, 0)); + expect(mocks.preview).not.toHaveBeenCalled(); +}); it("removes an old unscoped preview instead of loading it in a scoped owner", async () => { const key = "openmapx-platform-preview:http://admin.test:admin-a"; sessionStorage.setItem( diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx index e1b67a70d..9cbafbaf3 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx @@ -250,6 +250,8 @@ function PlatformForm({ "Platform state could not be refreshed. Reload before reviewing changes.", ); } + if (!owner.active) + throw new PlatformScopeError("The configuration view changed. Reopen it before continuing."); if (fresh.expectedCurrentRevisionId !== view.expectedCurrentRevisionId) throw new PlatformScopeError( "Saved platform configuration changed. Reload and review your draft.", From 44197c642a0a36004028164a3f7b425a7d48e0d3 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 14:20:59 +0100 Subject: [PATCH 3/4] test(admin): match platform review test to the local mock contract --- .../admin/settings/PlatformConfiguration.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx index 832c30b58..d4ed1cca1 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx @@ -155,12 +155,13 @@ it("does not preview an empty adopted default region", async () => { }); it("does not start a platform write after leaving during the fresh-baseline read", async () => { let resolve!: (value: unknown) => void; - mocks.get.mockResolvedValueOnce({ status: 200, data: { ...initial, pending: [] } }); - mocks.get.mockImplementation( - () => - new Promise((done) => { - resolve = done; - }), + let reads = 0; + mocks.get.mockImplementation(() => + ++reads === 1 + ? Promise.resolve({ status: 200, data: { ...initial, pending: [] } }) + : new Promise((done) => { + resolve = done; + }), ); const view = mount("retention"); fireEvent.click(await screen.findByRole("button", { name: "Preview changes" })); From f7f344b2157fa3ed2b03fcb0887d8304430991ec Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 14:31:07 +0100 Subject: [PATCH 4/4] fix(admin): label the unavailable mobile topic selection --- .../admin/AdminLayout.navigation.test.tsx | 1 + apps/web/src/components/admin/AdminLayout.tsx | 2 + .../admin/services/ComposePreview.tsx | 2 +- .../services/ServiceAdministration.test.tsx | 42 +++++++++- .../admin/services/ServiceBulkActions.tsx | 13 ++-- .../admin/services/ServiceCatalog.tsx | 4 +- .../admin/services/ServiceDetail.tsx | 2 +- .../settings/PlatformConfiguration.test.tsx | 77 +++++++++++++++++++ .../admin/settings/PlatformConfiguration.tsx | 32 +++++++- .../settings/PlatformPreviewLifecycle.tsx | 36 +++++++++ .../admin/shared/AdminThemeProvider.tsx | 24 +++++- .../admin/shared/AdminTopicNavigation.tsx | 1 + .../admin/system/SystemMaintenance.tsx | 6 +- packages/core/src/api/contracts/index.ts | 2 +- packages/i18n/locales/de.json | 8 +- packages/i18n/locales/en.json | 8 +- 16 files changed, 238 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx diff --git a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx index 66af255de..9920a62ec 100644 --- a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx +++ b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx @@ -38,6 +38,7 @@ it.each([false, null])("guards direct host-topic entry for deployment %s", (self { wrapper: createQueryWrapper() }, ); if (selfHosted === false) { + expect(screen.getByRole("combobox", { name: "Topic" })).toHaveTextContent("Choose a topic"); expect(screen.queryByRole("button", { name: "Host mutation" })).toBeNull(); expect(screen.getAllByRole("link", { name: "Cache" }).length).toBeGreaterThan(0); } else expect(screen.getByRole("button", { name: "Host mutation" })).toBeInTheDocument(); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index 9ec9ea2a4..8535c03a5 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -18,6 +18,7 @@ import { useHydrated } from "@/lib/useHydrated"; import { AdminSidebar } from "./AdminSidebar"; import { AdminTopBar } from "./AdminTopBar"; import { TOPBAR_HEIGHT } from "./layoutConstants"; +import { PlatformPreviewLifecycle } from "./settings/PlatformPreviewLifecycle"; import { AdminNavigationContext } from "./shared/AdminNavigationContext"; import { AdminPageHeader } from "./shared/AdminPageHeader"; import { AdminThemeProvider } from "./shared/AdminThemeProvider"; @@ -80,6 +81,7 @@ function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutP className="omx-admin" sx={{ display: "flex", height: "100dvh", bgcolor: "background.default" }} > + setMobileOpen(false)} diff --git a/apps/web/src/components/admin/services/ComposePreview.tsx b/apps/web/src/components/admin/services/ComposePreview.tsx index 47379351f..e6100d452 100644 --- a/apps/web/src/components/admin/services/ComposePreview.tsx +++ b/apps/web/src/components/admin/services/ComposePreview.tsx @@ -178,7 +178,7 @@ function ComposePreviewView({ client, apiUrl }: ReturnType Compose Up starts the selected stack and applies ordinary services. It preserves existing Data Manager and operations-agent containers. Apply or update Data Manager from its service - page; apply agent changes through Settings or System Maintenance. + page; apply agent changes through System → Deployment or Updates. Full shutdown stops this panel and its operations agent. From the instance checkout on the diff --git a/apps/web/src/components/admin/services/ServiceAdministration.test.tsx b/apps/web/src/components/admin/services/ServiceAdministration.test.tsx index f0839d252..c518d4551 100644 --- a/apps/web/src/components/admin/services/ServiceAdministration.test.tsx +++ b/apps/web/src/components/admin/services/ServiceAdministration.test.tsx @@ -1,5 +1,7 @@ import { SERVICE_ACTIONS } from "@openmapx/core/ops"; +import { en } from "@openmapx/i18n"; import { QueryClientProvider } from "@tanstack/react-query"; +import { NextIntlClientProvider } from "next-intl"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { job } from "@/components/admin/activity/__tests__/fixtures"; import { AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; @@ -257,7 +259,9 @@ function mount(child: React.ReactNode) { caches.push(qc); const view = (next: React.ReactNode) => ( - {next} + + {next} + ); return { qc, view, ...render(view(child)) }; @@ -1172,3 +1176,39 @@ it("starts a new apply intent after another successful configuration save", asyn expect(keys).toHaveLength(2); expect(keys[0]).not.toBe(keys[1]); }); + +it("reviews and submits captured Build All targets in the established build order", async () => { + const ids = ["tileserver", "motis", "osrm"]; + const requests = setup((url) => + url.pathname === "/api/admin/services" + ? Response.json({ + acquisition, + services: ids.map((id) => ({ + id, + name: id, + version: "1", + quality: "built-in", + enabled: true, + isBuiltIn: true, + status: "not-running", + actions: availableActions, + provides: [], + consumes: [], + })), + capabilityInventory: { recognized: [], unrecognized: [] }, + summary: { running: 0, stopped: 3, unknown: 0, total: 3 }, + }) + : undefined, + ); + mount(); + await screen.findByRole("link", { name: "tileserver" }); + fireEvent.click(screen.getByRole("button", { name: "Build All" })); + fireEvent.click(await screen.findByRole("button", { name: "Build reviewed targets" })); + await waitFor(() => + expect(requests.some((r) => r.url.pathname.endsWith("/bulk-action"))).toBe(true), + ); + expect( + JSON.parse(String(requests.find((r) => r.url.pathname.endsWith("/bulk-action"))?.init.body)) + .serviceIds, + ).toEqual(["osrm", "motis", "tileserver"]); +}); diff --git a/apps/web/src/components/admin/services/ServiceBulkActions.tsx b/apps/web/src/components/admin/services/ServiceBulkActions.tsx index 2f2486cdb..e974dd4c0 100644 --- a/apps/web/src/components/admin/services/ServiceBulkActions.tsx +++ b/apps/web/src/components/admin/services/ServiceBulkActions.tsx @@ -11,6 +11,7 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; import type { AdminServiceSummary, AdminServicesBulkAction } from "@openmapx/core/api-contracts"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import Dialog from "@/components/ui/Dialog"; import { serviceActionReason } from "@/lib/admin/serviceAdmin"; @@ -211,18 +212,16 @@ export function ServiceBuildAllDialog({ onQueue: (payload: AdminServicesBulkAction) => Promise; onClose: () => void; }) { + const t = useTranslations("adminNavigation.buildAll"); const [region, setRegion] = useState(""); const [continueOnError, setContinueOnError] = useState(true); const [motisImport, setMotisImport] = useState(false); return ( - Build all enabled services + {t("title")} - - Build these {ids.length} captured targets: {ids.join(", ")}. The server enforces each - service's build prerequisites. Newly enabled services will not be added to this request. - + {t("targets", { count: ids.length, ids: ids.join(", ") })} diff --git a/apps/web/src/components/admin/services/ServiceCatalog.tsx b/apps/web/src/components/admin/services/ServiceCatalog.tsx index 78408176d..dc3e8d9ce 100644 --- a/apps/web/src/components/admin/services/ServiceCatalog.tsx +++ b/apps/web/src/components/admin/services/ServiceCatalog.tsx @@ -22,6 +22,7 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { listAdminServicePresets, unwrapAdminResponse } from "@openmapx/core/api-admin-sdk"; import type { AdminServiceSummary, AdminServicesBulkAction } from "@openmapx/core/api-contracts"; +import { compareServiceBuildOrder } from "@openmapx/core/api-contracts"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; @@ -305,7 +306,8 @@ function ServiceCatalogView() { setBuildAllTargets( services .filter((service) => service.enabled && service.actions?.build.available) - .map((service) => service.id), + .map((service) => service.id) + .sort(compareServiceBuildOrder), ) } disabled={ diff --git a/apps/web/src/components/admin/services/ServiceDetail.tsx b/apps/web/src/components/admin/services/ServiceDetail.tsx index 6c9ef7ebe..8072cdf00 100644 --- a/apps/web/src/components/admin/services/ServiceDetail.tsx +++ b/apps/web/src/components/admin/services/ServiceDetail.tsx @@ -666,7 +666,7 @@ function ServiceDetailView({ id }: ServiceDetailProps) { )} {["app-api", "traefik", "ops-agent"].includes(manifest.id) && ( )} {manifest.id === "dawarich-app" && } diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx index d4ed1cca1..13a2dc713 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.test.tsx @@ -1,7 +1,10 @@ import { QueryClientProvider } from "@tanstack/react-query"; +import { useContext } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { AdminDraftContext, AdminDraftProvider } from "@/lib/admin/AdminDraftProvider"; import { cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; import { PlatformConfiguration } from "./PlatformConfiguration"; +import { PlatformPreviewLifecycle } from "./PlatformPreviewLifecycle"; import type { PlatformScope } from "./platformScope"; const mocks = vi.hoisted(() => ({ get: vi.fn(), preview: vi.fn(), apply: vi.fn(), job: vi.fn() })); @@ -316,3 +319,77 @@ it("retains the exact unexpired actor-bound preview across the alternate-login r expect(await screen.findByText("Review platform changes")).toBeVisible(); expect(mocks.preview).toHaveBeenCalledTimes(1); }); + +it.each([false, true])( + "clears retained platform previews on global discard while the form is hidden=%s", + async (hide) => { + function Discard() { + const store = useContext(AdminDraftContext); + return ( + + ); + } + const cache = createTestQueryClient(); + caches.push(cache); + const tree = (show: boolean) => ( + + + + + {show && } + + + ); + const view = render(tree(true)); + fireEvent.click(await screen.findByRole("button", { name: "Preview changes" })); + await screen.findByText("Review platform changes"); + const key = "openmapx-platform-preview:http://admin.test:admin-a:retention"; + expect(sessionStorage.getItem(key)).not.toBeNull(); + if (hide) view.rerender(tree(false)); + fireEvent.click(screen.getByRole("button", { name: "Discard all" })); + expect(sessionStorage.getItem(key)).toBeNull(); + if (hide) view.rerender(tree(true)); + expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).toBeNull(); + }, +); +it("does not revive a preview that arrives after global discard", async () => { + let resolve!: (value: unknown) => void; + mocks.preview.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + function Discard() { + const store = useContext(AdminDraftContext); + return ( + + ); + } + const cache = createTestQueryClient(); + caches.push(cache); + render( + + + + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Preview changes" })); + await waitFor(() => expect(mocks.preview).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: "Discard all" })); + resolve({ + status: 200, + data: { preview, expiresAt: new Date(Date.now() + 600000).toISOString() }, + }); + await new Promise((done) => setTimeout(done, 0)); + expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).toBeNull(); + expect( + sessionStorage.getItem("openmapx-platform-preview:http://admin.test:admin-a:retention"), + ).toBeNull(); +}); diff --git a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx index 9cbafbaf3..8dd208ffd 100644 --- a/apps/web/src/components/admin/settings/PlatformConfiguration.tsx +++ b/apps/web/src/components/admin/settings/PlatformConfiguration.tsx @@ -23,6 +23,7 @@ import { adminPlatformPreviewRetainedSchema, } from "@openmapx/core/api-contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { AdminJobLink } from "@/components/admin/activity/AdminJobLink"; import { useAccountAdmin } from "@/lib/admin/accountAdmin"; @@ -36,6 +37,7 @@ import { } from "../activity/activityQueries"; import { JobDetail } from "../activity/JobDetail"; import { AdminReauthenticationDialog } from "../users/AdminReauthenticationDialog"; +import { usePlatformPreviewDiscard } from "./PlatformPreviewLifecycle"; import { buildPlatformCandidate, PLATFORM_SCOPE_FIELDS, @@ -220,6 +222,7 @@ function PlatformForm({ [reauth, setReauth] = useState(false), [accepted, setAccepted] = useState(), [, tick] = useState(0); + const previewEpoch = useRef(0); const previewKey = useRef(null), applyKey = useRef(null); const acceptedJob = useQuery({ @@ -233,11 +236,16 @@ function PlatformForm({ const pending = view.pending ?? [], busy = pending.length > 0 || (!!accepted && !observedTerminal); const clearPreview = () => { + previewEpoch.current++; setRetained(null); setConfirmations(emptyConfirmations); setProof(undefined); setReauth(false); - sessionStorage.removeItem(storageKey); + try { + sessionStorage.removeItem(storageKey); + } catch { + /* Storage is optional for local discard. */ + } previewKey.current = null; applyKey.current = null; }; @@ -265,7 +273,10 @@ function PlatformForm({ const previewMutation = useMutation({ retry: false, mutationFn: async () => { + const epoch = previewEpoch.current; const fresh = await freshView(); + if (epoch !== previewEpoch.current) + throw new PlatformScopeError("Draft changed before the preview started."); const edits = Object.fromEntries( ordinaryDraft.dirtyKeys.map((key) => [key, ordinaryDraft.values[key]]), ) as PlatformDraft; @@ -290,13 +301,13 @@ function PlatformForm({ throw new PlatformScopeError( "The preview changes a field owned by another topic. Reload before continuing.", ); - return { value, fresh }; + return { value, fresh, epoch }; }, onError: () => { if (owner.active) previewKey.current = null; }, - onSuccess: ({ value, fresh }) => { - if (!owner.active) return; + onSuccess: ({ value, fresh, epoch }) => { + if (!owner.active || epoch !== previewEpoch.current) return; queryClient.setQueryData(["admin", "platform", apiUrl, actorId], fresh); setRetained(value); sessionStorage.setItem(storageKey, JSON.stringify(value)); @@ -347,6 +358,11 @@ function PlatformForm({ clearPreview(); }, }); + usePlatformPreviewDiscard(scope, () => { + clearPreview(); + previewMutation.reset(); + applyMutation.reset(); + }); useEffect(() => { if (!retained) return; const timeout = setTimeout( @@ -378,6 +394,14 @@ function PlatformForm({ expired = !!retained && Date.parse(retained.expiresAt) <= Date.now(); return ( + {scope === "deployment" && ( + + Release selector: {view.values.releaseSelector} + + + )} Host-owned values are locked until you explicitly adopt them. Returning a field to the host uses its current environment value. Previewing does not save or restart services. diff --git a/apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx b/apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx new file mode 100644 index 000000000..f5ed06e9e --- /dev/null +++ b/apps/web/src/components/admin/settings/PlatformPreviewLifecycle.tsx @@ -0,0 +1,36 @@ +"use client"; +import { useContext, useEffect } from "react"; +import { AdminDraftContext } from "@/lib/admin/AdminDraftProvider"; +import { PLATFORM_SCOPE_FIELDS, type PlatformScope } from "./platformScope"; + +const entryId = (scope: PlatformScope) => JSON.stringify([`platform:${scope}`, "platform-v1"]); +/** Retained metadata belongs to its ordinary draft, including while its topic is closed. */ +export function PlatformPreviewLifecycle({ apiUrl, actorId }: { apiUrl: string; actorId: string }) { + const store = useContext(AdminDraftContext); + useEffect( + () => + store?.subscribeDiscard(({ id, reason }) => { + if (reason === "reauthentication") return; + for (const scope of Object.keys(PLATFORM_SCOPE_FIELDS) as PlatformScope[]) + if (!id || id === entryId(scope)) { + try { + sessionStorage.removeItem(`openmapx-platform-preview:${apiUrl}:${actorId}:${scope}`); + } catch { + /* A missing storage area cannot keep a local reviewed form alive. */ + } + } + }), + [store, apiUrl, actorId], + ); + return null; +} +export function usePlatformPreviewDiscard(scope: PlatformScope, discard: () => void) { + const store = useContext(AdminDraftContext); + useEffect( + () => + store?.subscribeDiscard(({ id, reason }) => { + if (reason !== "reauthentication" && (!id || id === entryId(scope))) discard(); + }), + [store, scope, discard], + ); +} diff --git a/apps/web/src/components/admin/shared/AdminThemeProvider.tsx b/apps/web/src/components/admin/shared/AdminThemeProvider.tsx index 7aace6b23..6062dc808 100644 --- a/apps/web/src/components/admin/shared/AdminThemeProvider.tsx +++ b/apps/web/src/components/admin/shared/AdminThemeProvider.tsx @@ -60,7 +60,29 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) { }, }, MuiDialog: { - styleOverrides: { paper: { borderRadius: 16 } }, + styleOverrides: { + paper: { + borderRadius: 16, + "@media (prefers-reduced-motion: reduce)": { + transition: "none !important", + animation: "none !important", + }, + }, + root: { + "@media (prefers-reduced-motion: reduce)": { + "& *": { transition: "none !important", animation: "none !important" }, + }, + }, + }, + }, + MuiPopover: { + styleOverrides: { + root: { + "@media (prefers-reduced-motion: reduce)": { + "& *": { transition: "none !important", animation: "none !important" }, + }, + }, + }, }, MuiDialogActions: { styleOverrides: { root: { padding: "8px 20px 16px" } }, diff --git a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx index c163d052e..83f9a6749 100644 --- a/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx +++ b/apps/web/src/components/admin/shared/AdminTopicNavigation.tsx @@ -39,6 +39,7 @@ export function AdminTopicNavigation({ size="small" fullWidth label={t("topic")} + slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }} value={entries.find((entry) => entry.id === active)?.href ?? ""} onChange={(event) => router.push(event.target.value)} sx={{ display: { lg: "none" }, mb: 1 }} diff --git a/apps/web/src/components/admin/system/SystemMaintenance.tsx b/apps/web/src/components/admin/system/SystemMaintenance.tsx index 199703991..4b1e46633 100644 --- a/apps/web/src/components/admin/system/SystemMaintenance.tsx +++ b/apps/web/src/components/admin/system/SystemMaintenance.tsx @@ -558,9 +558,9 @@ export function SystemMaintenance({ Check reads the configured release manifest without downloading images or restarting services. Update downloads that exact checked release before replacing any container. - Change the configured channel under Platform configuration in Settings. Clearing the - pin preserves that channel and all running containers; the next configuration action - may resolve the configured channel again. + Change the configured channel in the release selection above. Clearing the pin + preserves that channel and all running containers; the next configuration action may + resolve the configured channel again.