feat(admin): /admin lands on a real dashboard instead of redirecting to /admin/users #324
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
/admincurrently redirects to/admin/users. The left-rail item reads ADMIN DASHBOARD, but no dashboard exists — the label promises something the page doesn't deliver. Admins have to click around three different sub-routes (Users, System, OCR) to get a sense of system health.A real dashboard aggregates the most-asked questions into one view:
Non-goals
Proposed layout
Panels are links/deep-links to the actual sub-routes; the dashboard is a summary, not a replacement.
Implementation plan
Backend
AdminDashboardController.getDashboard()→GET /api/admin/dashboard→AdminDashboardDTO.AdminDashboardDTO:DocumentService,MassImportService,UserService, OCR client, MinIO admin API).@RequirePermission(Permission.ADMIN).Frontend
frontend/src/routes/admin/+page.server.tsredirect with a loader that calls/api/admin/dashboard.frontend/src/routes/admin/+page.svelte:frontend/src/lib/components/admin/.i18n
15–20 new Paraglide keys covering all panel titles, labels, and empty states.
Tests
GET /api/admin/dashboardwith admin → 200 + correct shape. Without admin → 403./admin, assert no redirect; confirm all 5 panel headers visible./admin— expect redirect to login or access-denied (whatever the current global pattern is).Verification
Manual: log in as admin,
/adminshows dashboard. Click each quick action; lands on the right page. Disable OCR (stop the container) — dashboard shows "Offline".Acceptance criteria
/adminno longer redirects to/admin/users/api/admin/dashboardis permission-gated toADMINCritical files
Related
📋 Elicit — Requirements Engineer
Requirements discussion, 2026-04-26. Nine open items worked through; all resolved.
Resolved items
1. Pending invites data source
InviteToken.isActive()(not revoked, not expired, not exhausted) is the correct filter.InviteService.listInvites(true, ...).size()gives the count directly. System fully exists.2. Quick Actions panel → replaced by OCR panel
Mass import and thumbnail generation are too infrequent (≤ once/year) to warrant dashboard real estate. Quick Actions panel is dropped entirely. The OCR section becomes its own panel showing:
blockCountof last completed run per type)3. OCR panel resilience
If the OCR service is unreachable, only the OCR panel shows a degraded/offline state. The rest of the dashboard (Activity, Invites) loads normally. The backend must catch OCR connectivity failures and return
ocr.online = falsewith null training fields rather than propagating a 500. All OCR training fields in the DTO must be nullable.4. Storage panel — removed from this issue
The backend currently has no MinIO admin API integration and the
Documententity stores no file size. Adding storage stats requires new integration work, which contradicts the "no new data pipelines" non-goal. Storage panel removed from v1 and tracked as a separate follow-on in #334.5. Auto-refresh
Not needed in v1. Manual page reload is sufficient.
6. Non-admin access pattern
The pattern is already defined and consistent: authenticated non-admin users hit
error(403)in+layout.server.ts— they see the 403 error page, not a redirect. The E2E acceptance criterion should read: "authenticated non-admin visits/admin→ 403 error page" (not the vague "redirect to login or access-denied").7. OCR error rate
Dropped from the dashboard (resolved as part of item 2). Detail page only.
8. Training block count definition
"Blocks not yet used for training" = total eligible blocks (per type) −
blockCountof the last completedOcrTrainingRunfor that type, split byKURRENT_RECOGNITIONandKURRENT_SEGMENTATION.9. Activity panel — redesigned
recentUploads,recentImports, andrecentUsersas lists are dropped. The panel becomes four weekly counts (last 7 days), sourced entirely from the existing audit log:FILE_UPLOADEDCOMMENT_ADDEDTEXT_SAVEDANNOTATION_CREATEDNo lists, no links to individual items — counts only.
Implementer flag (not a blocker)
OcrTrainingRunhas no explicit training type column. Type is currently inferred frommodelName(german_kurrent→ Kurrent recognition,blla→ segmentation). This works today but is fragile if model names change. Consider adding atrainingTypeenum column via migration to make the split explicit and query-safe.Updated DTO shape (replacing the original proposal)
Storage section removed (tracked in #334).
🏗️ Markus Keller — Senior Application Architect
Observations
Package placement conflict. The issue puts
AdminDashboardControllerincontroller/— consistent withAdminController.java, which also lives there at/api/admin/**. This is correct. The existingdashboard/package is for the user-facing dashboard (DashboardController,DashboardService,ActivityFeedItemDTO). Keep admin stats separate from user-facing pulse/resume logic. Don't mix the two.OcrTrainingRunhas notrainingTypecolumn. The OCR panel requires splitting the last-run query by type (kurrent recognition vs. segmentation). Currently, type is inferred frommodelNamestring matching.OcrTrainingRunRepositoryhas no query for "last completed run per type" — whatever is added here must pattern-match on model name strings likegerman_kurrentandblla. This is fragile: if the model name changes in the Python service, the dashboard silently breaks with no type error. Elicit marked this as a non-blocking implementer flag. I'd push back on that: adding atrainingTypeenum column is a 2-line migration + one column addition. Doing it in this issue means one deploy. Doing it as a follow-on means two deploys and a window where the dashboard infers the wrong type silently.AuditLogQueryRepository.getPulseStats()already does most of the work. The existing native query (lines 114–129) countsFILE_UPLOADED,ANNOTATION_CREATED,TEXT_SAVEDper week. The admin dashboard Activity panel needs the same three plusCOMMENT_ADDED, without the per-useryourPagesmetric. AddgetSystemActivityCounts(@Param("weekStart") OffsetDateTime weekStart)toAuditLogQueryRepository— adapt the existing SQL rather than writing from scratch.Invite count overhead.
InviteService.listInvites(true, appBaseUrl)fetches fullInviteListItemDTOobjects with shareable URLs and formatted display codes — just to count them. For a dashboard summary, add acountActive()native query toInviteTokenRepository:This is a scalar count, not a full entity fetch. At current volume it's negligible, but the pattern is the right one.
OCR connectivity resilience must be in the service layer.
AdminDashboardServicecalls the OCR health endpoint. If the OCR service is unreachable, the exception must be caught in the service — not the controller. ReturnAdminDashboardDTOwithocr.online = falseand null training fields. A 500 from a side panel health check is not acceptable.The
+layout.server.tshas a related TODO. It loads ALL users, ALL groups, ALL tags to get counts — there's a comment flagging this as wasteful. This issue doesn't need to fix that, but the new/api/admin/dashboardendpoint should not replicate the same pattern. Return counts only, not lists.Recommendations
AdminDashboardControllerincontroller/— consistent withAdminController. Keepdashboard/for user-facing data.trainingTypeenum column + Flyway migration in this issue, not as a follow-on. The dashboard query against a typed column is onefindFirstByTrainingTypeAndStatus(type, COMPLETED)JPA call; the modelName-based version requires a native query with string matching.AuditLogQueryRepository.getSystemActivityCounts(weekStart)— adapted fromgetPulseStats(), addsCOMMENT_ADDED, removes theyourPagesper-user metric.InviteTokenRepository.countActive()— native count query, no DTO allocation.AdminDashboardService, return degraded state, never propagate the exception upward.Open Decisions
trainingTypemigration: this issue or follow-on? Elicit called it non-blocking. I think it belongs here — the dashboard result is incorrect without it. Options: (A) Add migration in this PR (recommended), (B) Deploy dashboard with modelName string inference and add migration in a follow-on. What each costs: A = one deploy, correct from day one; B = two deploys, silent failure risk if model names drift.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The critical files list in the issue body is outdated. It still includes
QuickActionsPanel.svelteandStoragePanel.svelte— both dropped by Elicit. The panel count is now 3 (Activity, Invites, OCR), not 5. Start from the Elicit comment's revised design, not the original issue body, when building the component tree.+page.sveltecurrently usesonMount+goto()for desktop redirect. This means: browser loads the page → renders mobile entity picker → JS runs → navigates to/admin/users. The flash is visible on desktop. The server-side+page.server.tsloader eliminates this entirely — the data is available before first paint. TheonMount+gotoblock must be deleted.AuditLogQueryRepository.getPulseStats()already has the right shape. Lines 114–129: it countsFILE_UPLOADED,ANNOTATION_CREATED,TEXT_SAVEDgrouped toweekStart. The admin version needs:COMMENT_ADDEDto the queryyourPagesper-user columnuserIdparameterAdd a new method
getSystemActivityCounts(OffsetDateTime weekStart)returning a new projection interfaceSystemActivityRowwith four fields:uploaded,comments,transcribed,annotated.OcrTrainingRunRepositoryhas no query for "last completed run per type." The existing methods query bypersonId,status, or top-20 recent. For the dashboard, two new methods are needed:called once with
"german_kurrent"and once with"blla". This is exactly the fragile modelName inference the Elicit comment flagged. If the decision is to add atrainingTypecolumn, usefindFirstByTrainingTypeAndStatusOrderByCompletedAtDesc(TrainingType type, TrainingStatus status)instead — one call per type, no string matching.OCR panel needs null-safe
$derivedblocks. The DTO guaranteeskurrentandsegmentationare nullable whenocr.online = false. The component must guard this:If the component accesses
data.dashboard.ocr.kurrent.lastRunAtwithout a null guard, it throws at runtime when OCR is offline.The existing
page.svelte.spec.tscurrently tests the mobile picker and desktop redirect behavior. This file must be rewritten — not just updated. Delete theonMount/gototests and replace with panel rendering tests.Recommendations
QuickActionsPanel.svelte,StoragePanel.svelte; renameSystemHealthPanel.svelte→OcrPanel.svelte.getSystemActivityCounts(OffsetDateTime weekStart)toAuditLogQueryRepository— adapt existing SQL, 5-minute task.onMount+gotoblock from+page.svelteentirely once the loader is in place. No partial: the redirect and the mobile picker are both replaced by the dashboard.$derivedwith null guards for the OCR sub-sections — do not access nested fields without first checkingocr.online.+page.svelteor any backend class — the existingpage.svelte.spec.tsis the red starting point.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
Permission gate is correct, but must be class-level. The issue specifies
@RequirePermission(Permission.ADMIN). The existingAdminControllerapplies this at the class level — every method is protected by default. The newAdminDashboardControllermust do the same. A method-level annotation on onlygetDashboard()would leave any future method on the same class unprotected by default.Data sensitivity is low — aggregated counts only. The dashboard response contains: weekly activity counts, pending invite count, OCR service status + training metrics. No individual user records, no document content, no PII in the response shape. The
ADMINpermission gate is the right and sufficient mitigation.OCR health check timeout is an availability risk.
AdminDashboardServicewill call the Python OCR service to determineonlinestatus. If the OCR service is hung (not unreachable, but slow — responding after 30 seconds), every admin page load blocks a Spring thread for 30 seconds waiting for the OCR ping. At low concurrency this is invisible; under load it becomes thread exhaustion. The fix: configure a short read timeout (2–3 seconds) specifically for the dashboard health check, separate from the global OCR client timeout used for full OCR jobs.Audit log query returns counts, not rows. The
getSystemActivityCounts()query returns four integer counts. No risk of over-fetching event details or leaking document/user identifiers. Safe.Invite count query.
InviteTokenRepository.findActive()returns token objects including codes and shareable URLs. If the dashboard service useslistInvites()to count and discards the rest, those objects (containing invite codes) are allocated and then GC'd. This is a minor concern — not a security issue at this scale, just unnecessary. AcountActive()native query avoids it.Recommendations
@RequirePermission(Permission.ADMIN)at the class level onAdminDashboardController— not method-level. MatchAdminController's pattern exactly.AdminDashboardService. Use a separateRestClientinstance orRequestOptionswith a short timeout — do not share timeout config with the full OCR processing client.GET /api/admin/dashboardwith no auth → 401GET /api/admin/dashboardwith authenticated user missingADMINpermission → 403Both are distinct failure modes and must be tested separately — "Spring Security handles auth" is not coverage.
🧪 Sara Holt — Senior QA Engineer
Observations
Test plan structure is good, but missing critical cases. The issue lists: controller test (200+shape, 403), service tests per sub-aggregate, frontend page test, E2E. The pyramid is right. The gaps:
OCR service offline state — no test listed for
ocr.online = falsewith nullkurrent/segmentation. This is a required branch in both backend (service catches RestClientException → returns degraded DTO) and frontend (OcrPanel renders "Offline" without crashing). Without this test, the first time OCR goes down, the dashboard throws a 500 or the UI crashes.getSystemActivityCounts()integration test — this will be a native SQL query againstaudit_log. It must run against real PostgreSQL via Testcontainers with seeded rows. Mocking it at the service layer does not prove the SQL is correct. Verify each of the four counts independently with seeded fixtures.page.svelte.spec.tsrewrite — the existing test asserts the mobile picker and desktop redirect behavior. Both are being deleted. The spec must be rewritten before the old behavior is removed — otherwise there's a window where the old tests are green (redirect exists), then they're deleted, then nothing covers the new dashboard panels.E2E acceptance criteria need tightening. "Assert no redirect" verifies the
gotois gone — good. Also assert all 3 panel headings are visible in the same test. UsegetByRole('heading')to avoid coupling to CSS class names.Non-admin E2E test. The issue says "authenticated non-admin visits
/admin→ 403 error page" (Elicit resolved this correctly). Write this as a Playwright test — not just a@WebMvcTesttest. The 403 is enforced at the SvelteKit layout level (+layout.server.tsline 24), which is separate from the backend permission gate. Test both layers.Recommendations
Add three missing test cases before closing the issue:
AdminDashboardControllerTest:when_ocr_unreachable_returns_200_with_online_false— mock OCR client to throwRestClientException, assert response is 200 withocr.online = false.AuditLogQueryRepositoryTest(Testcontainers): seed 2FILE_UPLOADED, 3COMMENT_ADDED, 1TEXT_SAVED, 0ANNOTATION_CREATEDin the last 7 days — assert each count matches.OcrPanel.spec.ts(Vitest Browser): render withmakeDashboard({ ocr: { online: false, kurrent: null, segmentation: null } })— assert no runtime errors, offline badge visible.Factory function for dashboard DTO in frontend tests:
Pass
{ ocr: { online: false, kurrent: null, segmentation: null } }to cover the offline case in one line.Rewrite
page.svelte.spec.tsfirst — before touching+page.svelte. The spec rewrite IS the red phase for the frontend dashboard implementation.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
Mobile behavior is unspecified. The current
+page.svelteis a mobile entity picker: tapping Users, Groups, Invites, Tags, System navigates to each sub-route, andhistory.back()returns here. The issue says "replace redirect with a loader" but doesn't define what mobile admins see. On desktop the entity nav lives in the left rail (viaEntityNav.svelte); on mobile there is no persistent left rail. If the mobile entity picker is removed and replaced with dashboard panels, mobile admins lose their sub-route hub. This needs an explicit decision before any pixel is placed.3-panel layout works better than the original 5-panel grid. With Storage and QuickActions removed, the dashboard is leaner: Activity (4 counts), Invites (1 count), OCR (2 training types + online/offline). This fits a single-column layout on mobile and a 2-column or asymmetric grid on desktop without feeling sparse.
Activity panel: 4 stat cards, not a list. The Elicit design (4 weekly counts) is a stat card grid — not a list of recent items. On mobile: 2×2 grid. On desktop: 1×4 row. Use the pattern from
OcrStatCards.svelte(it already exists inadmin/ocr/) — the number styling and label hierarchy are established there. Reuse, don't reinvent.OCR offline state needs a visible status badge, never color alone. If
ocr.online = false, the panel must show a status badge with both a colored indicator AND the text "Offline". Color-blind users (8% of men) see no difference between a green dot and a red dot. The badge pattern:Zero state for Invites.
pendingCount: 0should display a message — not just "0". A small icon + "Keine ausstehenden Einladungen" communicates clearly. Showing "0" without context leaves admins wondering if the count failed to load.Loading state is missing from the issue. The
+page.server.tsloader is async. SvelteKit renders the page after the load completes, so there is no spinner needed in the normal SSR case — but if a Svelte streaming approach or client-side navigation to this page is in scope, skeleton cards should be considered. For the default SSR approach, the backend must respond quickly. The OCR health check timeout (flagged by Nora) directly affects time-to-first-paint.The
onMountredirect removal is a UX win. On desktop, the current behavior renders mobile content briefly before the client-sidegoto()fires. The new server-side loader means correct content on first paint, no flash. This improves both performance and perceived quality.Recommendations
EntityNavcomponent in the left rail on desktop, and via a "Zur Administration" header link or bottom-of-page links on mobile.OcrStatCards.sveltepattern for Activity counts — number infont-serif text-3xl font-bold text-ink, label infont-sans text-xs text-ink-3 uppercase tracking-widest.pendingCount === 0.bg-white shadow-sm border border-brand-sand rounded-sm p-6— consistent with every other admin panel in the project.Open Decisions
/adminon mobile show dashboard panels, the entity picker, or both? Options: (A) Dashboard panels only, sub-routes accessible via back button from each panel's detail link. (B) Tab switcher at the top: "Dashboard | Navigation". (C) Dashboard panels with a "Zur Administration ›" footer row linking to the entity picker. My preference: C — keeps the dashboard as the landing with easy escape to sub-routes, no new tab pattern needed.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
No new infrastructure required. The dashboard reads from existing services (PostgreSQL audit log, InviteTokenRepository, OcrTrainingRunRepository, existing OCR client). No new containers, no new volumes, no config changes. Clean.
OCR health check adds a new outbound call on the hot path. Every admin page load will now trigger a call to the Python OCR service to determine
onlinestatus. In normal operation this is fast. During OCR service restarts, upgrades, or GPU hangs, this call may stall. Tobias confirms: the OCR service has astart_period: 60shealth check in the Compose file for model loading. During that window,/healthon the OCR service may return unhealthy or not respond. The dashboard backend must have an explicit short timeout on this call (2–3 seconds) — not the same timeout as the full OCR job client. If not set, an admin hitting the dashboard during an OCR cold start gets a thread blocked for the full model-load window.No API type regen impact beyond what's expected. The new
AdminDashboardDTOis the only new shape in the OpenAPI spec. Onenpm run generate:apiafter the backend is running with--spring.profiles.active=devcovers it.The
+layout.server.tscurrently loads ALL users, groups, tags to get counts (with a TODO noting this). The dashboard endpoint doesn't fix this — it's a separate concern. But noting it: the three parallel calls in+layout.server.tsrun on every admin sub-route load, including the new dashboard. This is existing overhead, not introduced by this issue.No Flyway migration required unless the
trainingTypecolumn addition (flagged by Markus) is included. If it is: one new migration file, namedV{N}__add_training_type_to_ocr_training_runs.sql. The existing pattern isOcrTrainingRun.modelNameis a non-null string column — adding a nullabletraining_type VARCHAR(50)with a subsequentUPDATEto backfill is the standard approach.Recommendations
OcrTrainingServiceorOcrClientuses a global timeout of, say, 120 seconds (reasonable for a full OCR job), the dashboard health check must override this with a 2–3 second timeout. CheckRestClientOcrClientconfiguration.trainingTypemigration is included: name the migrationV{N}__add_training_type_to_ocr_training_runs.sql, add a nullable column, backfill existing rows frommodelName(CASE WHEN model_name LIKE '%german_kurrent%' THEN 'KURRENT_RECOGNITION' WHEN model_name LIKE '%blla%' THEN 'KURRENT_SEGMENTATION' ELSE NULL END), then make itNOT NULL DEFAULT 'UNKNOWN'or keep it nullable for safety. Verify the migration runs cleanly against a Testcontainers instance before merging.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Schema
trainingTypemigration: this issue or follow-on? The OCR panel requires splitting the last-run record by type (kurrent recognition vs. segmentation). CurrentlyOcrTrainingRunhas notrainingTypecolumn — the split would usemodelNamestring matching (LIKE '%german_kurrent%',LIKE '%blla%'). Elicit marked this non-blocking. Markus disagrees: if model names drift in the Python service, the dashboard silently shows wrong data with no compile-time error. Options: (A) Add atrainingTypeenum column + backfill migration in this PR — one deploy, correct from day one. (B) Ship the dashboard with modelName inference, file a follow-on — two deploys, silent-failure window. (Raised by: Markus, Felix)UX / Mobile
/adminshow on mobile? The current+page.svelteis a mobile entity picker (Users, Groups, Invites, Tags, System as tappable rows). On desktop it redirects to/admin/users. The issue replaces the desktop redirect with a dashboard loader — but is silent on mobile. Options: (A) Dashboard panels only on all screen sizes; sub-routes accessible via detail links within each panel. (B) Tab switcher at the top of the mobile view: "Dashboard | Navigation". (C) Dashboard panels at the top + "Zur Administration ›" footer row that expands or links to the existing entity picker rows. Leonie recommends C — preserves the sub-route hub without adding a new navigation pattern. (Raised by: Leonie, Markus)