Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions observer-web/e2e/explorer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";

// @smoke
// Requires: next dev running on port 3000, observer-api on 8090, Supabase running on 54321
// Seeded test user: admin@energex.local / energex-observer-dev (role: admin)
// Run: npx playwright test e2e/explorer.spec.ts
// Deferred: full run requires the composed stack (docker compose up).

const E2E_EMAIL = process.env.E2E_EMAIL ?? "admin@energex.local";
const E2E_PASSWORD = process.env.E2E_PASSWORD ?? "energex-observer-dev";

async function signIn(page: Parameters<Parameters<typeof test>[1]>[0]) {
await page.goto("http://localhost:3000/login");
await page.fill('input[type="email"]', E2E_EMAIL);
await page.fill('input[type="password"]', E2E_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL("http://localhost:3000/", { timeout: 10000 });
}

test.describe("@smoke explorer flow", () => {
test("home page shows the 4V tiles after sign-in", async ({ page }) => {
await signIn(page);
await expect(page.getByText("Volume")).toBeVisible({ timeout: 8000 });
await expect(page.getByText("Velocity")).toBeVisible({ timeout: 8000 });
await expect(page.getByText("Variety")).toBeVisible({ timeout: 8000 });
await expect(page.getByText("Veracity")).toBeVisible({ timeout: 8000 });
});

test("navigates to Catalog and selects a symbol", async ({ page }) => {
await signIn(page);

// Navigate to Catalog via the nav rail
await page.click('a[href="/catalog"]');
await expect(page).toHaveURL(/\/catalog/, { timeout: 8000 });

// The catalog tree should render the sidebar heading
await expect(page.getByText("Catalog")).toBeVisible({ timeout: 8000 });
});

test("Series tab renders a chart canvas for a symbol", async ({ page }) => {
await signIn(page);

// Navigate directly to catalog with a known seeded symbol
// (adjust library/symbol to match seeded test data)
await page.goto("http://localhost:3000/catalog?library=power.load&symbol=erco", {
waitUntil: "networkidle",
});

// Click the Series tab
await page.click('button:has-text("Series")');

// The chart canvas should mount (echarts renders a canvas inside the chart div)
await expect(page.locator('[aria-label="series chart"]')).toBeVisible({ timeout: 10000 });
});
});
32 changes: 32 additions & 0 deletions observer-web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions observer-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dependencies": {
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.109.0",
"echarts": "^6.1.0",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4"
Expand Down
143 changes: 143 additions & 0 deletions observer-web/src/app/(app)/catalog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { apiFetch } from "@/lib/api";
import type { CatalogLibrary, SchemaDescription, VintageRow } from "@/lib/api";
import { CatalogTreeClient } from "@/components/catalog-tree-client";
import { SymbolDetail } from "@/components/symbol-detail";

interface SearchParams {
library?: string | string[];
symbol?: string | string[];
}

function str(v: string | string[] | undefined): string | null {
if (!v) return null;
return Array.isArray(v) ? v[0] : v;
}

function isAuthError(msg: string): boolean {
return /: 40[13]/.test(msg);
}

function ErrorBanner({ message }: { message: string }) {
const authError = isAuthError(message);
return (
<div className="panel p-4">
<p className="text-sm text-muted">
{authError
? "Couldn't load data — you may not have access. Try signing in again."
: "Couldn't load data — confirm observer-api is running and that you're signed in with access."}
</p>
</div>
);
}

export default async function CatalogPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const sp = await searchParams;
const selectedLibrary = str(sp.library);
const selectedSymbol = str(sp.symbol);

// Always fetch catalog
let catalogResult: { libraries: CatalogLibrary[] } | { error: string } | null = null;
try {
catalogResult = await apiFetch<{ libraries: CatalogLibrary[] }>("/catalog");
} catch (err) {
console.error("[CatalogPage] catalog fetch failed:", err);
catalogResult = { error: err instanceof Error ? err.message : String(err) };
}

const catalogError = catalogResult == null || "error" in catalogResult;
const libraries =
!catalogError && catalogResult !== null && "libraries" in catalogResult
? catalogResult.libraries
: [];

// Find selected symbol metadata from catalog (no extra fetch needed for overview data)
const selectedLib = selectedLibrary
? libraries.find((l) => l.name === selectedLibrary)
: null;
const selectedSym = selectedLib && selectedSymbol
? selectedLib.symbols.find((s) => s.symbol === selectedSymbol)
: null;

// Fetch schema + vintages if a symbol is selected
let schema: SchemaDescription | null = null;
let schemaError: string | null = null;
let vintages: VintageRow[] = [];
let vintagesError: string | null = null;

if (selectedLibrary && selectedSymbol && selectedSym) {
const base = `/symbol/${selectedLibrary}/${selectedSymbol}`;

const [schemaResult, vintagesResult] = await Promise.allSettled([
apiFetch<{ schema_name: string | null; columns: SchemaDescription["columns"]; checks: string[] }>(
`${base}/schema`
),
apiFetch<{ library: string; symbol: string; vintages: VintageRow[] }>(
`${base}/vintages`
),
]);

if (schemaResult.status === "fulfilled") {
schema = schemaResult.value;
} else {
console.error("[CatalogPage] schema fetch failed:", schemaResult.reason);
schemaError = schemaResult.reason instanceof Error
? schemaResult.reason.message
: String(schemaResult.reason);
}

if (vintagesResult.status === "fulfilled") {
vintages = vintagesResult.value.vintages;
} else {
console.error("[CatalogPage] vintages fetch failed:", vintagesResult.reason);
vintagesError = vintagesResult.reason instanceof Error
? vintagesResult.reason.message
: String(vintagesResult.reason);
}
}

return (
<div className="flex h-full gap-4">
{/* Left pane: tree */}
<aside className="w-60 shrink-0 overflow-y-auto panel p-2">
<p className="px-2 py-1.5 text-xs font-medium text-muted uppercase tracking-wider">
Catalog
</p>
{catalogError ? (
<ErrorBanner
message={catalogResult != null && "error" in catalogResult ? catalogResult.error : "unavailable"}
/>
) : (
<CatalogTreeClient
catalog={{ libraries }}
selectedLibrary={selectedLibrary}
selectedSymbol={selectedSymbol}
/>
)}
</aside>

{/* Right pane: detail */}
<div className="flex-1 overflow-y-auto min-w-0">
{selectedSym && selectedLibrary ? (
<SymbolDetail
library={selectedLibrary}
sym={selectedSym}
schema={schema}
vintages={vintages}
schemaError={schemaError}
vintagesError={vintagesError}
/>
) : (
<div className="panel p-6 text-sm text-muted">
{selectedLibrary && selectedSymbol && !selectedSym
? `Symbol "${selectedSymbol}" not found in library "${selectedLibrary}".`
: "Select a symbol from the tree to view details."}
</div>
)}
</div>
</div>
);
}
7 changes: 2 additions & 5 deletions observer-web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { roleFromSession } from "@/lib/api";
import { NavRail } from "@/components/nav-rail";
import { NavRailActive } from "@/components/nav-rail-active";

export default async function AppLayout({
children,
Expand All @@ -19,12 +19,9 @@ export default async function AppLayout({

const role = roleFromSession(session.access_token);

// active section is fixed until the other section routes exist
const active = "overview";

return (
<div className="flex h-screen overflow-hidden">
<NavRail role={role} active={active} />
<NavRailActive role={role} />
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-12 shrink-0 items-center border-b border-line-soft bg-panel px-4">
<span className="text-sm text-muted">Energex Observer</span>
Expand Down
Loading
Loading