From 03672b59f67404d32cd40ad3b30e8ef3cfa43475 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 14:31:45 +0100 Subject: [PATCH 1/3] feat(admin): organize data workflows and privacy casework --- .../(full-admin)/data/operations/page.tsx | 4 + .../app/admin/(full-admin)/data/osm/page.tsx | 4 + .../admin/(full-admin)/data/overture/page.tsx | 4 + .../src/app/admin/(full-admin)/data/page.tsx | 6 +- .../admin/(full-admin)/data/search/page.tsx | 4 + .../(full-admin)/data/transfers/page.tsx | 4 + .../admin/(full-admin)/data/transit/page.tsx | 6 +- apps/web/src/app/admin/privacy/page.test.tsx | 97 +++- apps/web/src/app/admin/privacy/page.tsx | 332 +----------- .../admin/coverage/CoveragePage.tsx | 34 +- .../admin/data/DataWorkspace.test.tsx | 77 +++ .../components/admin/data/DataWorkspace.tsx | 155 ++++++ .../operations/DataOperationLauncher.tsx | 96 ++++ .../admin/privacy/PrivacyCaseView.tsx | 200 ++++++++ .../admin/privacy/PrivacyWorkspace.tsx | 334 ++++++++++++ .../admin/services/DataWorkflowsPage.tsx | 479 +----------------- .../admin/services/ServiceWorkflows.test.tsx | 20 +- .../TransitCredentialsSection.test.tsx | 35 +- .../services/TransitCredentialsSection.tsx | 39 ++ .../components/admin/transit/LockfileCard.tsx | 2 +- .../admin/transit/TransitMobilitySections.tsx | 245 +++++++++ .../admin/transit/TransitPipelinePage.tsx | 65 +-- .../admin/transit/TransitWorkspace.test.tsx | 44 ++ .../admin/transit/TransitWorkspace.tsx | 92 ++++ .../admin/transit/__tests__/page.test.tsx | 7 +- apps/web/src/lib/admin/adminDataNavigation.ts | 13 + .../web/src/lib/admin/adminNavigation.test.ts | 13 +- apps/web/src/lib/admin/adminNavigation.ts | 35 +- apps/web/src/lib/admin/adminSearch.test.ts | 2 +- apps/web/src/lib/admin/adminSearch.ts | 18 +- docs/docs/administration/admin-panel.md | 112 ++-- packages/i18n/locales/de.json | 37 +- packages/i18n/locales/en.json | 37 +- 33 files changed, 1705 insertions(+), 947 deletions(-) create mode 100644 apps/web/src/app/admin/(full-admin)/data/operations/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/data/osm/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/data/overture/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/data/search/page.tsx create mode 100644 apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx create mode 100644 apps/web/src/components/admin/data/DataWorkspace.test.tsx create mode 100644 apps/web/src/components/admin/data/DataWorkspace.tsx create mode 100644 apps/web/src/components/admin/operations/DataOperationLauncher.tsx create mode 100644 apps/web/src/components/admin/privacy/PrivacyCaseView.tsx create mode 100644 apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx create mode 100644 apps/web/src/components/admin/transit/TransitMobilitySections.tsx create mode 100644 apps/web/src/components/admin/transit/TransitWorkspace.test.tsx create mode 100644 apps/web/src/components/admin/transit/TransitWorkspace.tsx create mode 100644 apps/web/src/lib/admin/adminDataNavigation.ts diff --git a/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx b/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx new file mode 100644 index 000000000..eeac0c1c8 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx @@ -0,0 +1,4 @@ +import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx b/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx new file mode 100644 index 000000000..243b7c3ed --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx @@ -0,0 +1,4 @@ +import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx b/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx new file mode 100644 index 000000000..143ba0364 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx @@ -0,0 +1,4 @@ +import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/data/page.tsx b/apps/web/src/app/admin/(full-admin)/data/page.tsx index fbe14df87..c23c28937 100644 --- a/apps/web/src/app/admin/(full-admin)/data/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/page.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { DataWorkflowsPage } from "@/components/admin/services/DataWorkflowsPage"; +import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; import { HostedDataTopics } from "@/components/admin/shared/HostedDataTopics"; import { getSelfHosted } from "@/lib/admin/serverDeployment"; -export const metadata: Metadata = { title: "Data workflows — Admin — OpenMapX" }; +export const metadata: Metadata = { title: "Data — Admin — OpenMapX" }; export default async function Page() { const selfHosted = await getSelfHosted((await cookies()).toString()); - return selfHosted === false ? : ; + return selfHosted === false ? : ; } diff --git a/apps/web/src/app/admin/(full-admin)/data/search/page.tsx b/apps/web/src/app/admin/(full-admin)/data/search/page.tsx new file mode 100644 index 000000000..5893e2a8f --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/search/page.tsx @@ -0,0 +1,4 @@ +import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx b/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx new file mode 100644 index 000000000..f48190de2 --- /dev/null +++ b/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx @@ -0,0 +1,4 @@ +import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export default function Page() { + return ; +} diff --git a/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx b/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx index 40c13d6fa..87c74122c 100644 --- a/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx @@ -1,6 +1,4 @@ -import type { Metadata } from "next"; -import { TransitPipelinePage } from "@/components/admin/transit/TransitPipelinePage"; -export const metadata: Metadata = { title: "Transit — Admin — OpenMapX" }; +import { TransitWorkspace } from "@/components/admin/transit/TransitWorkspace"; export default function Page() { - return ; + return ; } diff --git a/apps/web/src/app/admin/privacy/page.test.tsx b/apps/web/src/app/admin/privacy/page.test.tsx index 1d53ad805..0e8568286 100644 --- a/apps/web/src/app/admin/privacy/page.test.tsx +++ b/apps/web/src/app/admin/privacy/page.test.tsx @@ -1,8 +1,18 @@ +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); + import { createApiClient } from "@openmapx/core"; import { privacyDetail, privacyReadiness, } from "@/components/admin/privacy/privacyFixtures.test-support"; +import { + PRIVACY_ADMIN_RESUME_KEY, + savePendingPrivacyAdminDelivery, +} from "@/lib/admin/privacyReauthentication"; +import { adminTestHistory } from "@/test/adminNavigation"; import { createQueryWrapper, createTestQueryClient } from "@/test/query"; const binding = vi.hoisted(() => ({ client: undefined as unknown })); @@ -117,6 +127,8 @@ function installFetch() { describe("privacy admin case selection", () => { beforeEach(() => { + adminTestHistory.reset("/admin/privacy"); + sessionStorage.clear(); binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); Object.defineProperty(globalThis.crypto, "randomUUID", { configurable: true, @@ -185,10 +197,9 @@ describe("privacy admin case selection", () => { installFetch(); render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); - expect( - await screen.findByText("privacySetup.readiness.checks.controller-contact.title"), - ).toBeInTheDocument(); + expect(await screen.findByText("adminNavigation.privacyReadinessBlocked")).toBeInTheDocument(); expect(screen.getByText("privacySetup.readiness.contactFullAdmin")).toBeInTheDocument(); + expect(screen.queryByText("privacySetup.readiness.checks.controller-contact.title")).toBeNull(); expect(screen.queryByRole("link", { name: /controller-contact/ })).not.toBeInTheDocument(); expect(screen.queryByText("controller-contact")).not.toBeInTheDocument(); }); @@ -200,3 +211,83 @@ describe("privacy admin case selection", () => { expect(screen.getByLabelText("privacyAdmin.workflows.extensionNotifiedAt")).toBeInTheDocument(); }); }); + +function pendingDelivery(requestId = CASE_B) { + savePendingPrivacyAdminDelivery({ + apiOrigin: "http://privacy.test", + actorId: "synthetic-worker", + requestId, + artifactId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + challengeId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + channel: "assisted", + expiresAt: new Date(Date.now() + 300000).toISOString(), + }); +} +it("restores a pending delivery on return without a case query", async () => { + binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); + sessionStorage.clear(); + adminTestHistory.reset("/admin/privacy"); + pendingDelivery(); + installFetch(); + render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); + await screen.findByText(/registration-for-case-b/); + expect(sessionStorage.getItem(PRIVACY_ADMIN_RESUME_KEY)).not.toBeNull(); +}); +it("loads an explicitly addressed case independently and clears another case's pending delivery", async () => { + binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); + sessionStorage.clear(); + pendingDelivery(); + adminTestHistory.reset(`/admin/privacy?case=${CASE_A}`); + installFetch(); + render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); + await screen.findByText(/registration-for-case-a/); + expect(sessionStorage.getItem(PRIVACY_ADMIN_RESUME_KEY)).toBeNull(); +}); +it("clears pending delivery on a history-driven case switch", async () => { + binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); + sessionStorage.clear(); + adminTestHistory.reset(`/admin/privacy?case=${CASE_A}`); + installFetch(); + render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); + await screen.findByText(/registration-for-case-a/); + act(() => adminTestHistory.push(`/admin/privacy?case=${CASE_B}`)); + await screen.findByText(/registration-for-case-b/); + pendingDelivery(); + act(() => adminTestHistory.back()); + await screen.findByText(/registration-for-case-a/); + expect(sessionStorage.getItem(PRIVACY_ADMIN_RESUME_KEY)).toBeNull(); +}); + +it("loads an addressed case that is outside the visible queue", async () => { + binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); + sessionStorage.clear(); + adminTestHistory.reset(`/admin/privacy?case=${CASE_A}`); + installFetch(); + const fetchFixture = globalThis.fetch; + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => + String(input).endsWith("/privacy/admin/queue") + ? Promise.resolve(json({ requests: [detail(CASE_B).request] })) + : fetchFixture(input, init), + ); + render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); + await screen.findByText(/registration-for-case-a/); + expect(screen.queryByText(/registration-for-case-b/)).not.toBeInTheDocument(); +}); +it.each([403, 404])( + "does not substitute a queue case when an addressed case is refused (%s)", + async (status) => { + binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); + sessionStorage.clear(); + adminTestHistory.reset(`/admin/privacy?case=${CASE_A}`); + installFetch(); + const fetchFixture = globalThis.fetch; + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => + String(input).endsWith(`/privacy/admin/requests/${CASE_A}`) + ? Promise.resolve(Response.json({ error: "Unavailable" }, { status })) + : fetchFixture(input, init), + ); + render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); + await screen.findByText("privacyAdmin.caseFailed"); + expect(screen.queryByText(/registration-for-case-b/)).not.toBeInTheDocument(); + }, +); diff --git a/apps/web/src/app/admin/privacy/page.tsx b/apps/web/src/app/admin/privacy/page.tsx index daec77bbe..66a1d76ba 100644 --- a/apps/web/src/app/admin/privacy/page.tsx +++ b/apps/web/src/app/admin/privacy/page.tsx @@ -1,331 +1,5 @@ "use client"; -import { - Alert, - Button, - Chip, - CircularProgress, - DialogContent, - DialogTitle, - Divider, - Grid, - List, - ListItemButton, - ListItemText, - MenuItem, - Paper, - Stack, - TextField, - Typography, -} from "@mui/material"; -import * as sdk from "@openmapx/core/api-admin-sdk"; -import type { PrivacyAdminDetail } from "@openmapx/core/api-contracts"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { PrivacyArtifactDelivery } from "@/components/admin/privacy/PrivacyArtifactDelivery"; -import { PrivacyAttachments } from "@/components/admin/privacy/PrivacyAttachments"; -import { PrivacyBackupReview } from "@/components/admin/privacy/PrivacyBackupReview"; -import { PrivacyCaseDecisionForms } from "@/components/admin/privacy/PrivacyCaseDecisionForms"; -import { PrivacyIdentityReview } from "@/components/admin/privacy/PrivacyIdentityReview"; -import { PrivacyReadinessChecklist } from "@/components/admin/privacy/PrivacyReadinessChecklist"; -import { PrivacyTaskActions } from "@/components/admin/privacy/PrivacyTaskActions"; -import Dialog from "@/components/ui/Dialog"; -import { - type PrivacyAdminBinding, - privacyResult, - usePrivacyAdmin, - usePrivacyCaseClient, - usePrivacyMutationClient, -} from "@/lib/admin/privacyAdmin"; -import { - clearPendingPrivacyAdminDelivery, - readPendingPrivacyAdminDelivery, -} from "@/lib/admin/privacyReauthentication"; -export default function PrivacyAdminPage() { - const binding = usePrivacyAdmin(); - return ; -} -function Workspace({ binding }: { binding: PrivacyAdminBinding }) { - const t = useTranslations("privacyAdmin"), - account = useTranslations("account.privacyData"); - const queue = useQuery(binding.queries.queue()), - readiness = useQuery(binding.queries.readiness()); - const [selected, setSelected] = useState(null), - [intake, setIntake] = useState(false); - useEffect(() => { - if (selected || !queue.data || !binding.actorId) return; - const pending = readPendingPrivacyAdminDelivery(binding.apiOrigin, binding.actorId); - setSelected(pending?.requestId ?? queue.data.requests[0]?.id ?? null); - }, [queue.data, selected, binding.actorId, binding.apiOrigin]); - const choose = (id: string) => { - if (id !== selected) clearPendingPrivacyAdminDelivery(); - setSelected(id); - }; - return ( - - - {t("title")} - - - - - - {queue.isError && {t("loadFailed")}} - {readiness.isError && {t("notReady")}} - {readiness.data && ( - - - - )} - - - - - {t("queue", { count: queue.data?.requests.length ?? 0 })} - - {queue.isLoading && } - - {queue.data?.requests.map((r) => ( - choose(r.id)} - > - - - ))} - - {queue.data?.requests.length === 0 && {t("empty")}} - - - - {selected ? ( - - ) : ( - {t("selectRequest")} - )} - - - setIntake(false)} fullWidth maxWidth="sm"> - {t("assistedIntake")} - - { - setIntake(false); - setSelected(id); - }} - /> - - - - ); -} -function Case({ requestId, binding }: { requestId: string; binding: PrivacyAdminBinding }) { - const t = useTranslations("privacyAdmin"); - const query = useQuery(binding.queries.detail(requestId)); - return ( - - {query.isError && {t("caseFailed")}} - {query.isLoading && } - {query.data && } - - ); -} -function CaseForms({ - detail, - binding, -}: { - detail: PrivacyAdminDetail; - binding: PrivacyAdminBinding; -}) { - const t = useTranslations("privacyAdmin"), - w = useTranslations("privacyAdmin.workflows"); - const client = usePrivacyCaseClient(detail, binding); - return ( - - - - {t("case", { id: detail.request.id.slice(0, 8) })} - - - - {client.errorCode && ( - - {client.errorCode === "REQUEST_VERSION_CONFLICT" - ? w("versionConflict") - : t("actionFailed")}{" "} - ({client.errorCode}) - - )} - - - - - - - - - - - {!detail.capabilities.generate && ( - - {w("generationBlocked")} ({detail.capabilities.reasons.generate}) - - )} - - - {w("caseEvents")} - {detail.events.map((e) => ( - - {e.createdAt} · {e.eventType} · {e.actorKind} {e.payload?.reasonCode ?? ""} - - ))} - - - ); -} -function Intake({ - binding, - onCreated, -}: { - binding: PrivacyAdminBinding; - onCreated: (id: string) => void; -}) { - const t = useTranslations("privacyAdmin"); - const mutation = usePrivacyMutationClient("intake", 1, binding); - const [channel, setChannel] = useState<"email" | "post" | "representative" | "internal">("email"), - [locator, setLocator] = useState(""), - [locatorType, setLocatorType] = useState< - "email" | "user_id" | "username" | "erasure_reference" | "other_reference" - >("email"), - [accountState, setAccountState] = useState<"current" | "inaccessible" | "deleted" | "unknown">( - "inaccessible", - ), - [contact, setContact] = useState(""), - [received, setReceived] = useState(""); - return ( - - {t("assistedIntakeHelp")} - {mutation.errorCode && {t("actionFailed")}} - setChannel(e.target.value as typeof channel)} - > - {(["email", "post", "representative", "internal"] as const).map((x) => ( - - {t(`channels.${x}`)} - - ))} - - setLocatorType(e.target.value as typeof locatorType)} - > - {(["email", "user_id", "username", "erasure_reference", "other_reference"] as const).map( - (x) => ( - - {x} - - ), - )} - - setLocator(e.target.value)} - helperText={t("subjectLocatorHelp")} - /> - setAccountState(e.target.value as typeof accountState)} - > - {(["current", "inaccessible", "deleted", "unknown"] as const).map((x) => ( - - {t(`accountStates.${x}`)} - - ))} - - {channel === "representative" && ( - setContact(e.target.value)} - /> - )} - setReceived(e.target.value)} - /> - - - ); +import { PrivacyWorkspace } from "@/components/admin/privacy/PrivacyWorkspace"; +export default function Page() { + return ; } diff --git a/apps/web/src/components/admin/coverage/CoveragePage.tsx b/apps/web/src/components/admin/coverage/CoveragePage.tsx index 93a56c608..785f80670 100644 --- a/apps/web/src/components/admin/coverage/CoveragePage.tsx +++ b/apps/web/src/components/admin/coverage/CoveragePage.tsx @@ -3,6 +3,7 @@ import RefreshIcon from "@mui/icons-material/Refresh"; 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 FormControl from "@mui/material/FormControl"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -16,6 +17,7 @@ import Switch from "@mui/material/Switch"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import type { CoverageDomain, CoverageSourceRow, UsageAssessment } from "@openmapx/core/coverage"; +import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -57,6 +59,7 @@ function numberParam(value: string | null, fallback: number): number { export function CoveragePage() { const t = useTranslations("adminCoverage"); + const transitSourceLabel = useTranslations("adminNavigation")("transitSourceOwner"); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -189,18 +192,25 @@ export function CoveragePage() { title={t("title")} subtitle={t("subtitle")} actions={ - - - - - - - + <> + {domain === "transit" && ( + + )} + + + + + + + + } /> diff --git a/apps/web/src/components/admin/data/DataWorkspace.test.tsx b/apps/web/src/components/admin/data/DataWorkspace.test.tsx new file mode 100644 index 000000000..3a7e585e1 --- /dev/null +++ b/apps/web/src/components/admin/data/DataWorkspace.test.tsx @@ -0,0 +1,77 @@ +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); + +import { QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, createTestQueryClient, render, screen } from "@/test"; +import { DataWorkspace } from "./DataWorkspace"; + +const reads = vi.hoisted(() => [] as string[]); +vi.mock("../activity/activityQueries", () => ({ + useActivityClient: () => ({ apiUrl: "http://data.test", client: {} }), +})); +vi.mock("@/lib/admin/serviceAdmin", () => ({ + serviceAdminQueries: () => ({ + data: () => ({ + queryKey: ["directory"], + queryFn: () => { + reads.push("aggregate"); + return { osm: { total: 7, unavailable: false }, builds: [] }; + }, + }), + }), +})); +vi.mock("../services/OvertureMaintenance", () => ({ + OvertureMaintenance: () => { + reads.push("overture"); + return
Overture family
; + }, +})); +vi.mock("../services/SearchIndexMaintenance", () => ({ + SearchIndexMaintenance: () => { + reads.push("search"); + return
Search family
; + }, +})); +vi.mock("../services/AmbientPlacesMaintenance", () => ({ + AmbientPlacesMaintenance: () =>
Ambient family
, +})); +vi.mock("../services/NotablePlacesMaintenance", () => ({ + NotablePlacesMaintenance: () =>
Notable family
, +})); +afterEach(() => { + cleanup(); + reads.length = 0; +}); +it("uses only the aggregate read for the directory and links every owned domain", async () => { + render( + + + , + ); + await screen.findByText(/7 extracts/); + expect(reads).toEqual(["aggregate"]); + for (const topic of [ + "osm", + "overture", + "search", + "transit", + "coverage", + "transfers", + "operations", + ]) + expect(document.querySelector(`a[href="/admin/data/${topic}"]`)).not.toBeNull(); + expect(screen.queryByText("Overture family")).toBeNull(); +}); +it("mounts only the selected domain's status family", () => { + render( + + + , + ); + expect(screen.getByText("Search family")).toBeInTheDocument(); + expect(screen.getByText("Notable family")).toBeInTheDocument(); + expect(reads).toEqual(["search"]); +}); diff --git a/apps/web/src/components/admin/data/DataWorkspace.tsx b/apps/web/src/components/admin/data/DataWorkspace.tsx new file mode 100644 index 000000000..ceeb7b7d2 --- /dev/null +++ b/apps/web/src/components/admin/data/DataWorkspace.tsx @@ -0,0 +1,155 @@ +"use client"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { + ADVANCED_OPERATION_IDS, + adminDataOperationHref, + OSM_OPERATION_IDS, +} from "@/lib/admin/adminDataNavigation"; +import { ADMIN_DESTINATIONS } from "@/lib/admin/adminNavigation"; +import { serviceAdminQueries } from "@/lib/admin/serviceAdmin"; +import { useAdminQueryState } from "@/lib/admin/useAdminQueryState"; +import { useActivityClient } from "../activity/activityQueries"; +import { DataOperationLauncher } from "../operations/DataOperationLauncher"; +import { AmbientPlacesMaintenance } from "../services/AmbientPlacesMaintenance"; +import { ArtifactTransfersSection } from "../services/ArtifactTransfersSection"; +import { DataInventorySection } from "../services/DataInventorySection"; +import { NotablePlacesMaintenance } from "../services/NotablePlacesMaintenance"; +import { OsmExtractDetails } from "../services/OsmExtractDetails"; +import { OvertureMaintenance } from "../services/OvertureMaintenance"; +import { SearchIndexMaintenance } from "../services/SearchIndexMaintenance"; +import { AdminPageHeader } from "../shared/AdminPageHeader"; + +export type DataTopic = "overview" | "osm" | "overture" | "search" | "transfers" | "operations"; +export function DataWorkspace({ topic = "overview" }: { topic?: DataTopic }) { + const { apiUrl } = useActivityClient(); + const router = useRouter(); + useEffect(() => { + if (topic !== "overview") return; + const hash = window.location.hash; + if (hash === "#transit-sources") router.replace("/admin/data/transit?tab=sources"); + else if (hash.startsWith("#operation-")) { + const id = hash.slice(11); + if (/^[a-z0-9-]{1,128}$/.test(id)) router.replace(adminDataOperationHref(id)); + } + }, [topic, router]); + const destination = ADMIN_DESTINATIONS.find( + (entry) => entry.href === `/admin/data${topic === "overview" ? "" : `/${topic}`}`, + ); + return ( + + + {topic === "overview" && } + {topic === "osm" && } + {topic === "overture" && ( +
+ {["overture-pull", "overture-ingest", "overture-extract", "overture-conflate"].map( + (id) => ( + + ), + )} + +
+ )} + {topic === "search" && ( + <> +
+ +
+ +
+ +
+ + )} + {topic === "transfers" && } + {topic === "operations" && } +
+ ); +} +function DataDirectory() { + const { client, apiUrl } = useActivityClient(); + // No domain-specific status subscriptions or polling on the directory. + const query = useQuery(serviceAdminQueries(client, apiUrl).data()); + return ( + + {query.isError && ( + void query.refetch()}>Retry} + > + Inventory summary is unavailable. Open a workspace to inspect its own status. + + )} + {ADMIN_DESTINATIONS.filter((entry) => entry.workspace === "data" && entry.id !== "data").map( + (entry) => ( + + + + + {entry.label} + + + {entry.description} + + + + {entry.id === "osm" && query.data + ? query.data.osm.unavailable + ? "Inventory unavailable" + : `${query.data.osm.total} extract${query.data.osm.total === 1 ? "" : "s"}` + : "Open to inspect"} + + + + ), + )} + + ); +} +function OsmWorkspace() { + const { client, apiUrl } = useActivityClient(); + const { params, update } = useAdminQueryState(); + const raw = params.get("offset") ?? "0"; + const offset = /^\d{1,7}$/.test(raw) ? Math.min(Number(raw), 1000000) : 0; + const query = useQuery({ + ...serviceAdminQueries(client, apiUrl).data({ osmLimit: 50, osmOffset: offset }), + refetchInterval: 30000, + }); + return ( + + + {query.isError && ( + void query.refetch()}>Retry} + > + Failed to load data inventory. + + )} + {query.isLoading && Loading inventory…} + {query.data && ( + <> + + + + update({ offset: value ? String(value) : null }, "replace")} + /> + + )} + + ); +} diff --git a/apps/web/src/components/admin/operations/DataOperationLauncher.tsx b/apps/web/src/components/admin/operations/DataOperationLauncher.tsx new file mode 100644 index 000000000..962d602fd --- /dev/null +++ b/apps/web/src/components/admin/operations/DataOperationLauncher.tsx @@ -0,0 +1,96 @@ +"use client"; +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { serviceAdminQueries } from "@/lib/admin/serviceAdmin"; +import { AdminJobLink } from "../activity/AdminJobLink"; +import { useActivityClient } from "../activity/activityQueries"; +import { notablePlacesStatusQueryKey } from "../services/NotablePlacesMaintenance"; +import { overtureStatusQueryKey } from "../services/OvertureMaintenance"; +import { searchIndexStatusQueryKey } from "../services/SearchIndexMaintenance"; +import { useAdminToast } from "../shared/AdminToast"; +import { AdminOperationCard } from "./AdminOperationCard"; + +export function DataOperationLauncher({ operationIds }: { operationIds: readonly string[] }) { + const { client, apiUrl } = useActivityClient(); + const toast = useAdminToast(); + const catalog = useQuery({ + ...serviceAdminQueries(client, apiUrl).operations(), + staleTime: 300000, + }); + const operations = catalog.data?.operations.filter((op) => operationIds.includes(op.id)) ?? []; + const [selected, setSelected] = useState(null); + const [lastJob, setLastJob] = useState(null); + useEffect(() => { + const reveal = () => { + const id = window.location.hash.slice("#operation-".length); + if (window.location.hash.startsWith("#operation-") && operationIds.includes(id)) + setSelected(id); + }; + reveal(); + window.addEventListener("hashchange", reveal); + return () => window.removeEventListener("hashchange", reveal); + }, [operationIds]); + const operation = operations.find((op) => op.id === selected); + return ( + + {lastJob && ( + + Inspect job + + } + > + Operation accepted. Inspect its outcome in Activity. + + )} + {catalog.isError && ( + void catalog.refetch()}>Retry} + > + Operations are unavailable. + + )} + {catalog.isLoading && Loading operations…} + + {operations.map((op) => ( + + ))} + + {operation && ( + + { + setLastJob(id); + toast(`Queued ${operation.title} (${id})`); + }} + onError={(message) => toast(message, "error")} + /> + + )} + + ); +} diff --git a/apps/web/src/components/admin/privacy/PrivacyCaseView.tsx b/apps/web/src/components/admin/privacy/PrivacyCaseView.tsx new file mode 100644 index 000000000..669dcf0c3 --- /dev/null +++ b/apps/web/src/components/admin/privacy/PrivacyCaseView.tsx @@ -0,0 +1,200 @@ +"use client"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +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 Typography from "@mui/material/Typography"; +import * as sdk from "@openmapx/core/api-admin-sdk"; +import type { PrivacyAdminDetail } from "@openmapx/core/api-contracts"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { PRIVACY_CASE_SECTIONS, type PrivacyCaseSection } from "@/lib/admin/adminNavigationState"; +import { + type PrivacyAdminBinding, + privacyResult, + usePrivacyCaseClient, +} from "@/lib/admin/privacyAdmin"; +import { PrivacyArtifactDelivery } from "./PrivacyArtifactDelivery"; +import { PrivacyAttachments } from "./PrivacyAttachments"; +import { PrivacyBackupReview } from "./PrivacyBackupReview"; +import { PrivacyCaseDecisionForms } from "./PrivacyCaseDecisionForms"; +import { PrivacyIdentityReview } from "./PrivacyIdentityReview"; +import { PrivacyTaskActions } from "./PrivacyTaskActions"; + +export function PrivacyCaseView({ + detail, + binding, + section, + onSectionChange, + onRefresh, +}: { + detail: PrivacyAdminDetail; + binding: PrivacyAdminBinding; + section: PrivacyCaseSection; + onSectionChange: (value: PrivacyCaseSection) => void; + onRefresh: () => void; +}) { + const t = useTranslations("privacyAdmin"), + w = useTranslations("privacyAdmin.workflows"), + nav = useTranslations("adminNavigation"); + const client = usePrivacyCaseClient(detail, binding); + const [expanded, setExpanded] = useState(section), + [eventCount, setEventCount] = useState(100); + useEffect(() => setExpanded(section), [section]); + const capabilitySections = { + identityReview: "identity", + taskDecision: "tasks", + taskAssignment: "tasks", + extendDeadline: "decisions", + caseDecision: "decisions", + attachmentWrite: "attachments", + backupReview: "backups", + generate: "delivery", + assistedDelivery: "delivery", + representativeDelivery: "delivery", + } as const; + const allowed = [ + ...new Set( + Object.entries(capabilitySections) + .filter(([key]) => detail.capabilities[key as keyof typeof capabilitySections]) + .map(([, value]) => value), + ), + ]; + return ( + + + + + {t("case", { id: detail.request.id.slice(0, 8) })} + + + + + + + {nav("privacyDue", { date: new Date(detail.request.dueAt).toLocaleString() })} + + + + {nav("privacyAvailable")} + + {allowed.map((value) => ( + + ))} + + {client.errorCode && ( + + {client.errorCode === "REQUEST_VERSION_CONFLICT" + ? w("versionConflict") + : t("actionFailed")}{" "} + ({client.errorCode}) + + )} + {!detail.capabilities.generate && ( + + {w("generationBlocked")} ({detail.capabilities.reasons.generate}) + + )} + {Object.entries(detail.capabilities.reasons) + .filter(([key]) => key !== "generate") + .map(([key, reason]) => ( + + {key}: {reason} + + ))} +
+ {PRIVACY_CASE_SECTIONS.map((value) => ( + { + setExpanded(open ? value : null); + if (open) onSectionChange(value); + }} + disableGutters + > + } + id={`case-section-${value}`} + aria-controls={`case-content-${value}`} + > + + {nav(`privacySections.${value}`)} + + + + {value === "identity" && } + {value === "tasks" && } + {value === "decisions" && ( + + )} + {value === "attachments" && } + {value === "backups" && } + {value === "delivery" && expanded === value && ( + + + + + )} + {value === "events" && ( + + {detail.events.slice(-eventCount).map((event) => ( + + {event.createdAt} · {event.eventType} · {event.actorKind}{" "} + {event.payload?.reasonCode ?? ""} + + ))} + {detail.events.length > eventCount && ( + + )} + + )} + + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx b/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx new file mode 100644 index 000000000..2630fbd2d --- /dev/null +++ b/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx @@ -0,0 +1,334 @@ +"use client"; +import { + Alert, + Button, + CircularProgress, + DialogContent, + DialogTitle, + Grid, + List, + ListItemButton, + ListItemText, + MenuItem, + Paper, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { useSession } from "@openmapx/core"; +import * as sdk from "@openmapx/core/api-admin-sdk"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; +import { parseAdminCaseSelection } from "@/lib/admin/adminNavigationState"; +import { + type PrivacyAdminBinding, + privacyResult, + usePrivacyAdmin, + usePrivacyMutationClient, +} from "@/lib/admin/privacyAdmin"; +import { + clearPendingPrivacyAdminDelivery, + readPendingPrivacyAdminDelivery, +} from "@/lib/admin/privacyReauthentication"; +import { useAdminQueryState } from "@/lib/admin/useAdminQueryState"; +import { AdminPageHeader } from "../shared/AdminPageHeader"; +import { PrivacyCaseView } from "./PrivacyCaseView"; +export function PrivacyWorkspace() { + const binding = usePrivacyAdmin(); + return ; +} +function Workspace({ binding }: { binding: PrivacyAdminBinding }) { + const t = useTranslations("privacyAdmin"), + account = useTranslations("account.privacyData"), + setup = useTranslations("privacySetup"), + nav = useTranslations("adminNavigation"); + const queue = useQuery(binding.queries.queue()), + readiness = useQuery(binding.queries.readiness()); + const { params, update } = useAdminQueryState(); + const { caseId, section } = parseAdminCaseSelection(params); + const { data: session } = useSession(); + const initialized = useRef(false), + previous = useRef(null); + const [ready, setReady] = useState(false), + [intake, setIntake] = useState(false); + useEffect(() => { + if (initialized.current || !binding.actorId) return; + const pending = readPendingPrivacyAdminDelivery(binding.apiOrigin, binding.actorId); + if (caseId) { + if (pending && pending.requestId !== caseId) clearPendingPrivacyAdminDelivery(); + } else if (pending) update({ case: pending.requestId, section: "delivery" }, "replace"); + else if (!queue.data) return; + else if (queue.data.requests[0]) update({ case: queue.data.requests[0].id }, "replace"); + initialized.current = true; + setReady(true); + }, [caseId, queue.data, binding.apiOrigin, binding.actorId, update]); + useEffect(() => { + if (!ready || !caseId) return; + if (previous.current && previous.current !== caseId) clearPendingPrivacyAdminDelivery(); + previous.current = caseId; + }, [ready, caseId]); + const selected = caseId ?? previous.current; + const choose = (id: string) => { + if (id !== selected) clearPendingPrivacyAdminDelivery(); + update({ case: id, section: "identity" }); + }; + return ( + + + + + + } + /> + {queue.isError && {t("loadFailed")}} + {readiness.isError && {t("notReady")}} + {readiness.data && ( + + {setup("title")} + + ) : undefined + } + > + {readiness.data.ready ? nav("privacyReadinessReady") : nav("privacyReadinessBlocked")} + {!readiness.data.ready && session?.user.role !== "admin" && ( + {setup("readiness.contactFullAdmin")} + )} + + )} + choose(e.target.value)} + > + {selected && !queue.data?.requests.some((r) => r.id === selected) && ( + {t("case", { id: selected.slice(0, 8) })} + )} + {queue.data?.requests.map((r) => ( + + {r.id.slice(0, 8)} · {r.state} + + ))} + + + + + + {t("queue", { count: queue.data?.requests.length ?? 0 })} + + {queue.isLoading && } + + {queue.data?.requests.map((r) => ( + choose(r.id)} + > + + + ))} + + {queue.data?.requests.length === 0 && {t("empty")}} + + + + {ready && selected ? ( + update({ case: selected, section: value })} + /> + ) : ( + {t("selectRequest")} + )} + + + setIntake(false)} fullWidth maxWidth="sm"> + {t("assistedIntake")} + + { + setIntake(false); + choose(id); + }} + /> + + + + ); +} +function Case({ + requestId, + binding, + section, + onSectionChange, +}: { + requestId: string; + binding: PrivacyAdminBinding; + section: import("@/lib/admin/adminNavigationState").PrivacyCaseSection; + onSectionChange: (value: import("@/lib/admin/adminNavigationState").PrivacyCaseSection) => void; +}) { + const t = useTranslations("privacyAdmin"); + const query = useQuery(binding.queries.detail(requestId)); + return ( + + {query.isError && ( + void query.refetch()}>{t("refresh")}} + > + {t("caseFailed")} + + )} + {query.isLoading && } + {query.data && ( + void query.refetch()} + detail={query.data} + binding={binding} + section={section} + onSectionChange={onSectionChange} + /> + )} + + ); +} +function Intake({ + binding, + onCreated, +}: { + binding: PrivacyAdminBinding; + onCreated: (id: string) => void; +}) { + const t = useTranslations("privacyAdmin"); + const mutation = usePrivacyMutationClient("intake", 1, binding); + const [channel, setChannel] = useState<"email" | "post" | "representative" | "internal">("email"), + [locator, setLocator] = useState(""), + [locatorType, setLocatorType] = useState< + "email" | "user_id" | "username" | "erasure_reference" | "other_reference" + >("email"), + [accountState, setAccountState] = useState<"current" | "inaccessible" | "deleted" | "unknown">( + "inaccessible", + ), + [contact, setContact] = useState(""), + [received, setReceived] = useState(""); + return ( + + {t("assistedIntakeHelp")} + {mutation.errorCode && {t("actionFailed")}} + setChannel(e.target.value as typeof channel)} + > + {(["email", "post", "representative", "internal"] as const).map((x) => ( + + {t(`channels.${x}`)} + + ))} + + setLocatorType(e.target.value as typeof locatorType)} + > + {(["email", "user_id", "username", "erasure_reference", "other_reference"] as const).map( + (x) => ( + + {x} + + ), + )} + + setLocator(e.target.value)} + helperText={t("subjectLocatorHelp")} + /> + setAccountState(e.target.value as typeof accountState)} + > + {(["current", "inaccessible", "deleted", "unknown"] as const).map((x) => ( + + {t(`accountStates.${x}`)} + + ))} + + {channel === "representative" && ( + setContact(e.target.value)} + /> + )} + setReceived(e.target.value)} + /> + + + ); +} diff --git a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx index ba1abe718..26079a39d 100644 --- a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx +++ b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx @@ -1,479 +1,6 @@ "use client"; - -import BuildIcon from "@mui/icons-material/Build"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import StorageIcon from "@mui/icons-material/Storage"; -import Alert from "@mui/material/Alert"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Chip from "@mui/material/Chip"; -import CircularProgress from "@mui/material/CircularProgress"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Grid from "@mui/material/Grid"; -import IconButton from "@mui/material/IconButton"; -import Paper from "@mui/material/Paper"; -import Stack from "@mui/material/Stack"; -import Switch from "@mui/material/Switch"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import * as sdk from "@openmapx/core/api-admin-sdk"; -import type { - AdminServiceData, - AdminServiceSharedMobilityCategory, -} from "@openmapx/core/api-contracts"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { useState } from "react"; -import { adminJobHref } from "@/lib/admin/adminJobNavigation"; -import { - invalidateServiceAdministration, - serviceAdminQueries, - serviceOrThrow, -} from "@/lib/admin/serviceAdmin"; -import { formatBytes } from "@/lib/storageFormat"; -import { useActivityClient } from "../activity/activityQueries"; -import { AdminOperationCard } from "../operations/AdminOperationCard"; -import type { AdminOperationContract } from "../operations/adminOperationsApi"; -import { AdminPageHeader } from "../shared/AdminPageHeader"; -import { useAdminToast } from "../shared/AdminToast"; -import { AmbientPlacesMaintenance } from "./AmbientPlacesMaintenance"; -import { ArtifactTransfersSection } from "./ArtifactTransfersSection"; -import { DataInventorySection } from "./DataInventorySection"; -import { NotablePlacesMaintenance, notablePlacesStatusQueryKey } from "./NotablePlacesMaintenance"; -import { OsmExtractDetails } from "./OsmExtractDetails"; -import { OvertureMaintenance, overtureStatusQueryKey } from "./OvertureMaintenance"; -import { SearchIndexMaintenance, searchIndexStatusQueryKey } from "./SearchIndexMaintenance"; -import { TransitSourcesSection } from "./TransitSourcesSection"; - -type MotisTransitousStatus = AdminServiceData["motisTransitous"]; - -/** Overture and search operations have their own maintenance sections below. */ -const DATA_OPERATION_GROUPS: ReadonlySet = new Set([ - "osm", - "build", - "transit", -]); - -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -function DataOperationsSection({ apiUrl }: { apiUrl: string }) { - const showToast = useAdminToast(); - const { client } = useActivityClient(); - const [lastJobId, setLastJobId] = useState(null); - - const catalog = useQuery({ - ...serviceAdminQueries(client, apiUrl).operations(), - staleTime: 5 * 60_000, - }); - - const handleQueued = (jobId: string, operation: AdminOperationContract) => { - setLastJobId(jobId); - showToast(`Queued ${operation.title} (${jobId})`); - }; - - return ( - - - - - Data Operations - - - - - - Queue data jobs from the GUI. Each operation previews its effect before it is queued and - streams its progress to Activity. - - {lastJobId && ( - - View Jobs - - } - > - Last queued job: {lastJobId} - - )} - {catalog.isError && ( - - {catalog.error instanceof Error ? catalog.error.message : "Failed to load operations"} - - )} - {catalog.isLoading && ( - - - - )} - - {(catalog.data?.operations ?? []) - .filter((operation) => DATA_OPERATION_GROUPS.has(operation.group)) - .map((operation) => ( - - showToast(message, "error")} - /> - - ))} - - - ); -} - -function motisProxyModeColor( - mode: MotisTransitousStatus["feedProxyMode"], -): "default" | "success" | "warning" | "error" { - if (mode === "self-hosted") return "success"; - if (mode === "none") return "default"; - if (mode === "mixed") return "warning"; - return "error"; -} - -function MotisTransitousSection({ status }: { status: MotisTransitousStatus }) { - return ( - - - - - MOTIS Transitous Parity - - - {!status.configFound ? ( - - No MOTIS config found yet. Sync transit sources and build MOTIS data. - - ) : ( - - - - - - - - - - - - - - - - Proxy artifacts: config {status.feedProxyConfigFound ? "present" : "missing"} · vars{" "} - {status.feedProxyVarsFound ? "present" : "missing"} · {status.feedProxyFeedCount} mapped - feed endpoint(s) · GBFS proxy {status.gbfsProxyUrl ?? "not configured"} - - {status.gbfsCatalog.state === "active" && ( - - Registry {status.gbfsCatalog.commit?.slice(0, 12)} ·{" "} - {status.gbfsCatalog.registryAdded} added · {status.gbfsCatalog.transitousPreferred}{" "} - Transitous-preferred duplicate(s) · {status.gbfsCatalog.quarantined} quarantined ·{" "} - {status.gbfsCatalog.validationFailed} validation failure(s) - - )} - - Active epoch {status.activeEpoch ?? "unknown"} · candidate{" "} - {status.candidateEpoch ?? "none"} · tested{" "} - {status.testedAt ? formatDate(status.testedAt) : "never"} · rollback{" "} - {status.rollbackAvailable ? "available" : "unavailable"} - - - Capacity:{" "} - {status.preflightRequiredDiskBytes != null - ? `${formatBytes(status.preflightRequiredDiskBytes)} required` - : "not estimated"}{" "} - ·{" "} - {status.preflightFreeDiskBytes != null - ? `${formatBytes(status.preflightFreeDiskBytes)} free` - : "free disk unknown"}{" "} - · previous healthy slot {status.previousHealthySlot ?? "none"} - {status.pinProposalPending ? " · pin proposal awaiting review" : ""} - - - Crowdsource sidecars are disabled pending authoritative provenance, protocol, license, - privacy-controller, moderation, and deletion approval. - - {status.capabilityState !== "healthy" && ( - - Promoted MOTIS capability evidence is {status.capabilityState}. - {status.capabilityError - ? ` ${status.capabilityError}` - : " Re-run the transactional import before trusting configured counts."} - - )} - {(status.feedProxyMode === "transitous-cloud" || status.feedProxyMode === "mixed") && ( - - MOTIS config still references Transitous cloud feed-proxy URLs. Rebuild MOTIS data to - switch fully to self-hosted proxy URLs. - - )} - - )} - - ); -} - -function SharedMobilityPolicySection({ apiUrl }: { apiUrl: string }) { - const queryClient = useQueryClient(); - const { client } = useActivityClient(); - const policy = useQuery({ - ...serviceAdminQueries(client, apiUrl).policy(), - refetchInterval: 30_000, - }); - const data = policy.data; - const rollback = useMutation({ - retry: false, - mutationFn: async ({ - category, - enabled, - }: { - category: AdminServiceSharedMobilityCategory; - enabled: boolean; - }) => { - const effect = { kind: "policy" } as const; - const result = await serviceOrThrow( - sdk.rollbackAdminSharedMobility(category, { enabled }, { client }), - ); - // This response is the completed policy change, not a job acknowledgement. - await invalidateServiceAdministration(queryClient, apiUrl, effect); - return result; - }, - }); - - return ( - - - Shared mobility source policy - - - MOTIS-first decisions contain category-level operational state only; no coordinates or - vehicle IDs are recorded. Enable rollback to restore fan-out immediately. - - {policy.isError && Failed to load shared mobility policy.} - {rollback.isError && {rollback.error.message}} - - {(["bike", "scooter", "car"] as const).map((category) => { - const record = data?.decisions.find((decision) => decision.category === category); - const enabled = data?.rollbackCategories.includes(category) ?? false; - return ( - - {category} - - {record && ( - - called {record.decision.calledAdapters.join(", ") || "MOTIS only"}; skipped{" "} - {record.decision.skippedAdapters.join(", ") || "none"} - - )} - rollback.mutate({ category, enabled: checked })} - /> - } - label="Fan-out rollback" - /> - - ); - })} - - - ); -} - +// Compatibility for existing focused inventory consumers; canonical routes use DataWorkspace. +import { DataWorkspace } from "../data/DataWorkspace"; export function DataWorkflowsPage() { - const { apiUrl } = useActivityClient(); - return ; -} -function DataWorkflowsView() { - const { apiUrl, client } = useActivityClient(); - const [osmOffset, setOsmOffset] = useState(0); - - const { data, isLoading, isError, refetch, isFetching } = useQuery({ - ...serviceAdminQueries(client, apiUrl).data({ osmLimit: 50, osmOffset }), - refetchInterval: 30_000, - }); - - if (isLoading) { - return ( - - - - ); - } - - if (isError || !data) { - return Failed to load data inventory.; - } - - return ( - - - - - refetch()} disabled={isFetching}> - - - - - - } - /> - - - - {data.osm.total > 0 && ( - - - OSM Planet Data - - - - - - )} - - -
- {["overture-pull", "overture-ingest", "overture-extract", "overture-conflate"].map( - (id) => ( - - ), - )} - -
-
- -
- -
- -
-
- -
- - -
-
- ); + return ; } diff --git a/apps/web/src/components/admin/services/ServiceWorkflows.test.tsx b/apps/web/src/components/admin/services/ServiceWorkflows.test.tsx index 9c2315f77..672f9f9b1 100644 --- a/apps/web/src/components/admin/services/ServiceWorkflows.test.tsx +++ b/apps/web/src/components/admin/services/ServiceWorkflows.test.tsx @@ -1,3 +1,8 @@ +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); + import { en } from "@openmapx/i18n"; import { QueryClientProvider, useQuery } from "@tanstack/react-query"; import { NextIntlClientProvider } from "next-intl"; @@ -17,8 +22,11 @@ import { waitFor, within, } from "@/test"; +import { DataWorkspace } from "../data/DataWorkspace"; import { AdminOperationCard } from "../operations/AdminOperationCard"; import type { AdminOperationContract } from "../operations/adminOperationsApi"; +import { DataOperationLauncher } from "../operations/DataOperationLauncher"; +import { TransitMobilityView } from "../transit/TransitWorkspace"; import { BackupsPage } from "./BackupsPage"; import { DataWorkflowsPage } from "./DataWorkflowsPage"; import { NotablePlacesMaintenance } from "./NotablePlacesMaintenance"; @@ -925,7 +933,7 @@ describe("wire intent, validation, polling and policy", () => { fireEvent.click(screen.getByRole("button", { name: "Queue Update data" })); if (kind === "run") fireEvent.click(await screen.findByRole("button", { name: "Confirm" })); } else if (kind === "policy") { - mount(); + mount(); fireEvent.click((await screen.findAllByLabelText("Fan-out rollback"))[0] as HTMLElement); } else { mount(embedded(kind)); @@ -974,7 +982,7 @@ describe("wire intent, validation, polling and policy", () => { }); return undefined; }); - const { qc } = mount(); + const { qc } = mount(); await screen.findByText("1 schedule dataset(s)"); expect(qc.getQueryData(["admin", "services", apiUrl, "data", "50", "0"])).toEqual(inventory); expect(await screen.findByText(/called motis; skipped fallback/)).toBeInTheDocument(); @@ -1165,8 +1173,14 @@ it("refreshes embedded status displayed on the data page after a catalogue opera }); return undefined; }); - const { qc } = mount(); + const { qc } = mount( + <> + + + , + ); await screen.findByText("catalogue-before"); + fireEvent.click(await screen.findByRole("button", { name: "Update data" })); fireEvent.click(screen.getByRole("button", { name: "Queue Update data" })); fireEvent.click(await screen.findByRole("button", { name: "Confirm" })); await waitFor(() => expect(calls.some((r) => r.url.pathname.endsWith("/jobs/job-1"))).toBe(true)); diff --git a/apps/web/src/components/admin/services/TransitCredentialsSection.test.tsx b/apps/web/src/components/admin/services/TransitCredentialsSection.test.tsx index 72b2ab5d5..b07d49430 100644 --- a/apps/web/src/components/admin/services/TransitCredentialsSection.test.tsx +++ b/apps/web/src/components/admin/services/TransitCredentialsSection.test.tsx @@ -1,7 +1,10 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, createTestQueryClient, fireEvent, render, screen, waitFor } from "@/test"; -import { TransitCredentialsSection } from "./TransitCredentialsSection"; +import { + readTransitCredentialContinuation, + TransitCredentialsSection, +} from "./TransitCredentialsSection"; const mocks = vi.hoisted(() => ({ get: vi.fn(), @@ -35,7 +38,7 @@ vi.mock("../users/AdminReauthenticationDialog", () => ({ ), })); -const reply = (data: unknown) => ({ status: 200, data, headers: new Headers() }); +const reply = (data: T) => ({ status: 200, data, headers: new Headers() }); const view = { catalogRevisionId: "a".repeat(64), credentialsRevision: "b".repeat(64), @@ -176,3 +179,31 @@ it("discards a late preview response when the actor changes", async () => { expect(screen.getByLabelText("New value for de/A")).toHaveValue(""); expect(sessionStorage.length).toBe(0); }); + +it("restores only a completed actor-scoped matching credential review", () => { + const preview = reviewedReply().data; + const reviewKey = `openmapx-transit-credential-preview:${JSON.stringify([mocks.apiUrl, mocks.actorId])}`; + sessionStorage.setItem( + reviewKey, + JSON.stringify({ preview, operationKey: "opk1_aaaaaaaaaaaaaaaaaaaa" }), + ); + sessionStorage.setItem( + `openmapx-admin-reauth:${mocks.apiUrl}`, + JSON.stringify({ + phase: "completed", + requests: [ + { + purpose: "transit.credentials", + targetId: preview.catalogRevisionId, + intentFingerprint: preview.intentFingerprint, + }, + ], + challengeIds: ["11111111-1111-4111-8111-111111111111"], + expiresAt: preview.expiresAt, + returnPath: "/admin/transit", + }), + ); + expect(readTransitCredentialContinuation(mocks.apiUrl, mocks.actorId)).toEqual({}); + expect(readTransitCredentialContinuation(mocks.apiUrl, "other-actor")).toBeNull(); + expect(readTransitCredentialContinuation("http://other.test", mocks.actorId)).toBeNull(); +}); diff --git a/apps/web/src/components/admin/services/TransitCredentialsSection.tsx b/apps/web/src/components/admin/services/TransitCredentialsSection.tsx index 67345ce2f..f1f412bc9 100644 --- a/apps/web/src/components/admin/services/TransitCredentialsSection.tsx +++ b/apps/web/src/components/admin/services/TransitCredentialsSection.tsx @@ -30,6 +30,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import Dialog from "@/components/ui/Dialog"; import { useAccountAdmin } from "@/lib/admin/accountAdmin"; +import { readStoredAdminReauthentication } from "@/lib/admin/adminReauthentication"; import { browserAdminApiBase } from "@/lib/admin/browserAdminApiBase"; import { AdminReauthenticationDialog } from "../users/AdminReauthenticationDialog"; @@ -54,6 +55,44 @@ function restored(key: string): Reviewed | null { return null; } } +/** Only restore the actor/origin-scoped, already reviewed intent; never apply automatically. */ +export function readTransitCredentialContinuation( + apiUrl: string, + actorId: string, +): { proposalId?: string } | null { + const stored = readStoredAdminReauthentication(apiUrl); + const intent = + stored?.phase === "completed" + ? stored.requests.find((r) => r.purpose === "transit.credentials") + : null; + if (!intent || !actorId) return null; + try { + const prefix = "openmapx-transit-credential-preview:"; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (!key?.startsWith(prefix)) continue; + const identity = JSON.parse(key.slice(prefix.length)); + if ( + !Array.isArray(identity) || + ![2, 3].includes(identity.length) || + identity[0] !== apiUrl || + identity[1] !== actorId + ) + continue; + const value = restored(key); + if ( + value && + value.preview.catalogRevisionId === intent.targetId && + value.preview.intentFingerprint === intent.intentFingerprint && + (value.preview.proposalId ?? null) === (identity[2] ?? null) + ) + return value.preview.proposalId ? { proposalId: value.preview.proposalId } : {}; + } + } catch { + /* No matching retained review; let the operator start again. */ + } + return null; +} export function TransitCredentialsSection({ proposalId }: { proposalId?: string } = {}) { const { apiUrl, actorId } = useAccountAdmin(); return ( diff --git a/apps/web/src/components/admin/transit/LockfileCard.tsx b/apps/web/src/components/admin/transit/LockfileCard.tsx index af52da2a5..d3a34e793 100644 --- a/apps/web/src/components/admin/transit/LockfileCard.tsx +++ b/apps/web/src/components/admin/transit/LockfileCard.tsx @@ -31,7 +31,7 @@ import { useTransitPins, useValidateTransitPins, } from "@/lib/admin/transitPinHooks"; -export function LockfileCard(_props: { state: TransitStateSummary }) { +export function LockfileCard(_props: { state?: TransitStateSummary } = {}) { const { apiUrl, actorId } = useAccountAdmin(); return ; } diff --git a/apps/web/src/components/admin/transit/TransitMobilitySections.tsx b/apps/web/src/components/admin/transit/TransitMobilitySections.tsx new file mode 100644 index 000000000..aa27ec2f4 --- /dev/null +++ b/apps/web/src/components/admin/transit/TransitMobilitySections.tsx @@ -0,0 +1,245 @@ +"use client"; +import StorageIcon from "@mui/icons-material/Storage"; +import Alert from "@mui/material/Alert"; +import Chip from "@mui/material/Chip"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; +import Typography from "@mui/material/Typography"; +import * as sdk from "@openmapx/core/api-admin-sdk"; +import type { + AdminServiceData, + AdminServiceSharedMobilityCategory, +} from "@openmapx/core/api-contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + invalidateServiceAdministration, + serviceAdminQueries, + serviceOrThrow, +} from "@/lib/admin/serviceAdmin"; +import { formatBytes } from "@/lib/storageFormat"; +import { useActivityClient } from "../activity/activityQueries"; + +type MotisTransitousStatus = AdminServiceData["motisTransitous"]; +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(); +} +function motisProxyModeColor( + mode: MotisTransitousStatus["feedProxyMode"], +): "default" | "success" | "warning" | "error" { + if (mode === "self-hosted") return "success"; + if (mode === "none") return "default"; + if (mode === "mixed") return "warning"; + return "error"; +} + +export function MotisTransitousSection({ status }: { status: MotisTransitousStatus }) { + return ( + + + + + MOTIS Transitous Parity + + + {!status.configFound ? ( + + No MOTIS config found yet. Sync transit sources and build MOTIS data. + + ) : ( + + + + + + + + + + + + + + + + Proxy artifacts: config {status.feedProxyConfigFound ? "present" : "missing"} · vars{" "} + {status.feedProxyVarsFound ? "present" : "missing"} · {status.feedProxyFeedCount} mapped + feed endpoint(s) · GBFS proxy {status.gbfsProxyUrl ?? "not configured"} + + {status.gbfsCatalog.state === "active" && ( + + Registry {status.gbfsCatalog.commit?.slice(0, 12)} ·{" "} + {status.gbfsCatalog.registryAdded} added · {status.gbfsCatalog.transitousPreferred}{" "} + Transitous-preferred duplicate(s) · {status.gbfsCatalog.quarantined} quarantined ·{" "} + {status.gbfsCatalog.validationFailed} validation failure(s) + + )} + + Active epoch {status.activeEpoch ?? "unknown"} · candidate{" "} + {status.candidateEpoch ?? "none"} · tested{" "} + {status.testedAt ? formatDate(status.testedAt) : "never"} · rollback{" "} + {status.rollbackAvailable ? "available" : "unavailable"} + + + Capacity:{" "} + {status.preflightRequiredDiskBytes != null + ? `${formatBytes(status.preflightRequiredDiskBytes)} required` + : "not estimated"}{" "} + ·{" "} + {status.preflightFreeDiskBytes != null + ? `${formatBytes(status.preflightFreeDiskBytes)} free` + : "free disk unknown"}{" "} + · previous healthy slot {status.previousHealthySlot ?? "none"} + {status.pinProposalPending ? " · pin proposal awaiting review" : ""} + + + Crowdsource sidecars are disabled pending authoritative provenance, protocol, license, + privacy-controller, moderation, and deletion approval. + + {status.capabilityState !== "healthy" && ( + + Promoted MOTIS capability evidence is {status.capabilityState}. + {status.capabilityError + ? ` ${status.capabilityError}` + : " Re-run the transactional import before trusting configured counts."} + + )} + {(status.feedProxyMode === "transitous-cloud" || status.feedProxyMode === "mixed") && ( + + MOTIS config still references Transitous cloud feed-proxy URLs. Rebuild MOTIS data to + switch fully to self-hosted proxy URLs. + + )} + + )} + + ); +} + +export function SharedMobilityPolicySection({ apiUrl }: { apiUrl: string }) { + const queryClient = useQueryClient(); + const { client } = useActivityClient(); + const policy = useQuery({ + ...serviceAdminQueries(client, apiUrl).policy(), + refetchInterval: 30_000, + }); + const data = policy.data; + const rollback = useMutation({ + retry: false, + mutationFn: async ({ + category, + enabled, + }: { + category: AdminServiceSharedMobilityCategory; + enabled: boolean; + }) => { + const effect = { kind: "policy" } as const; + const result = await serviceOrThrow( + sdk.rollbackAdminSharedMobility(category, { enabled }, { client }), + ); + // This response is the completed policy change, not a job acknowledgement. + await invalidateServiceAdministration(queryClient, apiUrl, effect); + return result; + }, + }); + + return ( + + + Shared mobility source policy + + + MOTIS-first decisions contain category-level operational state only; no coordinates or + vehicle IDs are recorded. Enable rollback to restore fan-out immediately. + + {policy.isError && Failed to load shared mobility policy.} + {rollback.isError && {rollback.error.message}} + + {(["bike", "scooter", "car"] as const).map((category) => { + const record = data?.decisions.find((decision) => decision.category === category); + const enabled = data?.rollbackCategories.includes(category) ?? false; + return ( + + {category} + + {record && ( + + called {record.decision.calledAdapters.join(", ") || "MOTIS only"}; skipped{" "} + {record.decision.skippedAdapters.join(", ") || "none"} + + )} + rollback.mutate({ category, enabled: checked })} + /> + } + label="Fan-out rollback" + /> + + ); + })} + + + ); +} diff --git a/apps/web/src/components/admin/transit/TransitPipelinePage.tsx b/apps/web/src/components/admin/transit/TransitPipelinePage.tsx index 6d00ffa60..ea0790d61 100644 --- a/apps/web/src/components/admin/transit/TransitPipelinePage.tsx +++ b/apps/web/src/components/admin/transit/TransitPipelinePage.tsx @@ -3,50 +3,60 @@ import RefreshIcon from "@mui/icons-material/Refresh"; 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 IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; import Tooltip from "@mui/material/Tooltip"; import { useQueryClient } from "@tanstack/react-query"; -import { TransitCredentialsSection } from "@/components/admin/services/TransitCredentialsSection"; import { AdminPageHeader } from "@/components/admin/shared/AdminPageHeader"; import { useTransitState } from "@/lib/admin/transitHooks"; import { CurrentJobCard } from "./CurrentJobCard"; import { FeedsBreakdownChart } from "./FeedsBreakdownChart"; import { FeedsTable } from "./FeedsTable"; -import { LockfileCard } from "./LockfileCard"; import { ProviderHealthTable } from "./ProviderHealthTable"; import { RecentJobsTable } from "./RecentJobsTable"; -export function TransitPipelinePage() { +export function TransitPipelinePage({ showHeader = true }: { showHeader?: boolean } = {}) { const queryClient = useQueryClient(); const { data: state, isLoading, isError, refetch, isFetching } = useTransitState(); return ( - - - { - void refetch(); - void queryClient.invalidateQueries({ queryKey: ["admin", "transit"] }); - }} - disabled={isFetching} - > - - - - } - /> - - - - + {showHeader && ( + + + { + void refetch(); + void queryClient.invalidateQueries({ queryKey: ["admin", "transit"] }); + }} + disabled={isFetching} + > + + + + } + /> + + )} + {!showHeader && ( + + )} {isLoading && ( - - - diff --git a/apps/web/src/components/admin/transit/TransitWorkspace.test.tsx b/apps/web/src/components/admin/transit/TransitWorkspace.test.tsx new file mode 100644 index 000000000..0acd85c1b --- /dev/null +++ b/apps/web/src/components/admin/transit/TransitWorkspace.test.tsx @@ -0,0 +1,44 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@/test"; +import { adminTestHistory } from "@/test/adminNavigation"; +import { TransitWorkspace } from "./TransitWorkspace"; + +const continuation = vi.hoisted(() => ({ current: null as null | { proposalId?: string } })); +vi.mock( + "next/navigation", + async () => (await import("@/test/adminNavigation")).adminNavigationMock, +); +vi.mock("@/lib/admin/accountAdmin", () => ({ + useAccountAdmin: () => ({ actorId: "actor", apiUrl: "http://data.test" }), +})); +vi.mock("../services/TransitSourcesSection", () => ({ + TransitSourcesSection: () =>
Source editor
, +})); +vi.mock("../services/TransitCredentialsSection", () => ({ + readTransitCredentialContinuation: () => continuation.current, + TransitCredentialsSection: ({ proposalId }: { proposalId?: string }) => ( +
Credential editor {proposalId}
+ ), +})); +vi.mock("./LockfileCard", () => ({ LockfileCard: () =>
Pin editor
})); +vi.mock("./TransitPipelinePage", () => ({ TransitPipelinePage: () =>
Pipeline status
})); +beforeEach(() => { + continuation.current = null; + adminTestHistory.reset("/admin/data/transit"); +}); +it("has one mounted owner at a time and respects tab history", () => { + render(); + expect(screen.getByText("Source editor")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Catalog" })); + expect(screen.getByText("Pin editor")).toBeInTheDocument(); + expect(screen.queryByText("Source editor")).toBeNull(); + act(() => adminTestHistory.back()); + expect(screen.getByText("Source editor")).toBeInTheDocument(); +}); +it("restores a completed matching credential continuation once, then permits explicit navigation", () => { + continuation.current = { proposalId: "reviewed-proposal" }; + render(); + expect(screen.getByText("Credential editor reviewed-proposal")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Sources" })); + expect(screen.getByText("Source editor")).toBeInTheDocument(); +}); diff --git a/apps/web/src/components/admin/transit/TransitWorkspace.tsx b/apps/web/src/components/admin/transit/TransitWorkspace.tsx new file mode 100644 index 000000000..4c782e55d --- /dev/null +++ b/apps/web/src/components/admin/transit/TransitWorkspace.tsx @@ -0,0 +1,92 @@ +"use client"; +import Alert from "@mui/material/Alert"; +import Stack from "@mui/material/Stack"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useAccountAdmin } from "@/lib/admin/accountAdmin"; +import { serviceAdminQueries } from "@/lib/admin/serviceAdmin"; +import { useAdminQueryState } from "@/lib/admin/useAdminQueryState"; +import { useActivityClient } from "../activity/activityQueries"; +import { + readTransitCredentialContinuation, + TransitCredentialsSection, +} from "../services/TransitCredentialsSection"; +import { TransitSourcesSection } from "../services/TransitSourcesSection"; +import { AdminPageHeader } from "../shared/AdminPageHeader"; +import { LockfileCard } from "./LockfileCard"; +import { MotisTransitousSection, SharedMobilityPolicySection } from "./TransitMobilitySections"; +import { TransitPipelinePage } from "./TransitPipelinePage"; + +const TABS = ["sources", "catalog", "credentials", "pipeline", "mobility"] as const; +export function TransitWorkspace() { + const { apiUrl, actorId } = useAccountAdmin(); + return ; +} +function TransitView({ apiUrl, actorId }: { apiUrl: string; actorId: string }) { + const { params, update } = useAdminQueryState(); + const raw = params.get("tab"); + const tab = TABS.find((value) => value === raw) ?? "sources"; + const restored = useRef(false); + const [proposalId, setProposalId] = useState(); + useEffect(() => { + if (!actorId || restored.current) return; + restored.current = true; + const pending = readTransitCredentialContinuation(apiUrl, actorId); + if (pending) { + setProposalId(pending.proposalId); + update({ tab: "credentials" }, "replace"); + } + }, [apiUrl, actorId, update]); + return ( + + + update({ tab: value })} + variant="scrollable" + scrollButtons="auto" + aria-label="Transit sections" + > + {TABS.map((value) => ( + + ))} + +
+ {tab === "sources" && ( +
+ +
+ )} + {tab === "catalog" && } + {tab === "credentials" && } + {tab === "pipeline" && } + {tab === "mobility" && } +
+
+ ); +} +export function TransitMobilityView() { + return ; +} +function MobilityView() { + const { client, apiUrl } = useActivityClient(); + const query = useQuery({ ...serviceAdminQueries(client, apiUrl).data(), refetchInterval: 30000 }); + return ( + + {query.isError && MOTIS evidence is unavailable.} + {query.data && } + + + ); +} diff --git a/apps/web/src/components/admin/transit/__tests__/page.test.tsx b/apps/web/src/components/admin/transit/__tests__/page.test.tsx index 79a6f61e2..bc6bd8f24 100644 --- a/apps/web/src/components/admin/transit/__tests__/page.test.tsx +++ b/apps/web/src/components/admin/transit/__tests__/page.test.tsx @@ -164,7 +164,7 @@ describe("TransitPipelinePage", () => { const { TransitPipelinePage } = await import("../TransitPipelinePage"); const markup = renderToStaticMarkup(); expect(markup).toContain("Transit pipeline"); - expect(markup).toContain("Catalog pins"); + expect(markup).not.toContain("Catalog pins"); expect(markup).toContain("Pipeline idle"); expect(markup).toContain("Feeds breakdown"); expect(markup).toContain("Recent jobs"); @@ -175,10 +175,7 @@ describe("TransitPipelinePage", () => { it("renders the active paired catalogs and last-sync status", async () => { const { TransitPipelinePage } = await import("../TransitPipelinePage"); const markup = renderToStaticMarkup(); - expect(markup).toContain("abc123def4567890"); - expect(markup).toContain("atlas-active"); - expect(markup).toContain("gbfs-active"); - expect(markup).toContain("Active pair"); + expect(markup).not.toContain("Catalog pins"); // The success chip's label is what surfaces "last sync status". expect(markup).toContain("success"); }); diff --git a/apps/web/src/lib/admin/adminDataNavigation.ts b/apps/web/src/lib/admin/adminDataNavigation.ts new file mode 100644 index 000000000..c8117a388 --- /dev/null +++ b/apps/web/src/lib/admin/adminDataNavigation.ts @@ -0,0 +1,13 @@ +/** Specialized maintenance retains ownership of recovery phases and publication controls. */ +export const OSM_OPERATION_IDS = ["download-osm", "download-fonts", "convert-overpass"] as const; +export const ADVANCED_OPERATION_IDS = ["update", "link", "clean"] as const; +export function adminDataOperationHref(id: string) { + const topic = id.startsWith("overture-") + ? "overture" + : ["search-index-build", "notable-places-build"].includes(id) + ? "search" + : (OSM_OPERATION_IDS as readonly string[]).includes(id) + ? "osm" + : "operations"; + return `/admin/data/${topic}#operation-${encodeURIComponent(id)}`; +} diff --git a/apps/web/src/lib/admin/adminNavigation.test.ts b/apps/web/src/lib/admin/adminNavigation.test.ts index de36b2168..7735d144b 100644 --- a/apps/web/src/lib/admin/adminNavigation.test.ts +++ b/apps/web/src/lib/admin/adminNavigation.test.ts @@ -39,12 +39,21 @@ it("keeps hosted System tools and restricts privacy administrators to casework", ).toEqual(["/admin/privacy"]); expect(adminDestinations({ role: "user", selfHosted: true })).toEqual([]); }); -it("registers only existing B-stage Data topics and assigns one active workspace", () => { +it("registers the final Data topics and assigns one active workspace", () => { expect( adminDestinations({ role: "admin", selfHosted: true }) .filter((entry) => entry.workspace === "data") .map((entry) => entry.href), - ).toEqual(["/admin/data", "/admin/data/transit", "/admin/data/coverage"]); + ).toEqual([ + "/admin/data", + "/admin/data/osm", + "/admin/data/overture", + "/admin/data/search", + "/admin/data/transit", + "/admin/data/coverage", + "/admin/data/transfers", + "/admin/data/operations", + ]); for (const path of [ "/admin/services/api", "/admin/system/services/api", diff --git a/apps/web/src/lib/admin/adminNavigation.ts b/apps/web/src/lib/admin/adminNavigation.ts index b8da1f817..32b9da4d7 100644 --- a/apps/web/src/lib/admin/adminNavigation.ts +++ b/apps/web/src/lib/admin/adminNavigation.ts @@ -50,11 +50,26 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "/admin/extensions", "extensions", ), + destination("data", "Data", "Acquire, build and maintain map data", "/admin/data", "data"), destination( + "osm", + "OSM", + "Extracts, builds and OpenStreetMap acquisition", + "/admin/data/osm", "data", - "Data workflows", - "Acquire, build and maintain map data", - "/admin/data", + ), + destination( + "overture", + "Overture", + "Places release, conflation and recovery", + "/admin/data/overture", + "data", + ), + destination( + "search", + "Search & places", + "Search index, ambient and notable places", + "/admin/data/search", "data", ), destination( @@ -71,6 +86,20 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "/admin/data/coverage", "data", ), + destination( + "transfers", + "Prepared transfers", + "Review, stage and transfer data artifacts", + "/admin/data/transfers", + "data", + ), + destination( + "operations", + "Advanced operations", + "Full updates, hardlink sync and destructive cleanup", + "/admin/data/operations", + "data", + ), destination("users", "Users", "Accounts, sessions and recovery", "/admin/users", "users"), { ...destination( diff --git a/apps/web/src/lib/admin/adminSearch.test.ts b/apps/web/src/lib/admin/adminSearch.test.ts index 79d6deb80..f1a23e29f 100644 --- a/apps/web/src/lib/admin/adminSearch.test.ts +++ b/apps/web/src/lib/admin/adminSearch.test.ts @@ -12,7 +12,7 @@ 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"], + ["GTFS", "/admin/data/transit?tab=sources"], ["backup expiry", "/admin/system/backups#backup-retention"], ["release channel", "/admin/system/updates#release-selector"], ])("finds the current owning control for %s", (query, href) => { diff --git a/apps/web/src/lib/admin/adminSearch.ts b/apps/web/src/lib/admin/adminSearch.ts index 64b4538d1..8dbb55c3f 100644 --- a/apps/web/src/lib/admin/adminSearch.ts +++ b/apps/web/src/lib/admin/adminSearch.ts @@ -1,4 +1,5 @@ import { SCORE_CUTOFF, scoreCommand } from "@openmapx/command-palette"; +import { adminDataOperationHref } from "./adminDataNavigation"; import { type AdminNavigationAccess, adminDestinations } from "./adminNavigation"; export interface AdminSearchEntry { id: string; @@ -57,14 +58,13 @@ export function buildAdminSearchEntries( href: "/admin/settings/email?field=smtpHost", keywords: ["mail server", "email", "E-Mail"], }); - if (access.selfHosted !== false) - entries.push({ - id: "transit-sources", - title: "GTFS", - description: "Transit source acquisition", - href: "/admin/data#transit-sources", - keywords: ["transit feeds", "sources", "Fahrplandaten"], - }); + entries.push({ + id: "transit-sources", + title: "GTFS", + description: "Transit source acquisition", + href: "/admin/data/transit?tab=sources", + keywords: ["transit feeds", "sources", "Fahrplandaten"], + }); if (access.selfHosted !== false) entries.push( { @@ -115,7 +115,7 @@ export function buildAdminSearchEntries( id: `operation:${entry.id}`, title: entry.title, description: entry.description, - href: `/admin/data#operation-${encodeURIComponent(entry.id)}`, + href: adminDataOperationHref(entry.id), keywords: [entry.id, ...(entry.fields ?? []).map((field) => field.label)], }); return entries; diff --git a/docs/docs/administration/admin-panel.md b/docs/docs/administration/admin-panel.md index e34f1c89d..77639a4e8 100644 --- a/docs/docs/administration/admin-panel.md +++ b/docs/docs/administration/admin-panel.md @@ -80,56 +80,37 @@ integrations, services and bundles. Review the exact service commit, effective host ports and security rating before confirming. A verified catalog entry is not a security audit. See [Community extensions](./community-extensions.md). -### Services _(self-hosted only)_ - -- **Catalog:** service selection, container state, configuration, credentials - and lifecycle actions. -- **Compose preview:** generated Compose configuration and stack actions. -- **Data workflows:** source acquisition, transit publication, engine builds, - Overture, search indexes and map-place publication. Forms and previews come - from the API's operation catalog; destructive operations require confirmation. -- **Backups:** create, inspect, restore and delete retained snapshots. - -- **Catalog** — every installed service (built-in or extension-installed), its - capabilities and container state, with start/stop/restart. -- **Compose preview** — the generated `docker-compose` file, so you can see what - the renderer will do before bringing the stack up. Generate configuration only, - start the selected stack, or copy host shutdown guidance from this page. -- **Data workflows** — the inventory of downloaded data (OSM extracts, GTFS - feeds, glyph fonts), GTFS import controls, and Overture Places release and - durable OSM↔GERS link operations. The operation cards are rendered from a - server-side catalog: the API declares each operation's fields, defaults, - validation, and risk level, and the form only mirrors that contract. Queueing - an operation first asks the API for a preview of what it will do; destructive - operations add an explicit warning to that confirmation step. -- **Backups** — the on-disk snapshot manager. See [Backup and - restore](./backup-and-restore.md). - -The full picture of services and the `openmapx` CLI that backs these pages is in -[Services administration](./services-administration.md) and [Managing -services](../install/managing-services.md). - -### Maintenance _(self-hosted only)_ - -Check the selected release against running image digests, download an update, -or run deep service probes. A release check does not download application images. -Updates offer a pre-update database backup, replace the operations agent when -needed, and checkpoint progress before replacing the API so the new process can -finish after migrations. Inspect the job outcome before retrying an interrupted -update. See [Upgrading](../install/upgrading.md). - -You can start the update as soon as a check finishes. Checks have their own -once-per-30-seconds throttle, separate from the one shared by updates and -diagnostics. If the API rejects a request as too frequent, the page says so, keeps the update -confirmation open with your choices, and re-enables the action when the server's wait -is over. Nothing is retried automatically; press the button again when it is available. - -### Transit - -Inspect the Transitous catalog pin, feed/import state, provider health and -pipeline jobs. Pin changes require review. See -[MOTIS operations profiles](./motis-operations-profiles.md) and -[Public transit](../features/public-transit.md). +### System + +System groups operational topics without mixing them into application Settings: + +- **Services:** searchable inventory, configuration, credentials, logs and service actions. Bulk controls appear after selecting targets; presets change action targets only. Build All reviews a captured service list. +- **Deployment:** persistent service roots and their required dependencies, domain, ACME contact and default region. Compose preview and stack actions are in a disclosure. +- **Updates:** release selector, checked image updates and update progress. +- **Backups:** create, inspect, restore and delete snapshots, with backup retention in the same topic. +- **Diagnostics:** readiness evidence and deep service probes. +- **Cache:** inspect and clear application caches. +- **Configuration transfer:** export, import, migration and protected credential transfer. + +Services, Deployment, Updates, Backups and Diagnostics require self-hosted control. Cache and Configuration transfer remain available to hosted administrators. + +Release checks do not download application images. Updates can create a pre-update database backup, replace the operations agent and checkpoint progress before replacing the API. Updates and diagnostics share admission state; inspect the original job before retrying an interrupted or unknown outcome. Check and update throttles remain distinct. See [Upgrading](../install/upgrading.md). + +Platform previews preserve the fresh configuration owned by other topics. Applying an expired or conflicting preview requires a new review. Domain changes retain DNS/TLS, alternate-login, OAuth and passkey recovery checks. See [Services administration](./services-administration.md). + +### Data + +`/admin/data` is a directory. It reads the aggregate inventory without polling every data family. Open a workspace to inspect its authoritative evidence: + +- **OSM:** extract age, paginated disk/build inventory, OSM and font downloads, and Overpass conversion. +- **Overture:** release sync, durable linking and advanced recovery phases. +- **Search & places:** search index, ambient data and notable-place publication. +- **Transit:** Sources, Catalog, Credentials, Pipeline and Mobility tabs. Sources shows desired acquisition separately from active publication; Catalog retains paired pin review and validation. Credentials retains actor-scoped reauthentication without putting secrets in navigation state. +- **Coverage & freshness:** geography, publication, freshness and rights evidence. +- **Prepared transfers:** existing staged artifact import/export review. +- **Advanced operations:** full update, hardlink sync and destructive cleanup. + +Launchers open one existing schema-driven operation form at a time. The API still owns fields, defaults, validation and preview. Destructive operations retain explicit confirmation. Hidden workspaces do not run their domain status queries. See [MOTIS operations profiles](./motis-operations-profiles.md) and [Public transit](../features/public-transit.md). ### Coverage & freshness @@ -138,7 +119,7 @@ Fuel, Cameras and Traffic. Publication, freshness, runtime, geography and rights remain separate observations. **Unknown** means evidence is missing or cannot be verified. See [Coverage & freshness](./coverage-freshness.md). -### Cache +### Cache (System) The Redis/Valkey cache inspector — list the cache namespaces (geocoding results, provider responses, and the rest) with their key counts, and clear one @@ -185,21 +166,20 @@ does not approve generation for the deployment. See ## CLI coverage and deliberate exclusions -| Task | Admin location | -| ---------------------------------------------------------- | ------------------------- | -| Service selection, status, lifecycle, builds and logs | Services | -| Release check/update and deep probes | Maintenance | -| Source downloads, transit sync, indexes, links and cleanup | Services → Data workflows | -| Extension install/update/removal | Extensions | -| Account administration | Users | -| Snapshot create/list/restore/delete | Services → Backups | -| Application cache clearing | Cache | -| Transitous pin inspection and approval | Transit | +| Task | Admin location | +| ---------------------------------------------------------- | ------------------------------ | +| Service selection, status, lifecycle, builds and logs | System → Services / Deployment | +| Release check/update and deep probes | System → Updates / Diagnostics | +| Source downloads, transit sync, indexes, links and cleanup | Data workspaces | +| Extension install/update/removal | Extensions | +| Account administration | Users | +| Snapshot create/list/restore/delete | System → Backups | +| Application cache clearing | System → Cache | +| Transitous pin inspection and approval | Data → Transit → Catalog | Integration scaffolding, validation, building and packaging remain developer CLI workflows. The panel excludes `compose down --volumes`, unrestricted Redis flushes -and individual low-level Overture phases. Use the documented orchestrated data -operations for routine administration. +and unrestricted destructive host commands. Overture advanced recovery retains its specialized pull, ingest and extraction controls; use orchestrated data operations for routine administration. ## Where to go next @@ -232,3 +212,9 @@ workspace, retaining supported public navigation state. Ordinary unsaved drafts survive topic changes. The draft count in the top bar can discard all ordinary drafts after confirmation, including drafts for a removed service or integration. Draft values remain in this browser document's memory only. + +## Privacy case navigation + +Casework uses `?case=§ion=
` for direct, authorized detail access independently of queue membership. Identity, Tasks, Decisions, Attachments, Backup review, Generation & delivery and Events are addressable disclosures. Ordinary case form inputs survive section toggles; private delivery controls dispose when closed. Available work and denial reasons come from the server capabilities, and version conflicts require refresh. + +Changing cases, including browser Back, clears the previous pending delivery. A reauthentication return without a case restores its matching pending case. Casework shows a concise readiness status; the complete checklist and configuration stay in Privacy setup. Privacy administrators receive contact guidance without settings links. On narrow screens a case selector replaces the long side queue. diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index a2ecd9e09..5f2b42047 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -4940,7 +4940,7 @@ "description": "Erweiterungen entdecken und verwalten" }, "data": { - "label": "Datenabläufe", + "label": "Daten", "description": "Kartendaten beschaffen, erstellen und pflegen" }, "transit": { @@ -5014,6 +5014,26 @@ "configuration-transfer": { "label": "Konfiguration übertragen", "description": "Konfiguration exportieren, importieren und migrieren" + }, + "osm": { + "label": "OSM", + "description": "Extrakte, Builds und OpenStreetMap-Daten" + }, + "overture": { + "label": "Overture", + "description": "Places-Version, Abgleich und Wiederherstellung" + }, + "search": { + "label": "Suche & Orte", + "description": "Suchindex, Umgebungsdaten und bekannte Orte" + }, + "transfers": { + "label": "Vorbereitete Transfers", + "description": "Datenartefakte prüfen, vorbereiten und übertragen" + }, + "operations": { + "label": "Erweiterte Aktionen", + "description": "Gesamtaktualisierung, Hardlink-Abgleich und Bereinigung" } }, "applicationUrlHelp": "Die öffentliche Anwendungs-URL bestimmt von der Anwendung erzeugte Links. Die Bereitstellung verwaltet den Host-Ursprung und Zertifikate.", @@ -5030,6 +5050,21 @@ "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", + "privacyDue": "Frist: {date}", + "privacyAvailable": "Verfügbare Aufgaben", + "privacyOlderEvents": "Ältere Ereignisse anzeigen", + "privacySections": { + "identity": "Identität", + "tasks": "Aufgaben", + "decisions": "Entscheidungen", + "attachments": "Anhänge", + "backups": "Backup-Prüfung", + "delivery": "Erstellung & Zustellung", + "events": "Ereignisse" + }, + "transitSourceOwner": "Transit-Quellen verwalten", + "privacyReadinessReady": "Die Datenschutz-Bereitschaftsprüfungen sind bestanden.", + "privacyReadinessBlocked": "Die Datenschutz-Bereitschaft muss geprüft werden.", "hostedData": "Hostverwaltete Datenaktionen sind in dieser gehosteten Bereitstellung nicht verfügbar. Transit und Abdeckung bleiben verfügbar.", "buildAll": { "title": "Alle aktivierten Dienste bauen", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 94d80fe18..df60c8ad4 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -4940,7 +4940,7 @@ "description": "Discover and manage installed extensions" }, "data": { - "label": "Data workflows", + "label": "Data", "description": "Acquire, build and maintain map data" }, "transit": { @@ -5014,6 +5014,26 @@ "configuration-transfer": { "label": "Configuration transfer", "description": "Export, import and migrate configuration" + }, + "osm": { + "label": "OSM", + "description": "Extracts, builds and OpenStreetMap acquisition" + }, + "overture": { + "label": "Overture", + "description": "Places release, conflation and recovery" + }, + "search": { + "label": "Search & places", + "description": "Search index, ambient and notable places" + }, + "transfers": { + "label": "Prepared transfers", + "description": "Review, stage and transfer data artifacts" + }, + "operations": { + "label": "Advanced operations", + "description": "Full updates, hardlink sync and destructive cleanup" } }, "applicationUrlHelp": "The public application URL identifies links generated by the application. Deployment manages the host origin and certificates.", @@ -5030,6 +5050,21 @@ "chooseTopic": "Choose a topic", "authorityChanged": "Administrator access changed. Reload to confirm the current account and permissions.", "reload": "Reload", + "privacyDue": "Due: {date}", + "privacyAvailable": "Available work", + "privacyOlderEvents": "Show older events", + "privacySections": { + "identity": "Identity", + "tasks": "Tasks", + "decisions": "Decisions", + "attachments": "Attachments", + "backups": "Backup review", + "delivery": "Generation & delivery", + "events": "Events" + }, + "transitSourceOwner": "Manage transit sources", + "privacyReadinessReady": "Privacy readiness checks passed.", + "privacyReadinessBlocked": "Privacy readiness needs attention.", "hostedData": "Host-managed data operations are unavailable on this hosted deployment. Transit and Coverage remain available.", "buildAll": { "title": "Build all enabled services", From 94a477746862818c432508e8a5ee013ef7e52982 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 5 Nov 2026 14:34:42 +0100 Subject: [PATCH 2/3] fix(admin): retain descriptive titles for Data workspaces --- .../(full-admin)/data/operations/page.tsx | 2 + .../app/admin/(full-admin)/data/osm/page.tsx | 2 + .../admin/(full-admin)/data/overture/page.tsx | 2 + .../admin/(full-admin)/data/search/page.tsx | 2 + .../(full-admin)/data/transfers/page.tsx | 2 + .../admin/(full-admin)/data/transit/page.tsx | 2 + apps/web/src/app/admin/privacy/page.test.tsx | 26 ++++++++++++- .../admin/AdminLayout.navigation.test.tsx | 24 ++++++++++++ apps/web/src/components/admin/AdminLayout.tsx | 35 ++++++++++++----- .../admin/data/DataWorkspace.test.tsx | 36 ++++++++++++++--- .../components/admin/data/DataWorkspace.tsx | 35 +++++++++++------ .../privacy/PrivacyReadinessChecklist.tsx | 3 ++ .../admin/privacy/PrivacyWorkspace.tsx | 17 ++++++++ .../services/TransitCredentialsSection.tsx | 20 +++++++++- .../admin/shared/HostedDataTopics.tsx | 4 +- .../admin/transit/TransitWorkspace.test.tsx | 39 ++++++++++++++++++- .../admin/transit/TransitWorkspace.tsx | 14 ++++++- .../web/src/lib/admin/adminNavigation.test.ts | 5 +++ apps/web/src/lib/admin/adminNavigation.ts | 5 +++ .../docs/administration/backup-and-restore.md | 4 +- .../administration/community-extensions.md | 2 +- .../integrations-administration.md | 2 +- .../administration/platform-configuration.md | 14 ++++--- .../administration/services-administration.md | 8 ++-- .../developer/ambient-places-publication.md | 4 +- docs/docs/features/overture-places.md | 3 +- docs/docs/guides/dawarich.md | 2 +- docs/docs/guides/supporting-infrastructure.md | 2 +- docs/docs/install/configuration.md | 14 +++---- docs/docs/install/managing-services.md | 6 +-- docs/docs/install/preparing-data.md | 4 +- docs/docs/install/upgrading.md | 2 +- packages/i18n/locales/de.json | 4 +- packages/i18n/locales/en.json | 4 +- 34 files changed, 281 insertions(+), 69 deletions(-) diff --git a/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx b/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx index eeac0c1c8..c866e1cc1 100644 --- a/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/operations/page.tsx @@ -1,4 +1,6 @@ +import type { Metadata } from "next"; import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export const metadata: Metadata = { title: "Advanced operations — Admin — OpenMapX" }; export default function Page() { return ; } diff --git a/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx b/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx index 243b7c3ed..263ba9dcb 100644 --- a/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/osm/page.tsx @@ -1,4 +1,6 @@ +import type { Metadata } from "next"; import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export const metadata: Metadata = { title: "OSM — Admin — OpenMapX" }; export default function Page() { return ; } diff --git a/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx b/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx index 143ba0364..86038bdf2 100644 --- a/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/overture/page.tsx @@ -1,4 +1,6 @@ +import type { Metadata } from "next"; import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export const metadata: Metadata = { title: "Overture — Admin — OpenMapX" }; export default function Page() { return ; } diff --git a/apps/web/src/app/admin/(full-admin)/data/search/page.tsx b/apps/web/src/app/admin/(full-admin)/data/search/page.tsx index 5893e2a8f..d36a7c8d5 100644 --- a/apps/web/src/app/admin/(full-admin)/data/search/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/search/page.tsx @@ -1,4 +1,6 @@ +import type { Metadata } from "next"; import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export const metadata: Metadata = { title: "Search & places — Admin — OpenMapX" }; export default function Page() { return ; } diff --git a/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx b/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx index f48190de2..c4f9e977e 100644 --- a/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/transfers/page.tsx @@ -1,4 +1,6 @@ +import type { Metadata } from "next"; import { DataWorkspace } from "@/components/admin/data/DataWorkspace"; +export const metadata: Metadata = { title: "Prepared transfers — Admin — OpenMapX" }; export default function Page() { return ; } diff --git a/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx b/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx index 87c74122c..cc15b7331 100644 --- a/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx +++ b/apps/web/src/app/admin/(full-admin)/data/transit/page.tsx @@ -1,4 +1,6 @@ +import type { Metadata } from "next"; import { TransitWorkspace } from "@/components/admin/transit/TransitWorkspace"; +export const metadata: Metadata = { title: "Transit — Admin — OpenMapX" }; export default function Page() { return ; } diff --git a/apps/web/src/app/admin/privacy/page.test.tsx b/apps/web/src/app/admin/privacy/page.test.tsx index 0e8568286..46b519a46 100644 --- a/apps/web/src/app/admin/privacy/page.test.tsx +++ b/apps/web/src/app/admin/privacy/page.test.tsx @@ -199,7 +199,9 @@ describe("privacy admin case selection", () => { expect(await screen.findByText("adminNavigation.privacyReadinessBlocked")).toBeInTheDocument(); expect(screen.getByText("privacySetup.readiness.contactFullAdmin")).toBeInTheDocument(); - expect(screen.queryByText("privacySetup.readiness.checks.controller-contact.title")).toBeNull(); + expect( + screen.getByText(/privacySetup.readiness.checks.controller-contact.title/), + ).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /controller-contact/ })).not.toBeInTheDocument(); expect(screen.queryByText("controller-contact")).not.toBeInTheDocument(); }); @@ -291,3 +293,25 @@ it.each([403, 404])( expect(screen.queryByText(/registration-for-case-b/)).not.toBeInTheDocument(); }, ); + +it("names a blocked encryption readiness check for privacy operators without a setup link", async () => { + binding.client = createApiClient({ baseUrl: "http://privacy.test", credentials: "include" }); + sessionStorage.clear(); + adminTestHistory.reset("/admin/privacy"); + installFetch(); + const fetchFixture = globalThis.fetch; + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => + String(input).endsWith("/privacy/admin/readiness") + ? Promise.resolve( + json({ + ...privacyReadiness(), + checks: [{ id: "encryption-key", status: "fail", detailCode: "unavailable" }], + }), + ) + : fetchFixture(input, init), + ); + render(, { wrapper: createQueryWrapper(createTestQueryClient()) }); + await screen.findByText(/privacySetup.readiness.checks.encryption-key.title/); + expect(screen.getByText("privacySetup.readiness.contactFullAdmin")).toBeInTheDocument(); + expect(document.querySelector('a[href="/admin/privacy-setup"]')).toBeNull(); +}); diff --git a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx index 9920a62ec..185358533 100644 --- a/apps/web/src/components/admin/AdminLayout.navigation.test.tsx +++ b/apps/web/src/components/admin/AdminLayout.navigation.test.tsx @@ -153,3 +153,27 @@ it("server-renders the desktop sidebar open without a hydration width shift", () ); expect(markup).toContain('aria-expanded="true"'); }); + +it("links hosted Data operators to available Data topics", () => { + adminTestHistory.reset("/admin/data/osm"); + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + })); + render( + + + + + , + { wrapper: createQueryWrapper() }, + ); + expect(screen.queryByRole("button", { name: "Host mutation" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Cache" })).toBeNull(); + expect(screen.getAllByRole("link", { name: "Transit" })).toHaveLength(2); + expect(screen.getAllByRole("link", { name: "Coverage & freshness" })).toHaveLength(2); +}); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index 8535c03a5..81b647a78 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -150,17 +150,32 @@ function AdminLayoutContent({ children, user, selfHosted = false }: AdminLayoutP ) : hostUnavailable && destination ? ( - {t("hostUnavailable")} + + {t(workspace === "data" ? "hostedData" : "hostUnavailable")} + - - + {workspace === "data" ? ( + <> + + + + ) : ( + <> + + + + )} ) : ( diff --git a/apps/web/src/components/admin/data/DataWorkspace.test.tsx b/apps/web/src/components/admin/data/DataWorkspace.test.tsx index 3a7e585e1..80a4b1037 100644 --- a/apps/web/src/components/admin/data/DataWorkspace.test.tsx +++ b/apps/web/src/components/admin/data/DataWorkspace.test.tsx @@ -1,3 +1,7 @@ +import { en } from "@openmapx/i18n"; +import { NextIntlClientProvider } from "next-intl"; +import { AdminNavigationContext } from "../shared/AdminNavigationContext"; + vi.mock( "next/navigation", async () => (await import("@/test/adminNavigation")).adminNavigationMock, @@ -47,9 +51,11 @@ afterEach(() => { }); it("uses only the aggregate read for the directory and links every owned domain", async () => { render( - - - , + + + + + , ); await screen.findByText(/7 extracts/); expect(reads).toEqual(["aggregate"]); @@ -67,11 +73,29 @@ it("uses only the aggregate read for the directory and links every owned domain" }); it("mounts only the selected domain's status family", () => { render( - - - , + + + + + , ); expect(screen.getByText("Search family")).toBeInTheDocument(); expect(screen.getByText("Notable family")).toBeInTheDocument(); expect(reads).toEqual(["search"]); }); + +it("offers hosted admins only Transit and Coverage without an ops inventory request", () => { + render( + + + + + + + , + ); + expect(document.querySelector('a[href="/admin/data/osm"]')).toBeNull(); + expect(document.querySelector('a[href="/admin/data/transit"]')).not.toBeNull(); + expect(screen.getAllByRole("heading", { name: "Data" })).toHaveLength(1); + expect(reads).toEqual([]); +}); diff --git a/apps/web/src/components/admin/data/DataWorkspace.tsx b/apps/web/src/components/admin/data/DataWorkspace.tsx index ceeb7b7d2..4a5136063 100644 --- a/apps/web/src/components/admin/data/DataWorkspace.tsx +++ b/apps/web/src/components/admin/data/DataWorkspace.tsx @@ -7,13 +7,14 @@ import Typography from "@mui/material/Typography"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useEffect } from "react"; import { ADVANCED_OPERATION_IDS, adminDataOperationHref, OSM_OPERATION_IDS, } from "@/lib/admin/adminDataNavigation"; -import { ADMIN_DESTINATIONS } from "@/lib/admin/adminNavigation"; +import { ADMIN_DESTINATIONS, adminDestinations } from "@/lib/admin/adminNavigation"; import { serviceAdminQueries } from "@/lib/admin/serviceAdmin"; import { useAdminQueryState } from "@/lib/admin/useAdminQueryState"; import { useActivityClient } from "../activity/activityQueries"; @@ -25,11 +26,14 @@ import { NotablePlacesMaintenance } from "../services/NotablePlacesMaintenance"; import { OsmExtractDetails } from "../services/OsmExtractDetails"; import { OvertureMaintenance } from "../services/OvertureMaintenance"; import { SearchIndexMaintenance } from "../services/SearchIndexMaintenance"; +import { useAdminNavigationAccess } from "../shared/AdminNavigationContext"; import { AdminPageHeader } from "../shared/AdminPageHeader"; +import { HostedDataTopics } from "../shared/HostedDataTopics"; export type DataTopic = "overview" | "osm" | "overture" | "search" | "transfers" | "operations"; export function DataWorkspace({ topic = "overview" }: { topic?: DataTopic }) { const { apiUrl } = useActivityClient(); + const t = useTranslations("adminNavigation"); const router = useRouter(); useEffect(() => { if (topic !== "overview") return; @@ -45,7 +49,10 @@ export function DataWorkspace({ topic = "overview" }: { topic?: DataTopic }) { ); return ( - + {topic === "overview" && } {topic === "osm" && } {topic === "overture" && ( @@ -77,7 +84,13 @@ export function DataWorkspace({ topic = "overview" }: { topic?: DataTopic }) { function DataDirectory() { const { client, apiUrl } = useActivityClient(); // No domain-specific status subscriptions or polling on the directory. - const query = useQuery(serviceAdminQueries(client, apiUrl).data()); + const access = useAdminNavigationAccess(); + const t = useTranslations("adminNavigation"); + const query = useQuery({ + ...serviceAdminQueries(client, apiUrl).data(), + enabled: access.role === "admin" && access.selfHosted !== false, + }); + if (access.selfHosted === false) return ; return ( {query.isError && ( @@ -88,8 +101,9 @@ function DataDirectory() { Inventory summary is unavailable. Open a workspace to inspect its own status. )} - {ADMIN_DESTINATIONS.filter((entry) => entry.workspace === "data" && entry.id !== "data").map( - (entry) => ( + {adminDestinations(access) + .filter((entry) => entry.workspace === "data" && entry.id !== "data") + .map((entry) => ( - {entry.label} + {t(`destinations.${entry.id}.label`)} - {entry.description} + {t(`destinations.${entry.id}.description`)} {entry.id === "osm" && query.data ? query.data.osm.unavailable ? "Inventory unavailable" - : `${query.data.osm.total} extract${query.data.osm.total === 1 ? "" : "s"}` - : "Open to inspect"} + : t("extractCount", { count: query.data.osm.total }) + : t("openToInspect")} - ), - )} + ))} ); } diff --git a/apps/web/src/components/admin/privacy/PrivacyReadinessChecklist.tsx b/apps/web/src/components/admin/privacy/PrivacyReadinessChecklist.tsx index 16d69ac69..e50099526 100644 --- a/apps/web/src/components/admin/privacy/PrivacyReadinessChecklist.tsx +++ b/apps/web/src/components/admin/privacy/PrivacyReadinessChecklist.tsx @@ -44,6 +44,9 @@ const CHECK_ACTIONS: Record = { const KNOWN_CHECKS = new Set(Object.keys(CHECK_ACTIONS)); +export const privacyReadinessCheckTitle = (id: string) => + KNOWN_CHECKS.has(id) ? `readiness.checks.${id}.title` : "readiness.unknown.title"; + function statusColor(status: string): "success" | "warning" | "error" { if (status === "pass") return "success"; if (status === "warning") return "warning"; diff --git a/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx b/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx index 2630fbd2d..50296c726 100644 --- a/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx +++ b/apps/web/src/components/admin/privacy/PrivacyWorkspace.tsx @@ -36,6 +36,7 @@ import { import { useAdminQueryState } from "@/lib/admin/useAdminQueryState"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { PrivacyCaseView } from "./PrivacyCaseView"; +import { privacyReadinessCheckTitle } from "./PrivacyReadinessChecklist"; export function PrivacyWorkspace() { const binding = usePrivacyAdmin(); return ; @@ -109,6 +110,22 @@ function Workspace({ binding }: { binding: PrivacyAdminBinding }) { } > {readiness.data.ready ? nav("privacyReadinessReady") : nav("privacyReadinessBlocked")} + {readiness.data.checks.some((check) => check.status !== "pass") && ( + + {readiness.data.checks + .filter((check) => check.status !== "pass") + .map((check) => ( + + {setup(privacyReadinessCheckTitle(check.id))} —{" "} + {setup( + check.status === "warning" + ? "readiness.status.warning" + : "readiness.status.fail", + )} + + ))} + + )} {!readiness.data.ready && session?.user.role !== "admin" && ( {setup("readiness.contactFullAdmin")} )} diff --git a/apps/web/src/components/admin/services/TransitCredentialsSection.tsx b/apps/web/src/components/admin/services/TransitCredentialsSection.tsx index f1f412bc9..d828f1d33 100644 --- a/apps/web/src/components/admin/services/TransitCredentialsSection.tsx +++ b/apps/web/src/components/admin/services/TransitCredentialsSection.tsx @@ -93,7 +93,13 @@ export function readTransitCredentialContinuation( } return null; } -export function TransitCredentialsSection({ proposalId }: { proposalId?: string } = {}) { +export function TransitCredentialsSection({ + proposalId, + onReviewFinished, +}: { + proposalId?: string; + onReviewFinished?: () => void; +} = {}) { const { apiUrl, actorId } = useAccountAdmin(); return ( ); } @@ -108,10 +115,12 @@ function TransitCredentialView({ apiUrl, actorId, proposalId, + onReviewFinished, }: { apiUrl: string; actorId: string; proposalId?: string; + onReviewFinished?: () => void; }) { const client = useMemo( () => createApiClient({ baseUrl: browserAdminApiBase(apiUrl), credentials: "include" }), @@ -251,6 +260,7 @@ function TransitCredentialView({ } catch { /* No secret is stored; private drafts still clear. */ } + onReviewFinished?.(); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["admin", "transit", "credentials", apiUrl, actorId], @@ -438,7 +448,13 @@ function TransitCredentialView({ ))} - + ), })); vi.mock("./LockfileCard", () => ({ LockfileCard: () =>
Pin editor
})); @@ -42,3 +53,27 @@ it("restores a completed matching credential continuation once, then permits exp fireEvent.click(screen.getByRole("tab", { name: "Sources" })); expect(screen.getByText("Source editor")).toBeInTheDocument(); }); + +it("returns Credentials to the active catalog after the reviewed continuation finishes", () => { + continuation.current = { proposalId: "reviewed-proposal" }; + render(); + expect(screen.getByText("Credential editor reviewed-proposal")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Finish review" })); + fireEvent.click(screen.getByRole("tab", { name: "Sources" })); + fireEvent.click(screen.getByRole("tab", { name: "Credentials" })); + expect(screen.getByText("Credential editor")).toBeInTheDocument(); + expect(screen.queryByText("Credential editor reviewed-proposal")).toBeNull(); +}); + +it("retires a finished proposal when history leaves and returns to Credentials", () => { + continuation.current = { proposalId: "reviewed-proposal" }; + render(); + fireEvent.click(screen.getByRole("tab", { name: "Sources" })); + fireEvent.click(screen.getByRole("tab", { name: "Credentials" })); + fireEvent.click(screen.getByRole("button", { name: "Finish review" })); + act(() => adminTestHistory.back()); + expect(screen.getByText("Source editor")).toBeInTheDocument(); + act(() => adminTestHistory.push("/admin/data/transit?tab=credentials")); + expect(screen.getByText("Credential editor")).toBeInTheDocument(); + expect(screen.queryByText("Credential editor reviewed-proposal")).toBeNull(); +}); diff --git a/apps/web/src/components/admin/transit/TransitWorkspace.tsx b/apps/web/src/components/admin/transit/TransitWorkspace.tsx index 4c782e55d..c988b095d 100644 --- a/apps/web/src/components/admin/transit/TransitWorkspace.tsx +++ b/apps/web/src/components/admin/transit/TransitWorkspace.tsx @@ -30,6 +30,7 @@ function TransitView({ apiUrl, actorId }: { apiUrl: string; actorId: string }) { const tab = TABS.find((value) => value === raw) ?? "sources"; const restored = useRef(false); const [proposalId, setProposalId] = useState(); + const [proposalFinished, setProposalFinished] = useState(false); useEffect(() => { if (!actorId || restored.current) return; restored.current = true; @@ -39,6 +40,12 @@ function TransitView({ apiUrl, actorId }: { apiUrl: string; actorId: string }) { update({ tab: "credentials" }, "replace"); } }, [apiUrl, actorId, update]); + useEffect(() => { + if (tab !== "credentials" && proposalFinished) { + setProposalId(undefined); + setProposalFinished(false); + } + }, [tab, proposalFinished]); return ( )} {tab === "catalog" && } - {tab === "credentials" && } + {tab === "credentials" && ( + setProposalFinished(true)} + /> + )} {tab === "pipeline" && } {tab === "mobility" && } diff --git a/apps/web/src/lib/admin/adminNavigation.test.ts b/apps/web/src/lib/admin/adminNavigation.test.ts index 7735d144b..d818a023b 100644 --- a/apps/web/src/lib/admin/adminNavigation.test.ts +++ b/apps/web/src/lib/admin/adminNavigation.test.ts @@ -34,6 +34,11 @@ it("keeps hosted System tools and restricts privacy administrators to casework", .filter((entry) => entry.workspace === "system") .map((entry) => entry.id), ).toEqual(["cache", "configuration-transfer"]); + expect( + adminDestinations({ role: "admin", selfHosted: false }) + .filter((entry) => entry.workspace === "data") + .map((entry) => entry.id), + ).toEqual(["data", "transit", "coverage"]); expect( adminPrimaryNavigation({ role: "privacy_admin", selfHosted: null }).map((entry) => entry.href), ).toEqual(["/admin/privacy"]); diff --git a/apps/web/src/lib/admin/adminNavigation.ts b/apps/web/src/lib/admin/adminNavigation.ts index 32b9da4d7..9fa900025 100644 --- a/apps/web/src/lib/admin/adminNavigation.ts +++ b/apps/web/src/lib/admin/adminNavigation.ts @@ -57,6 +57,7 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "Extracts, builds and OpenStreetMap acquisition", "/admin/data/osm", "data", + true, ), destination( "overture", @@ -64,6 +65,7 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "Places release, conflation and recovery", "/admin/data/overture", "data", + true, ), destination( "search", @@ -71,6 +73,7 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "Search index, ambient and notable places", "/admin/data/search", "data", + true, ), destination( "transit", @@ -92,6 +95,7 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "Review, stage and transfer data artifacts", "/admin/data/transfers", "data", + true, ), destination( "operations", @@ -99,6 +103,7 @@ export const ADMIN_DESTINATIONS: readonly AdminDestination[] = [ "Full updates, hardlink sync and destructive cleanup", "/admin/data/operations", "data", + true, ), destination("users", "Users", "Accounts, sessions and recovery", "/admin/users", "users"), { diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index c324b6144..8596bc807 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -7,7 +7,7 @@ sidebar_position: 7 # Backup & restore Use `pnpm openmapx backup` from the deployment checkout, or -**Services → Backups** with a full administrator account. You need the generated +**System → Backups** with a full administrator account. You need the generated Compose configuration, Docker access on the host or through the operations agent, and enough disk for the selected volumes. @@ -257,7 +257,7 @@ pnpm openmapx backup create --name pre-upgrade-20261023 pnpm openmapx backup list ``` -The Maintenance update dialog offers a database backup by default and permits +The update dialog in **System → Updates** offers a database backup by default and permits opting out. Restoring data after an upgrade also requires a compatible runtime and schema; merely switching image tags does not reverse migrations. Follow [Upgrading](../install/upgrading.md) and test the recovery sequence before a diff --git a/docs/docs/administration/community-extensions.md b/docs/docs/administration/community-extensions.md index db3ca61f4..80e6ca152 100644 --- a/docs/docs/administration/community-extensions.md +++ b/docs/docs/administration/community-extensions.md @@ -392,7 +392,7 @@ installation does not prove that their database and feed credentials are valid. ### Configuring the OpenConditions services -1. Open **Services → openconditions-ingest** and +1. Open **System → Services → openconditions-ingest** and **openconditions-contributions-api**. Read each installed manifest's fields and setup guides. 2. Set secrets on **Credentials**. `OPENMAPX_SECRETS_KEY` is required for the API diff --git a/docs/docs/administration/integrations-administration.md b/docs/docs/administration/integrations-administration.md index 0ee26af26..95ec5c0b1 100644 --- a/docs/docs/administration/integrations-administration.md +++ b/docs/docs/administration/integrations-administration.md @@ -209,7 +209,7 @@ OpenMapX lets an operator gate the two restrictive classes: are merely undocumented. Both are allowed by default. Review the terms for your deployment and disallow either class from -`/admin/settings`, or with the `OPENMAPX_ALLOW_NONCOMMERCIAL` / +`/admin/settings/data-use`, or with the `OPENMAPX_ALLOW_NONCOMMERCIAL` / `OPENMAPX_ALLOW_GREY_AREA` environment variables (environment takes precedence). The policy cache refreshes at a 30-second interval and is invalidated by relevant settings changes. Orchestrators use the excluded provider/source sets, and the response filter removes tagged excluded diff --git a/docs/docs/administration/platform-configuration.md b/docs/docs/administration/platform-configuration.md index 4e5ff1870..15c87852c 100644 --- a/docs/docs/administration/platform-configuration.md +++ b/docs/docs/administration/platform-configuration.md @@ -6,10 +6,12 @@ sidebar_position: 3 # Platform configuration -Open **Admin → Settings → Platform configuration**. The form shows effective values -and their current source. Host-owned fields are locked. Choose **Manage … in UI** +Open **Admin → System → Deployment** for domain, TLS contact and default region, +**System → Updates** for the release selector, or **System → Backups** for retention. +Each topic shows its effective values and their current source. Host-owned fields are locked. Choose **Manage … in UI** to adopt a supported field, or **Return … to host** to use the current host value -again. Editing and previewing do not save or restart anything. +again. Editing and previewing do not save or restart anything. These host topics are +unavailable on hosted deployments. The hosted Data workspace offers Transit and Coverage. | Field | Host default | When an applied change takes effect | | ------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------- | @@ -36,14 +38,14 @@ Confirm the listed acknowledgments and verify a fresh supported password login with the account's effective second factor. A passkey-only or OAuth session cannot authorize this change. Establish and prove the supported alternate login first. The sign-in confirmation is bound to the exact preview and cannot be reused for -another domain or policy. Return to Settings after sign-in to complete the review. +another domain or policy. Return to **System → Deployment** after sign-in to complete the domain review. After applying, reconnect at the reviewed admin address. Browser disconnection is an observation failure, not cancellation or evidence that the change failed. ## Operation status and recovery -Accepted changes are durable jobs. Settings reconnects to retained platform jobs; +Accepted changes are durable jobs. The owning System topic reconnects to retained platform jobs; Activity shows the original job, outcome and logs. The application coordinates the configuration generation, Data Manager guard, protected service helper and image verification before reporting completion. The agent is replaced last during a @@ -66,7 +68,7 @@ mount paths, executable options or Docker socket access. ## Clear a selected release pin -In System maintenance, **Selected pin** is separate from the running release. +In **System → Updates**, **Selected pin** is separate from the running release. **Clear selected release pin** requires the exact `CLEAR RELEASE PIN` confirmation and queues an Activity job bound to that pin. A changed pin is refused. Clearing leaves images, running containers, release evidence and the configured channel diff --git a/docs/docs/administration/services-administration.md b/docs/docs/administration/services-administration.md index f339fbea0..54828cc4e 100644 --- a/docs/docs/administration/services-administration.md +++ b/docs/docs/administration/services-administration.md @@ -214,7 +214,7 @@ of the container's executable code. Use **Apply changes** after changing configuration, manifests or environment. A restart does not recreate the container. Administrative host operations have -safety restrictions; use **Maintenance** for the coordinated core-release update +safety restrictions; use **System → Updates** for the coordinated core-release update rather than trying to update the running API as an ordinary service job. ## Compose preview @@ -233,7 +233,7 @@ A stale preview is refused; refresh before confirming another generation. **Compose Up** prepares the full selected generation and starts only that explicit closure. It preserves existing Data Manager and operations-agent containers with `--no-recreate`, then applies ordinary services with `--no-deps`. Apply or update -Data Manager from its service page. Use Settings or System Maintenance to replace +Data Manager from its service page. Use **System → Deployment** or **System → Updates** to replace the agent through the guarded helper. Up does not silently replace either protected container or start services mentioned only by a release overlay. @@ -266,7 +266,7 @@ no platform credentials or arbitrary environment keys. ## Data inventory -Open `/admin/data` to inspect OSM downloads, engine build directories, +Open `/admin/data/osm` to inspect OSM downloads, engine build directories, transit source state and specialized publication workflows. Follow the linked jobs for failures and progress. Operation forms, previews and risk levels come from the API's operation catalog. @@ -517,3 +517,5 @@ 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. + +Build All reviews the exact enabled, buildable targets captured when the dialog opens. It preserves the operations agent's canonical build order and submits those explicit IDs, so an inventory refresh cannot add newly enabled services. The audit records those IDs rather than a late-bound “all” target. diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index bd5be22a1..cc6af33b5 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -46,7 +46,7 @@ sources and credits. A late reply is ignored, and a remount rediscovers. 2. Optionally complete Overture Places ingestion and conflation for the installed release. An absent or empty, uninitialized Overture source permits OSM-only publication; populated, unfinished, mismatched or stale sources fail closed. -3. In **Admin → Services → Data workflows → Nearby places**, select **Planet**, **Germany** +3. In **Admin → Data → Search & places → Nearby places**, select **Planet**, **Germany** or **Custom region**. Germany requires the ready OSM snapshot for `europe/germany` and, when Overture is initialized, the completed `europe/germany` Overture snapshot. A ready state for a smaller region is @@ -212,7 +212,7 @@ snapshots need rebuilding; merely widening region bounds does not establish worldwide coverage. Absent/empty uninitialized Overture allows explicit OSM-only fallback. A populated invalid Overture snapshot fails closed. -In **Admin → Services → Data workflows → Nearby places**, select **Planet**, +In **Admin → Data → Search & places → Nearby places**, select **Planet**, then **Publish planet snapshot**. The authenticated build body is: ```json diff --git a/docs/docs/features/overture-places.md b/docs/docs/features/overture-places.md index 065ba01d4..2df12cb8f 100644 --- a/docs/docs/features/overture-places.md +++ b/docs/docs/features/overture-places.md @@ -70,8 +70,7 @@ Places snapshot. Link rebuilding is a separate resumable workflow; an incomplete rebuild does not establish readiness for nearby-place publication. Failed work preserves the prior published data at the relevant boundary. -The admin panel offers these operations under **Services → Data workflows → -Overture Places**. **Full sync** runs the complete refresh. **Resume links** +The admin panel offers these operations under **Data → Overture**. **Full sync** runs the complete refresh. **Resume links** continues the installed release's durable rebuild; **Restart links from scratch** discards its resumable workspace while preserving published Places. Both show a server preview before confirmation. **Advanced recovery** exposes download, diff --git a/docs/docs/guides/dawarich.md b/docs/docs/guides/dawarich.md index 6cf8bdbe6..90fd4e492 100644 --- a/docs/docs/guides/dawarich.md +++ b/docs/docs/guides/dawarich.md @@ -87,7 +87,7 @@ and route the Dawarich hostname without a path prefix. Provision the bundle before attempting to start it: -1. Open **Admin → Services** and the **Dawarich Timeline** detail. +1. Open **Admin → System → Services** and the **Dawarich Timeline** detail. 2. In **Managed Dawarich setup**, enter a public hostname if the default is not suitable, then choose **Provision/reconcile**. 3. Resolve any reported conflict before continuing. Provisioning creates or diff --git a/docs/docs/guides/supporting-infrastructure.md b/docs/docs/guides/supporting-infrastructure.md index d64e40334..067ee70ba 100644 --- a/docs/docs/guides/supporting-infrastructure.md +++ b/docs/docs/guides/supporting-infrastructure.md @@ -205,7 +205,7 @@ until it is recreated. Render keeps writing the routes there while any container still mounts that directory, and render, check and **Run diagnostics** say so. Recreate Traefik when a few seconds without the proxy suit you: `pnpm openmapx services update traefik`, or **Recreate** on -**Admin → Services → Traefik**. +**Admin → System → Services → Traefik**. | Public path | Target | | ------------------- | ---------------------------- | diff --git a/docs/docs/install/configuration.md b/docs/docs/install/configuration.md index 228e3ac68..261e81ce2 100644 --- a/docs/docs/install/configuration.md +++ b/docs/docs/install/configuration.md @@ -276,7 +276,7 @@ is configurable in Admin Settings → Map; `BASEMAP_PROVIDER` overrides admin. S | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | | `NEXT_PUBLIC_STYLE_PROVIDER` | Selects the style. `openmapx` (default) uses the bundled light/dark styles. `maptiler` loads a complete hosted style only when MapTiler is the resolved vector provider. Self-hosted and OpenFreeMap vectors always use the owned styles. | Default `openmapx` | | `BASEMAP_PROVIDER` | Hosted vector preference: `auto` (MapTiler with a configured key, OpenFreeMap without one), `openfreemap`, or `maptiler`. Valid env > admin > auto; invalid env does not lock admin editing. Self-hosted tiles always win. Reload after admin changes. | Optional. Commented | -| `MAPTILER_KEY` | MapTiler Cloud API key for the hosted style/tile fallback and MapTiler layers. Also settable in `/admin/settings → Map`. Leave commented to manage from the admin UI; uncomment to lock a value (env wins). | Optional. Commented | +| `MAPTILER_KEY` | MapTiler Cloud API key for the hosted style/tile fallback and MapTiler layers. Also settable in `/admin/settings/map`. Leave commented to manage from the admin UI; uncomment to lock a value (env wins). | Optional. Commented | | `NEXT_PUBLIC_MAP_STYLE_URL` | Glyph server base URL; the owned style appends `/fonts/{fontstack}/{range}.pbf`. Built-in TileServer GL supplies this automatically. Does not select the vector provider. | Optional. Commented | | `NEXT_PUBLIC_TILES_URL` | Self-hosted TileJSON URL used as the vector source by the OpenMapX style. Always wins over hosted selection. If omitted, enabled TileServer GL is discovered on the web origin (not the API origin), otherwise the selected hosted source is used. | Optional. Commented | | `NEXT_PUBLIC_TRAFFIC_TILE_URL_TEMPLATE` | Override URL template for the traffic raster layer. | Optional. Commented | @@ -442,7 +442,7 @@ Keys and overrides for the `app-api` traffic and tile proxies. Used for email verification, 2FA, and email-change flows. The provider is auto-detected by priority: **EmailLabs > Lettermint > SMTP**. These are also -settable in `/admin/settings → Email / SMTP`; env always wins. +settable in `/admin/settings/email`; env always wins. | Variable | Description | Required / Default | | ------------------------ | -------------------------------------------------------------------------------- | --------------------------- | @@ -561,7 +561,7 @@ The legal fields are optional but required for complete production Each data source declares whether it may be used commercially. By default the default permits all source classes. Commercial operators should review and exclude sources whose terms do not permit their use. These are resolved -env-first, then the admin toggle at `/admin/settings`, then the default, and have +env-first, then the admin toggle at `/admin/settings/data-use`, then the default, and have no entry in `.env.example`; set them only if you need to restrict. See [Data-use policy](../administration/integrations-administration.md#data-use-policy) for what each class covers. @@ -608,7 +608,7 @@ are no place to go to (events, languages, historical states, sports clubs, craters on the Moon) are left out. It builds the index once when none is published and refreshes it on a schedule; a failed refresh keeps the previous snapshot, and a busy endpoint is retried for a few minutes first. -**Admin → Data workflows → Notable places** shows the snapshot and rebuilds it +**Admin → Data → Search & places → Notable places** shows the snapshot and rebuilds it on demand. | Variable | Description | Required / Default | @@ -668,7 +668,7 @@ environment override only when you want to pin it at deployment level: - `/admin/integrations/bulk`: every integration on one page, plus a generated catalogue (Copy config / Copy credentials / Copy all) you can paste into `.env`. -- `/admin/services/`: per-service config, exposed as +- `/admin/system/services/`: per-service config, exposed as `SERVICE__` (for example `SERVICE_VALHALLA_BUILD_ELEVATION=false`). Nothing in these override blocks is required at deploy time. Paste only the @@ -692,8 +692,8 @@ The default remains `main` before the first named preview exists. For deliberate ### Generating configuration from the admin panel Saving settings and generating a configuration are separate from replacing -containers. The Compose page's **Generate configuration** checks the preview +containers. **Generate configuration** in **System → Deployment → Compose and host recovery** checks the preview revision, commits the current desired settings and prepares hardlinks without -starting services. Use the guarded service Apply/Update or protected Settings +starting services. Use the guarded service Apply/Update or protected **System → Deployment** or **System → Updates** workflow when container replacement is required. Host environment overrides retain their ownership and cannot be overwritten through this generation action. diff --git a/docs/docs/install/managing-services.md b/docs/docs/install/managing-services.md index fd04c9f81..7dd9351e8 100644 --- a/docs/docs/install/managing-services.md +++ b/docs/docs/install/managing-services.md @@ -209,10 +209,10 @@ start/render returns to the persisted selection. Prefer `services enable` for lasting changes. Presets on lifecycle commands target already-selected services; they do not enable them. -The admin panel's Compose page also offers revision-checked **Generate +**System → Deployment → Compose and host recovery** also offers revision-checked **Generate configuration** without container lifecycle effects. Nonempty hardlink plans need an already-running Data Manager. **Compose Up** preserves Data Manager and the -executing agent; use their guarded Apply/Update or Settings/Maintenance workflows +executing agent; use their guarded Apply/Update or **System → Deployment** or **System → Updates** workflows to replace them. Full shutdown uses the host command below because it removes the web control plane: @@ -633,7 +633,7 @@ for setup, tuning, update behavior, fallback limits, and attribution. ## Move prepared data between instances -The admin panel's **Data Workflows → Prepared data** section uploads, validates, +The admin panel's **Data → Prepared transfers** section uploads, validates, previews and imports prepared tileserver, OSRM, OTP, Pelias and MOTIS bundles. It also exports verified prepared data into an authenticated, temporary download. The CLI's `data export` and `data import` commands use the same archive diff --git a/docs/docs/install/preparing-data.md b/docs/docs/install/preparing-data.md index 079b6e395..f556a0109 100644 --- a/docs/docs/install/preparing-data.md +++ b/docs/docs/install/preparing-data.md @@ -59,7 +59,7 @@ the correct region. Inspect the generated hardlink plan when several inputs exis The extract's own header says when its data is current to, which can be well before the download. Data-manager records it, and the admin panel's -**Data workflows** page shows it with its age on the OSM card, flagged once it +**Data → OSM** page shows it with its age on the OSM card, flagged once it is more than 30 days old. The card also shows the diff feed the extract names, which is the feed to give Nominatim (below). @@ -243,7 +243,7 @@ covers checkpoints, capacity, and recovery. ## Inspecting prepared data -**Services → Data workflows** lists every local OSM extract with pagination, +**Data → OSM** lists every local OSM extract with pagination, its recorded region, size and source fingerprint. Downloads record SHA-256 evidence during their checksum pass. Builds and imports record their output hashes and local file identities; refreshing this page does not hash datasets. diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md index d8544ceb5..3693e800b 100644 --- a/docs/docs/install/upgrading.md +++ b/docs/docs/install/upgrading.md @@ -521,7 +521,7 @@ Recreate Traefik when a few seconds without the proxy suit you: pnpm openmapx services update traefik ``` -or **Recreate** on **Admin → Services → Traefik**. OpenMapX does not recreate +or **Recreate** on **Admin → System → Services → Traefik**. OpenMapX does not recreate it for you. Once no container mounts the checkout's directory, render stops writing there and leaves the last `generated-routes.yml` in place for a rollback. See [where Traefik reads its configuration](../guides/supporting-infrastructure.md#where-traefik-reads-its-configuration). diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 5f2b42047..13e8371a9 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -5071,7 +5071,9 @@ "targets": "Diese {count} erfassten Ziele bauen: {ids}. Der Server prüft die Build-Voraussetzungen jedes Dienstes. Später aktivierte Dienste werden diesem Auftrag nicht hinzugefügt.", "cancel": "Abbrechen", "confirm": "Geprüfte Ziele bauen" - } + }, + "extractCount": "{count, plural, one {# Extrakt} other {# Extrakte}}", + "openToInspect": "Zur Prüfung öffnen" }, "adminDrafts": { "title": "Nicht gespeicherte Verwaltungsänderungen verwerfen?", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index df60c8ad4..7884ba87e 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -5071,7 +5071,9 @@ "targets": "Build these {count} captured targets: {ids}. The server enforces each service’s build prerequisites. Newly enabled services will not be added to this request.", "cancel": "Cancel", "confirm": "Build reviewed targets" - } + }, + "extractCount": "{count, plural, one {# extract} other {# extracts}}", + "openToInspect": "Open to inspect" }, "adminDrafts": { "title": "Discard unsaved administration changes?", From cfb7a2a978a77836192cef7537be95908f882f5e Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 6 Nov 2026 10:35:38 +0100 Subject: [PATCH 3/3] ci: serialize filesystem-heavy Linux deployment tests --- .github/workflows/ci.yml | 3 +- docs/docs/developer/ci-performance.md | 9 ++++ scripts/linux-user-ci-contract.test.ts | 71 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 scripts/linux-user-ci-contract.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32f3e6bb7..aa01438f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,8 +266,9 @@ jobs: - run: umask 002 && corepack pnpm install --frozen-lockfile - name: Run the agent, core and data-manager suites and the deployment-user test + # One file worker reduces I/O contention between filesystem-heavy lease fixtures. run: >- - umask 002 && corepack pnpm exec vitest run --project node + umask 002 && corepack pnpm exec vitest run --project node --maxWorkers=1 apps/ops-agent packages/core services/data-manager scripts/ops-agent-deployment-user.test.ts diff --git a/docs/docs/developer/ci-performance.md b/docs/docs/developer/ci-performance.md index 94a074e7d..f24fe1d65 100644 --- a/docs/docs/developer/ci-performance.md +++ b/docs/docs/developer/ci-performance.md @@ -230,6 +230,15 @@ these private deployment-image changes do not need empty package notes. Source, package manifests and nested Dockerfile fixtures still participate in the gate. The wider package/release baseline is handled separately. +## Linux deployment-user validation + +The Linux gate runs the complete agent, core, data-manager and deployment-user +selection with one Vitest worker. Test files run one at a time to reduce contention +between filesystem-heavy fixtures. Concurrency inside each case, test and hook +timeouts, and runtime deadlines stay unchanged. Failures are not retried. The gate +retains its pinned Node container, uid 1001 and umask `002`; the required CI +aggregate still requires it to succeed. + ## Database groups Database validation runs in five jobs on separate GitHub-hosted runners: diff --git a/scripts/linux-user-ci-contract.test.ts b/scripts/linux-user-ci-contract.test.ts new file mode 100644 index 000000000..4e47f412f --- /dev/null +++ b/scripts/linux-user-ci-contract.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +type Step = { + name?: string; + run?: string; + if?: string; + "continue-on-error"?: boolean; +}; + +const workflow = parse( + readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"), +); +const job = workflow.jobs["test-linux-user"]; +const steps: Step[] = job.steps; + +describe("required Linux deployment-user gate", () => { + it("runs the complete suite selection with one file worker", () => { + const step = steps.find( + (value) => + value.name === "Run the agent, core and data-manager suites and the deployment-user test", + ); + expect(step?.run?.trim().split(/\s+/)).toEqual([ + "umask", + "002", + "&&", + "corepack", + "pnpm", + "exec", + "vitest", + "run", + "--project", + "node", + "--maxWorkers=1", + "apps/ops-agent", + "packages/core", + "services/data-manager", + "scripts/ops-agent-deployment-user.test.ts", + ]); + }); + + it("keeps the pinned runtime, deployment identity, umask and failure handling", () => { + const version = readFileSync(new URL("../.nvmrc", import.meta.url), "utf8").trim(); + const [image, digest] = job.container.image.split("@"); + expect(image).toBe(`node:${version}-bookworm`); + expect(digest).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(job.container.options).toBe("--user 1001:1001 --init"); + expect(job.env).toEqual({ + HOME: "/tmp/home", + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + OPENMAPX_TEST_DEPLOYMENT_USER: "1001", + }); + expect(job.defaults.run.shell).toBe("bash"); + expect(job["timeout-minutes"]).toBe(30); + const confirmation = steps.find((step) => step.name === "Confirm the user and umask"); + expect(confirmation?.run).toContain("umask 002"); + expect(confirmation?.run).toContain('test "$(id -u)" = 1001 && test "$(umask)" = 0002'); + expect(job["continue-on-error"]).toBeUndefined(); + for (const step of steps) expect(step["continue-on-error"]).toBeUndefined(); + }); + + it("requires the Linux job to succeed before the aggregate can pass", () => { + const aggregate = workflow.jobs.ci; + expect(aggregate.needs).toContain("test-linux-user"); + const failure = (aggregate.steps as Step[]).find( + (step) => step.name === "Fail if any gate job did not succeed", + ); + expect(failure?.if).toContain("needs.test-linux-user.result != 'success'"); + }); +});