Dashboard — Routes
The dashboard has seven routes. One is a bare redirect, two are public (login and the auth callback), and the rest are protected by an authenticated layout that checks the session before rendering anything.
Root (/)
The root path has no component. Its sole purpose is to redirect the user to /contents via a beforeLoad hook. Visiting / directly always produces the same result as visiting /contents.
Login (/login)
The login page is the only entry point for unauthenticated users. If a valid session already exists when the page loads, the router redirects immediately to /contents — there is no need to log in again.
The page renders an email input. The value is validated with a z.string().email() schema via TanStack Form, so a malformed address is caught before any network request is made. When a valid email is submitted, the dashboard calls authClient.signIn.magicLink with the email address and a callbackURL of /auth/callback. The API generates a signed verification link and (in the current development setup) prints it to stdout.
The form manages three UI states: idle while waiting for input, success after the API confirms the magic link has been issued, and error if the request fails. Each state is reflected by a Kumo Banner component below the input.
Auth Callback (/auth/callback)
This route runs entirely in the browser — SSR is explicitly disabled for it. It has no visual design beyond a "Completing sign in…" message displayed while work is in progress.
On mount, the page reads the verifyToken or token parameter from the URL query string or hash. It then calls authClient.magicLink.verify with that token. The API validates the token's signature, creates a session, and issues a session cookie. On success the dashboard navigates to /contents. On failure — whether the token is missing, expired, or invalid — the page redirects to /login.
Authenticated Layout (/_authenticated)
This is a layout route, not a page. Every protected route renders inside it.
On every navigation into any child route, the layout's beforeLoad hook calls authClient.getSession(). If the API returns no active session, the hook throws a redirect to /login. This happens before any child route code runs, so protected pages never need their own auth checks.
When a session is present, the layout renders a persistent Kumo Sidebar that wraps the child page via <Outlet>. The sidebar has three navigation links — Contents, Analytics, and Pending — with the active one highlighted by comparing each link's path against the current routerState.location.pathname.
The sidebar footer has two actions. Trigger Fetch sends a POST to /api/trigger, which emits a Kafka message that tells the producer to start a new AA API fetch run immediately, bypassing its internal cron schedule. A Kumo toast confirms success or reports an error. Sign Out calls authClient.signOut() and redirects to /login.
Contents List (/contents/)
SSR is disabled on this page. On mount it fires up to 10 parallel requests to GET /api/contents — 100 items per page, up to 1,000 items total. This upfront bulk load is intentional: because all filtering and pagination run client-side, having the full dataset in memory means no additional round trips are needed when the user changes a filter.
At the same time, the page opens a persistent SSE connection to GET /api/events. This stream bridges the processed_content_aa Kafka topic — whenever the consumer finishes classifying an article, an event arrives here. The page does not automatically refresh on receiving one; instead it sets a flag that causes a banner to appear, prompting the user to manually trigger a refresh. Clicking the banner calls queryClient.invalidateQueries(["contents"]), which re-fetches all pages and clears the flag.
The table has columns for title, source (an "AA" badge), the model-predicted category, the human-approved category, language, publish date, a mock flag, parsed status, and a per-row action menu. All filtering is client-side: title uses a substring match, while source, model category, approved category, and language use exact match. Date filtering uses a custom function that checks the publish date against an optional range. Pagination shows 16 rows per page.
Row actions include navigating to the content detail page and requeueing a single item back to the Kafka classification topic. Selecting multiple rows enables batch requeue, which POSTs all selected UUIDs to /api/contents/requeue in one request.
Content Detail (/contents/$contentId)
SSR is disabled. The route fetches a single content item via GET /api/contents/:id, which returns both the article record and every model prediction run that has ever been performed on it.
The top of the page shows two category badges side by side: the model-predicted category (model_kategori) and the human-approved category (kategori). Below that is the article metadata — title (baslik), source, language, mock flag, parsed status, and publish date — followed by a metadata grid with the source ID, created timestamp, and processed timestamp. The article summary (ozet) and full body (icerik) are displayed next, with a link to the original source URL.
At the bottom is a prediction history table showing every model run in chronological order. Each row includes the predicted category, the confidence percentage for that prediction, the full set of per-class probability scores (one column per category), and the date of the run. This makes it possible to see how the model's confidence changed across multiple classification attempts.
A category dropdown above the table allows the user to override the human-approved kategori. The dropdown is pre-populated with the item's current kategori value. Saving the selection sends PATCH /api/contents/:id/category with the integer category value (1–7, corresponding to Politika through Teknoloji). On success, TanStack Query invalidates both ["content", contentId] and ["contents"], so the detail view and the contents list both reflect the change immediately.
Analytics (/analytics)
The analytics page fires six parallel queries on mount, one for each data source. While any query is loading, its section shows a loading state; they resolve independently.
The Category Weights section shows a bar chart of article counts grouped by category, drawn from GET /api/analytics/categories.
The Daily Processed section shows a smooth line chart of how many articles were processed each day over the past 30 days, from GET /api/analytics/daily.
The Model Trust section shows the median model confidence score as a large number and a Kumo Meter bar, from GET /api/analytics/trust. This is computed server-side using a PostgreSQL PERCENTILE_CONT(0.5) expression on the confidence column.
The Model vs Source Agreement section shows a donut pie chart of how often the model-predicted category matches the human-approved category, alongside totals and an agreement rate percentage, from GET /api/analytics/comparison.
The Producer Runs table shows the last 30 fetch runs from GET /api/analytics/producer. Columns include the run date, the number of articles fetched, duplicate count, rate limit errors, and database errors. The table is paginated client-side.
The Consumer Log table shows recent processing records from GET /api/analytics/consumer. Columns include the source ID, processing latency in milliseconds, a success or failed badge, the error type if applicable, and the date. This table is also paginated client-side.
Pending (/pending)
The pending page shows articles that the ML model has not yet classified — specifically, rows where parsed is true (the producer fetched the full article body) but model_kategori is null (the consumer has not written a result). The query is capped at 100 rows on the API side.
The table has columns for title, language, model category (empty for all rows, by definition), parsed status, publish date, and created date. Title filtering uses a substring match; language filtering supports TR, EN, and AR as discrete options. Multi-row selection enables batch requeue: selected UUIDs are POSTed to /api/contents/requeue, which emits each qualifying item to the raw_content_aa Kafka topic so the consumer picks them up for re-classification. The query is invalidated on success.