From 9f97725f39cab60e5c5a7f00a25470cb6566261c Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 3 May 2026 06:57:08 +0300 Subject: [PATCH 1/2] First batch of shadcn refactors --- .../SourceDiversityPanel/index.test.tsx | 98 ++ .../SourceDiversityPanel/index.tsx | 311 +++-- .../SourceHealthPanel/index.stories.tsx | 57 + .../SourceHealthPanel/index.test.tsx | 63 + .../_components/SourceHealthPanel/index.tsx | 119 ++ .../TopicCentroidPanel/index.stories.tsx | 60 + .../TopicCentroidPanel/index.test.tsx | 96 ++ .../_components/TopicCentroidPanel/index.tsx | 231 ++++ .../TrendTaskRunsPanel/index.stories.tsx | 92 ++ .../TrendTaskRunsPanel/index.test.tsx | 90 ++ .../_components/TrendTaskRunsPanel/index.tsx | 296 +++++ .../src/app/admin/health/page.stories.tsx | 126 +- frontend/src/app/admin/health/page.tsx | 542 +------- .../NewProjectFormCard/index.stories.tsx | 20 + .../NewProjectFormCard/index.test.tsx | 28 + .../_components/NewProjectFormCard/index.tsx | 71 + .../ProjectFlashNotice/index.stories.tsx | 31 + .../ProjectFlashNotice/index.test.tsx | 33 + .../_components/ProjectFlashNotice/index.tsx | 27 + .../app/admin/projects/new/page.stories.tsx | 68 + .../src/app/admin/projects/new/page.test.tsx | 126 ++ frontend/src/app/admin/projects/new/page.tsx | 51 +- .../NewsletterIntakePanel/index.stories.tsx | 104 ++ .../NewsletterIntakePanel/index.test.tsx | 116 ++ .../NewsletterIntakePanel/index.tsx | 374 ++++++ .../ProviderSetupPanel/index.stories.tsx | 101 ++ .../ProviderSetupPanel/index.test.tsx | 108 ++ .../_components/ProviderSetupPanel/index.tsx | 481 +++++++ .../SourceConfigList/index.stories.tsx | 49 + .../SourceConfigList/index.test.tsx | 71 + .../_components/SourceConfigList/index.tsx | 108 ++ .../app/admin/sources/_components/helpers.ts | 136 ++ .../src/app/admin/sources/page.stories.tsx | 223 ++++ frontend/src/app/admin/sources/page.test.tsx | 4 +- frontend/src/app/admin/sources/page.tsx | 1159 +---------------- .../ContentDetailMainColumn/index.stories.tsx | 72 + .../ContentDetailMainColumn/index.test.tsx | 154 +++ .../ContentDetailMainColumn/index.tsx | 215 +++ .../ContentDetailSidebar/index.stories.tsx | 66 + .../ContentDetailSidebar/index.test.tsx | 126 ++ .../ContentDetailSidebar/index.tsx | 111 ++ .../SkillActionBar/index.stories.tsx | 39 + ...SkillActionBar.test.tsx => index.test.tsx} | 0 .../[id]/_components/SkillActionBar/index.ts | 1 - .../{SkillActionBar.tsx => index.tsx} | 38 +- .../src/app/content/[id]/page.stories.tsx | 144 ++ frontend/src/app/content/[id]/page.tsx | 306 +---- .../_components/DraftEditor/index.stories.tsx | 153 +++ .../_components/DraftEditor/index.tsx | 1019 ++++++++------- .../DraftOverviewCards/index.stories.tsx | 48 + .../DraftOverviewCards/index.test.tsx | 62 + .../_components/DraftOverviewCards/index.tsx | 64 + .../DraftRenderedOutput/index.stories.tsx | 55 + .../DraftRenderedOutput/index.test.tsx | 48 + .../_components/DraftRenderedOutput/index.tsx | 41 + .../DraftViewSwitcher/index.stories.tsx | 29 + .../DraftViewSwitcher/index.test.tsx | 36 + .../_components/DraftViewSwitcher/index.tsx | 81 ++ .../src/app/drafts/[draftId]/page.stories.tsx | 152 +++ frontend/src/app/drafts/[draftId]/page.tsx | 135 +- .../_components/DraftsList/index.stories.tsx | 62 + .../_components/DraftsList/index.test.tsx | 60 + .../drafts/_components/DraftsList/index.tsx | 86 ++ .../DraftsOverviewCards/index.stories.tsx | 53 + .../DraftsOverviewCards/index.test.tsx | 54 + .../_components/DraftsOverviewCards/index.tsx | 54 + .../DraftsToolbar/index.stories.tsx | 30 + .../_components/DraftsToolbar/index.test.tsx | 25 + .../_components/DraftsToolbar/index.tsx | 79 ++ frontend/src/app/drafts/page.stories.tsx | 131 ++ frontend/src/app/drafts/page.tsx | 154 +-- .../components/elements/StatusBadge/index.tsx | 16 +- frontend/src/components/ui/alert.tsx | 76 ++ frontend/src/components/ui/avatar.tsx | 109 ++ frontend/src/components/ui/badge.tsx | 52 + frontend/src/components/ui/card.tsx | 103 ++ frontend/src/components/ui/input.tsx | 20 + frontend/src/components/ui/label.tsx | 20 + frontend/src/components/ui/select.tsx | 201 +++ frontend/src/components/ui/table.tsx | 116 ++ frontend/src/components/ui/textarea.tsx | 18 + frontend/src/lib/storybook-docs.tsx | 15 - frontend/tsconfig.tsbuildinfo | 2 +- 83 files changed, 7548 insertions(+), 2853 deletions(-) create mode 100644 frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx create mode 100644 frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx create mode 100644 frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx create mode 100644 frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx create mode 100644 frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx create mode 100644 frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx create mode 100644 frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx create mode 100644 frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx create mode 100644 frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx create mode 100644 frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx create mode 100644 frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx create mode 100644 frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx create mode 100644 frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx create mode 100644 frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.stories.tsx create mode 100644 frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.test.tsx create mode 100644 frontend/src/app/admin/projects/new/_components/ProjectFlashNotice/index.tsx create mode 100644 frontend/src/app/admin/projects/new/page.stories.tsx create mode 100644 frontend/src/app/admin/projects/new/page.test.tsx create mode 100644 frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.stories.tsx create mode 100644 frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.test.tsx create mode 100644 frontend/src/app/admin/sources/_components/NewsletterIntakePanel/index.tsx create mode 100644 frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.stories.tsx create mode 100644 frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.test.tsx create mode 100644 frontend/src/app/admin/sources/_components/ProviderSetupPanel/index.tsx create mode 100644 frontend/src/app/admin/sources/_components/SourceConfigList/index.stories.tsx create mode 100644 frontend/src/app/admin/sources/_components/SourceConfigList/index.test.tsx create mode 100644 frontend/src/app/admin/sources/_components/SourceConfigList/index.tsx create mode 100644 frontend/src/app/admin/sources/_components/helpers.ts create mode 100644 frontend/src/app/admin/sources/page.stories.tsx create mode 100644 frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.stories.tsx create mode 100644 frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.test.tsx create mode 100644 frontend/src/app/content/[id]/_components/ContentDetailMainColumn/index.tsx create mode 100644 frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.stories.tsx create mode 100644 frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.test.tsx create mode 100644 frontend/src/app/content/[id]/_components/ContentDetailSidebar/index.tsx create mode 100644 frontend/src/app/content/[id]/_components/SkillActionBar/index.stories.tsx rename frontend/src/app/content/[id]/_components/SkillActionBar/{SkillActionBar.test.tsx => index.test.tsx} (100%) delete mode 100644 frontend/src/app/content/[id]/_components/SkillActionBar/index.ts rename frontend/src/app/content/[id]/_components/SkillActionBar/{SkillActionBar.tsx => index.tsx} (86%) create mode 100644 frontend/src/app/content/[id]/page.stories.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.stories.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.stories.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.test.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftOverviewCards/index.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.stories.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.test.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftRenderedOutput/index.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.stories.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.test.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftViewSwitcher/index.tsx create mode 100644 frontend/src/app/drafts/[draftId]/page.stories.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsList/index.stories.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsList/index.test.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsList/index.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsOverviewCards/index.stories.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsOverviewCards/index.test.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsOverviewCards/index.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsToolbar/index.stories.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsToolbar/index.test.tsx create mode 100644 frontend/src/app/drafts/_components/DraftsToolbar/index.tsx create mode 100644 frontend/src/app/drafts/page.stories.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/textarea.tsx diff --git a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx new file mode 100644 index 00000000..0cc4fe00 --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { + SourceDiversityObservabilitySummary, + SourceDiversitySnapshot, +} from "@/lib/types" + +import { SourceDiversityPanel } from "." + +function createSnapshot( + overrides: Partial = {}, +): SourceDiversitySnapshot { + return { + id: 3, + project: 1, + computed_at: "2026-04-28T08:00:00Z", + window_days: 14, + plugin_entropy: 0.65, + source_entropy: 0.72, + author_entropy: 0.48, + cluster_entropy: 0.58, + top_plugin_share: 0.62, + top_source_share: 0.44, + breakdown: { + total_content_count: 12, + plugin_counts: [{ key: "rss", label: "rss", count: 7, share: 0.58 }], + source_counts: [ + { key: "feed:1", label: "Example Feed", count: 5, share: 0.42 }, + ], + author_counts: [], + cluster_counts: [], + alerts: [], + }, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): SourceDiversityObservabilitySummary { + return { + project: 1, + snapshot_count: 2, + latest_snapshot: createSnapshot(), + ...overrides, + } +} + +describe("SourceDiversityPanel", () => { + it("renders source diversity alerts and trend details", () => { + render( + , + ) + + expect( + screen.getByRole("heading", { level: 2, name: "Source diversity" }), + ).toBeInTheDocument() + expect(screen.getByText("Your stream is 70%+ from RSS this week.")).toBeInTheDocument() + expect(screen.getByLabelText("Source diversity trend")).toBeInTheDocument() + }) + + it("renders the empty state when no snapshots exist", () => { + render( + , + ) + + expect( + screen.getByText("No source-diversity snapshots exist for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx index 059185bc..ab2b0089 100644 --- a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx +++ b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx @@ -1,4 +1,13 @@ +import type { ComponentProps } from "react" + import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" import type { SourceDiversityObservabilitySummary, SourceDiversitySnapshot, @@ -13,7 +22,7 @@ type SourceDiversityPanelProps = { /** SVG polyline points for the top-plugin-share trend. */ trendPoints: string /** Semantic tone for the summary badge. */ - statusTone: "positive" | "warning" | "negative" | "neutral" + statusTone: ComponentProps["tone"] /** Visible summary label for the status badge. */ statusLabel: string } @@ -50,142 +59,186 @@ export function SourceDiversityPanel({ statusLabel, }: SourceDiversityPanelProps) { return ( -
-
-
-

Source diversity

-

- Entropy, source concentration, and advisory alerts derived from the latest source-diversity snapshot. -

-
- {statusLabel} -
- - {summary.latest_snapshot ? ( - <> -
-
-

Plugin diversity

-

- {formatPercentScore(summary.latest_snapshot.plugin_entropy)} -

-
-
-

Source diversity

-

- {formatPercentScore(summary.latest_snapshot.source_entropy)} -

-
-
-

Author diversity

-

- {formatPercentScore(summary.latest_snapshot.author_entropy)} -

-
-
-

Cluster diversity

-

- {formatPercentScore(summary.latest_snapshot.cluster_entropy)} -

-
-
-

Top plugin share

-

- {formatPercentScore(summary.latest_snapshot.top_plugin_share)} -

-
-
-

Top source share

-

- {formatPercentScore(summary.latest_snapshot.top_source_share)} -

-
-
- - {visibleSnapshots.length > 1 ? ( -
-
- Top plugin share trend - Last {visibleSnapshots.length} snapshots -
- - - -
- ) : null} + + +

+ Source diversity +

+ + Entropy, source concentration, and advisory alerts derived from the latest source-diversity snapshot. + + + {statusLabel} + +
- {(summary.latest_snapshot.breakdown.alerts ?? []).length > 0 ? ( -
- {summary.latest_snapshot.breakdown.alerts.map((alert) => ( -
- {alert.code} -

{alert.message}

-
- ))} -
- ) : ( -
- No source-diversity alerts are active for this project. + + {summary.latest_snapshot ? ( + <> +
+ + +

+ Plugin diversity +

+

+ {formatPercentScore(summary.latest_snapshot.plugin_entropy)} +

+
+
+ + +

+ Source diversity +

+

+ {formatPercentScore(summary.latest_snapshot.source_entropy)} +

+
+
+ + +

+ Author diversity +

+

+ {formatPercentScore(summary.latest_snapshot.author_entropy)} +

+
+
+ + +

+ Cluster diversity +

+

+ {formatPercentScore(summary.latest_snapshot.cluster_entropy)} +

+
+
+ + +

+ Top plugin share +

+

+ {formatPercentScore(summary.latest_snapshot.top_plugin_share)} +

+
+
+ + +

+ Top source share +

+

+ {formatPercentScore(summary.latest_snapshot.top_source_share)} +

+
+
- )} -
-
-

Top plugin buckets

-
- {summary.latest_snapshot.breakdown.plugin_counts.slice(0, 4).map((item) => ( -
-
- {item.label} - {formatPercentScore(item.share)} -
- {renderShareBar(item.share)} + {visibleSnapshots.length > 1 ? ( + + +
+ Top plugin share trend + Last {visibleSnapshots.length} snapshots
+ + + +
+
+ ) : null} + + {(summary.latest_snapshot.breakdown.alerts ?? []).length > 0 ? ( +
+ {summary.latest_snapshot.breakdown.alerts.map((alert) => ( + + + {alert.code} +

{alert.message}

+
+
))}
-
-
-

Top source buckets

-
- {summary.latest_snapshot.breakdown.source_counts.slice(0, 4).map((item) => ( -
-
- {item.label} - {formatPercentScore(item.share)} -
- {renderShareBar(item.share)} + ) : ( + + + No source-diversity alerts are active for this project. + + + )} + +
+ + +

Top plugin buckets

+
+ {summary.latest_snapshot.breakdown.plugin_counts.slice(0, 4).map((item) => ( +
+
+ {item.label} + {formatPercentScore(item.share)} +
+ {renderShareBar(item.share)} +
+ ))}
- ))} -
+ + + + +

Top source buckets

+
+ {summary.latest_snapshot.breakdown.source_counts.slice(0, 4).map((item) => ( +
+
+ {item.label} + {formatPercentScore(item.share)} +
+ {renderShareBar(item.share)} +
+ ))} +
+
+
-
-
- - View raw breakdown JSON - -
-              {JSON.stringify(summary.latest_snapshot.breakdown, null, 2)}
-            
-
- - ) : ( -
- No source-diversity snapshots exist for this project yet. -
- )} -
+
+ + View raw breakdown JSON + +
+                {JSON.stringify(summary.latest_snapshot.breakdown, null, 2)}
+              
+
+ + ) : ( + + + No source-diversity snapshots exist for this project yet. + + + )} + + ) } diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx new file mode 100644 index 00000000..784dbe2b --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { + createIngestionRun, + createSourceConfig, +} from "@/lib/storybook-fixtures" + +import { SourceHealthPanel } from "." + +const meta = { + title: "Pages/AdminHealth/Components/SourceHealthPanel", + component: SourceHealthPanel, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + statusLabel: "mixed", + statusTone: "warning", + rows: [ + { + sourceConfig: createSourceConfig(), + latestRun: createIngestionRun(), + status: "healthy", + }, + { + sourceConfig: createSourceConfig({ + id: 8, + plugin_name: "reddit", + last_fetched_at: null, + }), + latestRun: createIngestionRun({ + id: 23, + plugin_name: "reddit", + status: "failed", + error_message: "Rate limit", + }), + status: "failing", + }, + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Mixed: Story = {} + +export const Empty: Story = { + args: { + rows: [], + statusLabel: "idle", + statusTone: "neutral", + }, +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx new file mode 100644 index 00000000..f0839761 --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { IngestionRun, SourceConfig } from "@/lib/types" + +import { SourceHealthPanel } from "." + +function createSourceConfig(overrides: Partial = {}): SourceConfig { + return { + id: 7, + project: 1, + plugin_name: "rss", + config: { feed_url: "https://example.com/feed.xml" }, + is_active: true, + last_fetched_at: "2026-04-28T08:00:00Z", + ...overrides, + } +} + +function createIngestionRun(overrides: Partial = {}): IngestionRun { + return { + id: 22, + project: 1, + plugin_name: "rss", + started_at: "2026-04-28T09:00:00Z", + completed_at: "2026-04-28T09:03:00Z", + status: "success", + items_fetched: 12, + items_ingested: 9, + error_message: "", + ...overrides, + } +} + +describe("SourceHealthPanel", () => { + it("renders source health rows", () => { + render( + , + ) + + expect(screen.getByText("Source configuration health")).toBeInTheDocument() + expect(screen.getByText("rss", { selector: "strong" })).toBeInTheDocument() + expect(screen.getByText("9/12")).toBeInTheDocument() + }) + + it("renders the empty state when no source configs exist", () => { + render() + + expect( + screen.getByText("No source configurations exist for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx new file mode 100644 index 00000000..b2428380 --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx @@ -0,0 +1,119 @@ +import type { ComponentProps } from "react" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { HealthStatus, IngestionRun, SourceConfig } from "@/lib/types" +import { formatDate, healthTone } from "@/lib/view-helpers" + +type SourceHealthRow = { + /** Stored source configuration for one plugin. */ + sourceConfig: SourceConfig + /** Latest ingestion run for that plugin, if any. */ + latestRun: IngestionRun | null + /** Computed health state for the source row. */ + status: HealthStatus +} + +type SourceHealthPanelProps = { + /** Source rows prepared by the page orchestration layer. */ + rows: SourceHealthRow[] + /** Optional section status label. */ + statusLabel?: string + /** Optional section status tone. */ + statusTone?: ComponentProps["tone"] +} + +/** Render the source-by-source ingestion health table for the selected project. */ +export function SourceHealthPanel({ + rows, + statusLabel = "sources", + statusTone = "neutral", +}: SourceHealthPanelProps) { + return ( + + +
+
+

+ Source configuration health +

+ + Per-plugin freshness, latest run outcome, and current source health for the selected project. + +
+ {statusLabel} +
+
+ + + {rows.length === 0 ? ( + + + No source configurations exist for this project yet. + + + ) : ( + + + + Source + Status + Last fetch + Latest run + Items + Errors + + + + {rows.map(({ sourceConfig, latestRun, status }) => ( + + + + {sourceConfig.plugin_name} + +
+ Config #{sourceConfig.id} + {sourceConfig.is_active ? "active" : "disabled"} +
+
+ + {status} + + + {formatDate(sourceConfig.last_fetched_at)} + + + {latestRun + ? `${latestRun.status} at ${formatDate(latestRun.started_at)}` + : "No runs yet"} + + + {latestRun + ? `${latestRun.items_ingested}/${latestRun.items_fetched}` + : "0/0"} + + + {latestRun?.error_message || "-"} + +
+ ))} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx new file mode 100644 index 00000000..f95170e7 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { + createTopicCentroidSnapshot, + createTopicCentroidSummary, +} from "@/lib/storybook-fixtures" + +import { TopicCentroidPanel } from "." + +const activeSnapshots = [ + createTopicCentroidSnapshot({ id: 1, computed_at: "2026-04-25T08:00:00Z", drift_from_previous: 0.08 }), + createTopicCentroidSnapshot({ id: 2, computed_at: "2026-04-26T08:00:00Z", drift_from_previous: 0.11 }), + createTopicCentroidSnapshot({ id: 3, computed_at: "2026-04-27T08:00:00Z", drift_from_previous: 0.14 }), +] + +const meta = { + title: "Pages/AdminHealth/Components/TopicCentroidPanel", + component: TopicCentroidPanel, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + summary: createTopicCentroidSummary({ + snapshot_count: 3, + active_snapshot_count: 3, + avg_drift_from_previous: 0.11, + avg_drift_from_week_ago: 0.18, + latest_snapshot: activeSnapshots[2], + }), + visibleSnapshots: activeSnapshots, + trendPoints: "0,66 110,58 220,49", + statusTone: "positive", + statusLabel: "active", + historyHref: "/admin/health?project=1#centroid-snapshot-history", + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Active: Story = {} + +export const NoSnapshots: Story = { + args: { + summary: createTopicCentroidSummary({ + snapshot_count: 0, + active_snapshot_count: 0, + avg_drift_from_previous: null, + avg_drift_from_week_ago: null, + latest_snapshot: null, + }), + visibleSnapshots: [], + trendPoints: "0,36 220,36", + statusTone: "neutral", + statusLabel: "idle", + }, +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx new file mode 100644 index 00000000..0c354500 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { + TopicCentroidObservabilitySummary, + TopicCentroidSnapshot, +} from "@/lib/types" + +import { TopicCentroidPanel } from "." + +function createSnapshot( + overrides: Partial = {}, +): TopicCentroidSnapshot { + return { + id: 3, + project: 1, + computed_at: "2026-04-28T08:00:00Z", + centroid_active: true, + feedback_count: 14, + upvote_count: 11, + downvote_count: 3, + drift_from_previous: 0.1, + drift_from_week_ago: 0.2, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): TopicCentroidObservabilitySummary { + return { + project: 1, + snapshot_count: 3, + active_snapshot_count: 2, + avg_drift_from_previous: 0.1, + avg_drift_from_week_ago: 0.2, + latest_snapshot: createSnapshot(), + ...overrides, + } +} + +describe("TopicCentroidPanel", () => { + it("renders centroid summary metrics and history", () => { + render( + , + ) + + expect(screen.getByText("Topic centroid observability")).toBeInTheDocument() + expect(screen.getAllByText("10.0%").length).toBeGreaterThan(0) + expect(screen.getByText("Feedback 14")).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "Open centroid snapshot history" }), + ).toHaveAttribute( + "href", + "/admin/health?project=1#centroid-snapshot-history", + ) + expect(screen.getByText("Centroid snapshot history")).toBeInTheDocument() + }) + + it("renders empty states when no snapshots exist", () => { + render( + , + ) + + expect( + screen.getByText("No centroid snapshots exist for this project yet."), + ).toBeInTheDocument() + expect( + screen.getByText("No centroid snapshot history exists for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx new file mode 100644 index 00000000..4e1af5f9 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx @@ -0,0 +1,231 @@ +import Link from "next/link" +import type { ComponentProps } from "react" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { + TopicCentroidObservabilitySummary, + TopicCentroidSnapshot, +} from "@/lib/types" +import { formatDate } from "@/lib/view-helpers" + +function formatDriftPercent(value: number | null) { + if (value === null) { + return "n/a" + } + return `${(value * 100).toFixed(1)}%` +} + +type TopicCentroidPanelProps = { + /** Aggregate centroid summary for the selected project. */ + summary: TopicCentroidObservabilitySummary + /** Recent centroid snapshots shown in the summary sparkline and history table. */ + visibleSnapshots: TopicCentroidSnapshot[] + /** SVG polyline points for the drift trend line. */ + trendPoints: string + /** Semantic tone for the summary badge. */ + statusTone: ComponentProps["tone"] + /** Visible label for the centroid status badge. */ + statusLabel: string + /** Deep link to the snapshot history section. */ + historyHref: string +} + +/** Render centroid observability summary and history for the admin health page. */ +export function TopicCentroidPanel({ + summary, + visibleSnapshots, + trendPoints, + statusTone, + statusLabel, + historyHref, +}: TopicCentroidPanelProps) { + return ( + <> + + +

+ Topic centroid observability +

+ + The latest centroid state for this project, plus average drift across persisted snapshot history. + + + {statusLabel} + +
+ + +
+ + +

+ Centroid state +

+

+ {summary.latest_snapshot + ? summary.latest_snapshot.centroid_active + ? "Active" + : "Inactive" + : "Not computed"} +

+
+
+ + +

+ Avg drift vs previous +

+

+ {formatDriftPercent(summary.avg_drift_from_previous)} +

+
+
+ + +

+ Avg drift vs 7d +

+

+ {formatDriftPercent(summary.avg_drift_from_week_ago)} +

+
+
+ + +

+ Latest snapshot +

+

+ {formatDate(summary.latest_snapshot?.computed_at ?? null)} +

+
+
+
+ + {visibleSnapshots.length > 1 ? ( + +
+ Recent drift trend + Last {visibleSnapshots.length} snapshots +
+ + + + + ) : null} + + {summary.latest_snapshot ? ( +
+ {summary.snapshot_count} snapshots + {summary.active_snapshot_count} active snapshots + Feedback {summary.latest_snapshot.feedback_count} + Upvotes {summary.latest_snapshot.upvote_count} + Downvotes {summary.latest_snapshot.downvote_count} +
+ ) : ( + + + No centroid snapshots exist for this project yet. + + + )} +
+
+ + + +

+ Centroid snapshot history +

+ + Recent centroid recomputations for this project, including feedback volume and drift between snapshots. + + + + Showing {visibleSnapshots.length} of {summary.snapshot_count} snapshots + + +
+ + + {visibleSnapshots.length === 0 ? ( + + + No centroid snapshot history exists for this project yet. + + + ) : ( + + + + Computed + State + Feedback + Drift vs previous + Drift vs 7d + + + + {visibleSnapshots.map((snapshot) => ( + + + {formatDate(snapshot.computed_at)} + + + + {snapshot.centroid_active ? "active" : "inactive"} + + + + {snapshot.feedback_count} total + + + {formatDriftPercent(snapshot.drift_from_previous)} + + + {formatDriftPercent(snapshot.drift_from_week_ago)} + + + ))} + +
+ )} +
+
+ + ) +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx new file mode 100644 index 00000000..c64c46aa --- /dev/null +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import type { + TrendTaskRun, + TrendTaskRunObservabilitySummary, +} from "@/lib/types" + +import { TrendTaskRunsPanel } from "." + +function createTrendTaskRun(overrides: Partial = {}): TrendTaskRun { + return { + id: 41, + project: 1, + task_name: "recompute_topic_centroid", + task_run_id: "95ae5b14-5d7d-498e-9adc-1dbaab4dd4b8", + status: "completed", + started_at: "2026-04-28T08:00:00Z", + finished_at: "2026-04-28T08:00:01Z", + latency_ms: 523, + error_message: "", + summary: { + project_id: 1, + feedback_count: 12, + upvote_count: 10, + downvote_count: 2, + }, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): TrendTaskRunObservabilitySummary { + return { + project: 1, + run_count: 8, + failed_run_count: 0, + latest_runs: [createTrendTaskRun()], + ...overrides, + } +} + +const historyRuns = [ + createTrendTaskRun(), + createTrendTaskRun({ + id: 42, + task_name: "generate_theme_suggestions", + status: "failed", + latency_ms: 1480, + error_message: "OpenRouter timeout", + summary: { project_id: 1, created: 0, updated: 0, skipped: 2 }, + }), +] + +const meta = { + title: "Pages/AdminHealth/Components/TrendTaskRunsPanel", + component: TrendTaskRunsPanel, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + historyHref: "/admin/health?project=1#trend-task-run-history", + statusLabel: "healthy", + statusTone: "positive", + summary: createSummary({ latest_runs: historyRuns, failed_run_count: 1 }), + visibleRuns: historyRuns, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Healthy: Story = {} + +export const Failing: Story = { + args: { + statusLabel: "failing", + statusTone: "negative", + }, +} + +export const Empty: Story = { + args: { + statusLabel: "idle", + statusTone: "neutral", + summary: createSummary({ run_count: 0, failed_run_count: 0, latest_runs: [] }), + visibleRuns: [], + }, +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx new file mode 100644 index 00000000..8ad59640 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { + TrendTaskRun, + TrendTaskRunObservabilitySummary, +} from "@/lib/types" + +import { TrendTaskRunsPanel } from "." + +function createTrendTaskRun(overrides: Partial = {}): TrendTaskRun { + return { + id: 41, + project: 1, + task_name: "recompute_topic_centroid", + task_run_id: "run-41", + status: "completed", + started_at: "2026-04-28T08:00:00Z", + finished_at: "2026-04-28T08:00:01Z", + latency_ms: 523, + error_message: "", + summary: { + project_id: 1, + feedback_count: 12, + upvote_count: 10, + downvote_count: 2, + }, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): TrendTaskRunObservabilitySummary { + return { + project: 1, + run_count: 2, + failed_run_count: 1, + latest_runs: [createTrendTaskRun()], + ...overrides, + } +} + +describe("TrendTaskRunsPanel", () => { + it("renders trend task summaries and failure details", () => { + const failedRun = createTrendTaskRun({ + id: 42, + task_name: "generate_theme_suggestions", + status: "failed", + latency_ms: 1480, + error_message: "OpenRouter timeout", + summary: { project_id: 1, created: 0, updated: 0, skipped: 2 }, + }) + + render( + , + ) + + expect(screen.getByText("Trend pipeline runs")).toBeInTheDocument() + expect(screen.getAllByText("Theme suggestions").length).toBeGreaterThan(0) + expect(screen.getAllByText("OpenRouter timeout").length).toBeGreaterThan(0) + expect(screen.getAllByText("1.5s").length).toBeGreaterThan(0) + expect(screen.getByText("Trend task run history")).toBeInTheDocument() + }) + + it("renders empty states when no task runs exist", () => { + render( + , + ) + + expect( + screen.getByText("No trend pipeline runs have been persisted for this project yet."), + ).toBeInTheDocument() + expect( + screen.getByText("No trend task run history exists for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx new file mode 100644 index 00000000..b1448e05 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx @@ -0,0 +1,296 @@ +import Link from "next/link" +import type { ComponentProps } from "react" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { + TrendTaskRun, + TrendTaskRunObservabilitySummary, +} from "@/lib/types" +import { formatDate } from "@/lib/view-helpers" + +const TREND_TASK_LABELS: Record = { + recompute_topic_centroid: "Topic centroid", + recompute_topic_clusters: "Topic clusters", + recompute_topic_velocity: "Topic velocity", + recompute_source_diversity: "Source diversity", + generate_theme_suggestions: "Theme suggestions", + generate_original_content_ideas: "Original content ideas", +} + +const TREND_TASK_DETAIL_LABELS: Record = { + feedback_count: "feedback", + upvote_count: "upvotes", + downvote_count: "downvotes", + contents_considered: "content", + clusters_updated: "clusters updated", + clusters_evaluated: "clusters evaluated", + snapshots_created: "snapshots", + content_count: "content", + alert_count: "alerts", + created: "created", + updated: "updated", + skipped: "skipped", +} + +function formatLatency(value: number | null) { + if (value === null) { + return "n/a" + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}s` + } + return `${value}ms` +} + +function trendTaskRunTone(status: TrendTaskRun["status"]) { + if (status === "failed") { + return "negative" + } + if (status === "started") { + return "warning" + } + if (status === "skipped") { + return "neutral" + } + return "positive" +} + +function formatTrendTaskName(taskName: string) { + return TREND_TASK_LABELS[taskName] ?? taskName.replaceAll("_", " ") +} + +function buildTrendTaskRunSummaryText(taskRun: TrendTaskRun) { + const detailParts = Object.entries(taskRun.summary) + .filter(([key, value]) => key !== "project_id" && key !== "snapshot_id" && value !== null) + .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) + .slice(0, 3) + .map(([key, value]) => `${TREND_TASK_DETAIL_LABELS[key] ?? key.replaceAll("_", " ")} ${String(value)}`) + + if (detailParts.length === 0) { + return "No task summary recorded yet." + } + + return detailParts.join(" • ") +} + +type TrendTaskRunsPanelProps = { + /** Project-level trend task summary. */ + summary: TrendTaskRunObservabilitySummary + /** Visible persisted task runs for the history table. */ + visibleRuns: TrendTaskRun[] + /** Semantic tone for the overall section status. */ + statusTone: ComponentProps["tone"] + /** Visible label for the overall section status. */ + statusLabel: string + /** Deep link to the history section. */ + historyHref: string +} + +/** Render trend pipeline status and recent persisted runs for the health page. */ +export function TrendTaskRunsPanel({ + summary, + visibleRuns, + statusTone, + statusLabel, + historyHref, +}: TrendTaskRunsPanelProps) { + return ( + <> + + +

+ Trend pipeline runs +

+ + The latest persisted run for each tracked trend task, including task outcome, runtime, and any recorded failure message. + + + {statusLabel} + +
+ + +
+ + +

+ Persisted runs +

+

+ {summary.run_count} +

+
+
+ + +

+ Latest task rows +

+

+ {summary.latest_runs.length} +

+
+
+ + +

+ Failed runs +

+

+ {summary.failed_run_count} +

+
+
+
+ + {summary.latest_runs.length === 0 ? ( + + + No trend pipeline runs have been persisted for this project yet. + + + ) : ( + <> + {visibleRuns.length > 0 ? ( + +
+ Recent task history + Last {visibleRuns.length} persisted runs +
+ + ) : null} + + + + + Task + Status + Started + Duration + Summary + + + + {summary.latest_runs.map((taskRun) => ( + + + {formatTrendTaskName(taskRun.task_name)} + + + + {taskRun.status} + + + + {formatDate(taskRun.started_at)} + + + {formatLatency(taskRun.latency_ms)} + + +

{buildTrendTaskRunSummaryText(taskRun)}

+ {taskRun.error_message ? ( +

{taskRun.error_message}

+ ) : null} +
+
+ ))} +
+
+ + )} +
+
+ + + +

+ Trend task run history +

+ + Recent persisted executions across the trend pipeline, including run duration, summary output, and the latest recorded failures. + + + + Showing {visibleRuns.length} of {summary.run_count} runs + + +
+ + + {visibleRuns.length === 0 ? ( + + + No trend task run history exists for this project yet. + + + ) : ( + + + + Started + Task + Status + Finished + Duration + Summary + + + + {visibleRuns.map((taskRun) => ( + + + {formatDate(taskRun.started_at)} + + + {formatTrendTaskName(taskRun.task_name)} + + + + {taskRun.status} + + + + {formatDate(taskRun.finished_at)} + + + {formatLatency(taskRun.latency_ms)} + + +

{buildTrendTaskRunSummaryText(taskRun)}

+ {taskRun.error_message ? ( +

{taskRun.error_message}

+ ) : null} +
+
+ ))} +
+
+ )} +
+
+ + ) +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/page.stories.tsx b/frontend/src/app/admin/health/page.stories.tsx index 8ca1db93..94f97c94 100644 --- a/frontend/src/app/admin/health/page.stories.tsx +++ b/frontend/src/app/admin/health/page.stories.tsx @@ -1,7 +1,9 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite" import { SourceDiversityPanel } from "@/app/admin/health/_components/SourceDiversityPanel" -import { StatusBadge } from "@/components/elements/StatusBadge" +import { SourceHealthPanel } from "@/app/admin/health/_components/SourceHealthPanel" +import { TopicCentroidPanel } from "@/app/admin/health/_components/TopicCentroidPanel" +import { TrendTaskRunsPanel } from "@/app/admin/health/_components/TrendTaskRunsPanel" import { AppShell } from "@/components/layout/AppShell" import { compactDocsParameters } from "@/lib/storybook-docs" import { @@ -10,6 +12,7 @@ import { createSourceConfig, createSourceDiversitySnapshot, createSourceDiversitySummary, + createTopicCentroidSnapshot, createTopicCentroidSummary, } from "@/lib/storybook-fixtures" @@ -48,8 +51,15 @@ export const NoSnapshots: Story = { function HealthPagePreview({ alerting = false, noSnapshots = false }: HealthPreviewProps) { const projects = [createProject()] + const centroidSnapshots = noSnapshots + ? [] + : [ + createTopicCentroidSnapshot({ id: 1, computed_at: "2026-04-25T08:00:00Z", drift_from_previous: 0.08 }), + createTopicCentroidSnapshot({ id: 2, computed_at: "2026-04-26T08:00:00Z", drift_from_previous: 0.12 }), + createTopicCentroidSnapshot({ id: 3, computed_at: "2026-04-27T08:00:00Z", drift_from_previous: 0.18 }), + ] const centroidSummary = createTopicCentroidSummary({ - latest_snapshot: noSnapshots ? null : createTopicCentroidSummary().latest_snapshot, + latest_snapshot: noSnapshots ? null : centroidSnapshots[2], snapshot_count: noSnapshots ? 0 : 4, active_snapshot_count: noSnapshots ? 0 : 4, avg_drift_from_previous: noSnapshots ? null : 0.12, @@ -82,6 +92,47 @@ function HealthPagePreview({ alerting = false, noSnapshots = false }: HealthPrev createIngestionRun(), createIngestionRun({ id: 23, plugin_name: "reddit", status: alerting ? "failed" : "success", error_message: alerting ? "Rate limit" : "" }), ] + const trendRuns = [ + { + id: 41, + project: 1, + task_name: "recompute_topic_centroid", + task_run_id: "run-41", + status: "completed" as const, + started_at: "2026-04-28T08:00:00Z", + finished_at: "2026-04-28T08:00:01Z", + latency_ms: 523, + error_message: "", + summary: { + project_id: 1, + feedback_count: 12, + upvote_count: 10, + downvote_count: 2, + }, + }, + { + id: 42, + project: 1, + task_name: "generate_theme_suggestions", + task_run_id: "run-42", + status: alerting ? ("failed" as const) : ("completed" as const), + started_at: "2026-04-28T08:10:00Z", + finished_at: "2026-04-28T08:10:01Z", + latency_ms: alerting ? 1480 : 910, + error_message: alerting ? "OpenRouter timeout" : "", + summary: { project_id: 1, created: 1, updated: 0, skipped: 2 }, + }, + ] + const sourceRows = sourceConfigs.map((sourceConfig, index) => ({ + sourceConfig, + latestRun: runs[index] ?? null, + status: + index === 1 && alerting + ? ("failing" as const) + : noSnapshots && index === 1 + ? ("degraded" as const) + : ("healthy" as const), + })) return ( -
-
-
-

Topic centroid observability

-

Representative centroid summary for the health page composition story.

-
- {noSnapshots ? "idle" : "active"} -
-
-
-

Centroid state

-

{centroidSummary.latest_snapshot ? "Active" : "Not computed"}

-
-
-

Avg drift vs previous

-

{centroidSummary.avg_drift_from_previous ?? "n/a"}

-
-
-
+ + + -
-
- - - - - - - - - - {sourceConfigs.map((sourceConfig, index) => ( - - - - - - ))} - -
SourceStatusLatest run
{sourceConfig.plugin_name} - - {index === 1 && alerting ? "failing" : "healthy"} - - {runs[index]?.status ?? "No runs yet"}
-
-
+
) } diff --git a/frontend/src/app/admin/health/page.tsx b/frontend/src/app/admin/health/page.tsx index ab6b38f0..b4d16dbd 100644 --- a/frontend/src/app/admin/health/page.tsx +++ b/frontend/src/app/admin/health/page.tsx @@ -1,7 +1,7 @@ -import Link from "next/link" - import { SourceDiversityPanel } from "@/app/admin/health/_components/SourceDiversityPanel" -import { StatusBadge } from "@/components/elements/StatusBadge" +import { SourceHealthPanel } from "@/app/admin/health/_components/SourceHealthPanel" +import { TopicCentroidPanel } from "@/app/admin/health/_components/TopicCentroidPanel" +import { TrendTaskRunsPanel } from "@/app/admin/health/_components/TrendTaskRunsPanel" import { AppShell } from "@/components/layout/AppShell" import { getProjectIngestionRuns, @@ -16,38 +16,15 @@ import { } from "@/lib/api" import type { HealthStatus, + IngestionRun, + SourceConfig, SourceDiversityObservabilitySummary, SourceDiversitySnapshot, TopicCentroidObservabilitySummary, TopicCentroidSnapshot, - TrendTaskRun, TrendTaskRunObservabilitySummary, } from "@/lib/types" -import { formatDate, healthTone, selectProject } from "@/lib/view-helpers" - -const TREND_TASK_LABELS: Record = { - recompute_topic_centroid: "Topic centroid", - recompute_topic_clusters: "Topic clusters", - recompute_topic_velocity: "Topic velocity", - recompute_source_diversity: "Source diversity", - generate_theme_suggestions: "Theme suggestions", - generate_original_content_ideas: "Original content ideas", -} - -const TREND_TASK_DETAIL_LABELS: Record = { - feedback_count: "feedback", - upvote_count: "upvotes", - downvote_count: "downvotes", - contents_considered: "content", - clusters_updated: "clusters updated", - clusters_evaluated: "clusters evaluated", - snapshots_created: "snapshots", - content_count: "content", - alert_count: "alerts", - created: "created", - updated: "updated", - skipped: "skipped", -} +import { healthTone, selectProject } from "@/lib/view-helpers" type HealthPageProps = { /** Search params promise containing the optional `project` selector. */ @@ -159,47 +136,6 @@ export function formatDriftPercent(value: number | null) { return `${(value * 100).toFixed(1)}%` } -function formatLatency(value: number | null) { - if (value === null) { - return "n/a" - } - if (value >= 1000) { - return `${(value / 1000).toFixed(1)}s` - } - return `${value}ms` -} - -function trendTaskRunTone(status: TrendTaskRun["status"]) { - if (status === "failed") { - return "negative" - } - if (status === "started") { - return "warning" - } - if (status === "skipped") { - return "neutral" - } - return "positive" -} - -function formatTrendTaskName(taskName: string) { - return TREND_TASK_LABELS[taskName] ?? taskName.replaceAll("_", " ") -} - -function buildTrendTaskRunSummaryText(taskRun: TrendTaskRun) { - const detailParts = Object.entries(taskRun.summary) - .filter(([key, value]) => key !== "project_id" && key !== "snapshot_id" && value !== null) - .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) - .slice(0, 3) - .map(([key, value]) => `${TREND_TASK_DETAIL_LABELS[key] ?? key.replaceAll("_", " ")} ${String(value)}`) - - if (detailParts.length === 0) { - return "No task summary recorded yet." - } - - return detailParts.join(" • ") -} - /** * Build sparkline points for centroid drift across recent snapshots. * @@ -258,6 +194,25 @@ export function buildSourceDiversityTrendPoints( .join(" ") } +function buildSourceHealthRows( + sourceConfigs: SourceConfig[], + latestRunByPlugin: Map, +) { + return sourceConfigs.map((sourceConfig) => { + const latestRun = latestRunByPlugin.get(sourceConfig.plugin_name) ?? null + + return { + sourceConfig, + latestRun, + status: deriveSourceStatus( + sourceConfig.is_active, + latestRun?.status ?? null, + sourceConfig.last_fetched_at, + ), + } + }) +} + /** * Render the source-by-source ingestion health view for the selected project. * @@ -339,6 +294,16 @@ export default async function HealthPage({ searchParams }: HealthPageProps) { latestRunByPlugin.set(ingestionRun.plugin_name, ingestionRun) } } + const sourceHealthRows = buildSourceHealthRows( + sourceConfigs, + latestRunByPlugin, + ) + const centroidStatusLabel = centroidSummary.latest_snapshot + ? centroidSummary.latest_snapshot.centroid_active + ? "active" + : "inactive" + : "idle" + const trendStatus = deriveTrendTaskRunStatus(trendTaskRunSummary) return ( -
-
-
-

- Topic centroid observability -

-

- The latest centroid state for this project, plus average drift across - persisted snapshot history. -

-
- - {centroidSummary.latest_snapshot - ? centroidSummary.latest_snapshot.centroid_active - ? "active" - : "inactive" - : "idle"} - -
- -
-
-

- Centroid state -

-

- {centroidSummary.latest_snapshot - ? centroidSummary.latest_snapshot.centroid_active - ? "Active" - : "Inactive" - : "Not computed"} -

-
-
-

- Avg drift vs previous -

-

- {formatDriftPercent(centroidSummary.avg_drift_from_previous)} -

-
-
-

- Avg drift vs 7d -

-

- {formatDriftPercent(centroidSummary.avg_drift_from_week_ago)} -

-
-
-

- Latest snapshot -

-

- {formatDate(centroidSummary.latest_snapshot?.computed_at ?? null)} -

-
-
- - {visibleCentroidSnapshots.length > 1 ? ( - -
- Recent drift trend - Last {visibleCentroidSnapshots.length} snapshots -
- - - - - ) : null} - - {centroidSummary.latest_snapshot ? ( -
- {centroidSummary.snapshot_count} snapshots - {centroidSummary.active_snapshot_count} active snapshots - - Feedback {centroidSummary.latest_snapshot.feedback_count} - - Upvotes {centroidSummary.latest_snapshot.upvote_count} - Downvotes {centroidSummary.latest_snapshot.downvote_count} -
- ) : ( -
- No centroid snapshots exist for this project yet. -
- )} -
- -
-
-
-

- Trend pipeline runs -

-

- The latest persisted run for each tracked trend task, including task outcome, runtime, and any recorded failure message. -

-
- - {deriveTrendTaskRunStatus(trendTaskRunSummary)} - -
- -
-
-

- Persisted runs -

-

- {trendTaskRunSummary.run_count} -

-
-
-

- Latest task rows -

-

- {trendTaskRunSummary.latest_runs.length} -

-
-
-

- Failed runs -

-

- {trendTaskRunSummary.failed_run_count} -

-
-
- - {trendTaskRunSummary.latest_runs.length === 0 ? ( -
- No trend pipeline runs have been persisted for this project yet. -
- ) : ( - <> - {visibleTrendTaskRuns.length > 0 ? ( - -
- Recent task history - Last {visibleTrendTaskRuns.length} persisted runs -
- - ) : null} - -
- - - - - - - - - - - - {trendTaskRunSummary.latest_runs.map((taskRun) => ( - - - - - - - - ))} - -
TaskStatusStartedDurationSummary
- {formatTrendTaskName(taskRun.task_name)} - - - {taskRun.status} - - - {formatDate(taskRun.started_at)} - - {formatLatency(taskRun.latency_ms)} - -

{buildTrendTaskRunSummaryText(taskRun)}

- {taskRun.error_message ? ( -

{taskRun.error_message}

- ) : null} -
-
- - )} -
- -
-
-
-

- Trend task run history -

-

- Recent persisted executions across the trend pipeline, including run duration, summary output, and the latest recorded failures. -

-
- - Showing {visibleTrendTaskRuns.length} of {trendTaskRunSummary.run_count} runs - -
- - {visibleTrendTaskRuns.length === 0 ? ( -
- No trend task run history exists for this project yet. -
- ) : ( -
- - - - - - - - - - - - - {visibleTrendTaskRuns.map((taskRun) => ( - - - - - - - - - ))} - -
StartedTaskStatusFinishedDurationSummary
- {formatDate(taskRun.started_at)} - - {formatTrendTaskName(taskRun.task_name)} - - - {taskRun.status} - - - {formatDate(taskRun.finished_at)} - - {formatLatency(taskRun.latency_ms)} - -

{buildTrendTaskRunSummaryText(taskRun)}

- {taskRun.error_message ? ( -

{taskRun.error_message}

- ) : null} -
-
- )} -
- -
-
-
-

- Centroid snapshot history -

-

- Recent centroid recomputations for this project, including feedback volume and drift between snapshots. -

-
- - Showing {visibleCentroidSnapshots.length} of {centroidSummary.snapshot_count} snapshots - -
+ - {visibleCentroidSnapshots.length === 0 ? ( -
- No centroid snapshot history exists for this project yet. -
- ) : ( -
- - - - - - - - - - - - {visibleCentroidSnapshots.map((snapshot) => ( - - - - - - - - ))} - -
ComputedStateFeedbackDrift vs previousDrift vs 7d
- {formatDate(snapshot.computed_at)} - - - {snapshot.centroid_active ? "active" : "inactive"} - - - {snapshot.feedback_count} total - - {formatDriftPercent(snapshot.drift_from_previous)} - - {formatDriftPercent(snapshot.drift_from_week_ago)} -
-
- )} -
+ -
-
- - - - - - - - - - - - - {sourceConfigs.length === 0 ? ( - - - - ) : null} - {sourceConfigs.map((sourceConfig) => { - const latestRun = - latestRunByPlugin.get(sourceConfig.plugin_name) ?? null - const status = deriveSourceStatus( - sourceConfig.is_active, - latestRun?.status ?? null, - sourceConfig.last_fetched_at, - ) - return ( - - - - - - - - - ) - })} - -
SourceStatusLast fetchLatest runItemsErrors
-
- No source configurations exist for this project yet. -
-
- - {sourceConfig.plugin_name} - -
- Config #{sourceConfig.id} - - {sourceConfig.is_active ? "active" : "disabled"} - -
-
- - {status} - - - {formatDate(sourceConfig.last_fetched_at)} - - {latestRun - ? `${latestRun.status} at ${formatDate(latestRun.started_at)}` - : "No runs yet"} - - {latestRun - ? `${latestRun.items_ingested}/${latestRun.items_fetched}` - : "0/0"} - - {latestRun?.error_message || "-"} -
-
-
+
) } diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx new file mode 100644 index 00000000..b9f15efe --- /dev/null +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" + +import { NewProjectFormCard } from "." + +const meta = { + title: "Pages/AdminProjects/New/Components/NewProjectFormCard", + component: NewProjectFormCard, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} \ No newline at end of file diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx new file mode 100644 index 00000000..df1a909b --- /dev/null +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { NewProjectFormCard } from "." + +describe("NewProjectFormCard", () => { + it("renders the project creation form fields", () => { + render() + + expect(screen.getByRole("heading", { level: 2, name: "New project" })).toBeInTheDocument() + expect(screen.getByLabelText("Name")).toBeRequired() + expect(screen.getByLabelText("Topic description")).toBeRequired() + expect(screen.getByLabelText("Content retention days")).toHaveValue(365) + expect(screen.getByRole("button", { name: "Create project" })).toBeInTheDocument() + }) + + it("posts back to the projects api with the redirect hint", () => { + render() + + const form = screen.getByRole("button", { name: "Create project" }).closest("form") + const redirectInput = screen.getByDisplayValue("/admin/projects/new") + + expect(form).toHaveAttribute("action", "/api/projects") + expect(form).toHaveAttribute("method", "POST") + expect(redirectInput).toHaveAttribute("name", "redirectTo") + expect(redirectInput).toHaveAttribute("type", "hidden") + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx new file mode 100644 index 00000000..9602e7af --- /dev/null +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" + +/** Render the self-service project creation form. */ +export function NewProjectFormCard() { + return ( + + +

+ Provision +

+

+ New project +

+ + Create a project, set its editorial scope, and become the first project admin automatically. + +
+ + +
+ + +
+ + +
+ +
+ +