Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Public HTTPS origin of the canonical phlo-api service. Requests are made server-side,
# and the incoming OAuth2 Proxy session cookie is forwarded only to this origin.
PHLO_API_BASE_URL=https://api.example.com
39 changes: 16 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# phlo web — mission control

The phlo lakehouse mission-control app, built from the design canvas with **TanStack Start**, **shadcn-style components on Base UI**, and **Tailwind v4**. Every screen from the canvas is here, in light and dark mode, for desktop and phone. For now it runs on typed mock data.
The replacement Phlo Observatory, built with **TanStack Start**, **Base UI**, and **Tailwind v4**. It is being cut over screen by screen to Phlo's canonical `/api/v1` API. Local preview uses a disposable demo API, never a live Phlo installation.

## Run it

Expand All @@ -21,14 +21,13 @@ pnpm start # serves the production build on :3000 (vite preview)
| Route | Screen |
|---|---|
| `/` | Overview (prod) |
| `/staging` | Staging overview: differences from prod and promotion |
| `/incidents`, `/incidents/:id` | Incident list; #214, #213, #211, #209, #207 open, #198 resolved (`?dialog=new-incident`) |
| `/assets`, `/assets/:name` | Asset catalogue and detail; tabs in `?tab=`, plus `?dialog=materialize` and `?dialog=add-audit` |
| `/staging` | Independently fetched staging and production overview evidence; promotion remains unavailable |
| `/incidents`, `/incidents/:id` | Environment-scoped incident list, detail, and API timeline |
| `/assets`, `/assets/:name` | Environment-scoped asset catalogue, schema, lineage, and run history |
| `/query` | SQL editor, catalog tree, results as a table, chart or plan |
| `/pipelines`, `/pipelines/:job`, `/pipelines/timeline` | Jobs by domain, owner or source; job detail; 24 h run timeline |
| `/branches` | Nessie branches and tags, commit graph (`?dialog=new-branch`, `?dialog=merge`) |
| `/settings`, `/settings/members`, `/settings/audit-log` | Lakehouse settings, members and access, signed audit log |
| `/states` | Gallery of the harder states: running, failed, empty, no permission, offline |
| `/pipelines`, `/pipelines/:job`, `/pipelines/timeline` | Environment-scoped jobs, schedules, run evidence, logs, patterns, and maintenance windows |
| `/branches` | Environment-scoped refs, commits, diffs, and comparisons; write workflows remain unavailable |
| `/settings`, `/settings/members`, `/settings/audit-log` | Read-only settings and identity records; audit search, verification, and export |

⌘K (or the search button) opens the command palette. At `lg` and wider the sidebar shows. Below `lg` the app switches to the phone layout: a top bar plus a tab bar with Home, Incidents, Assets and Pipelines.

Expand All @@ -45,30 +44,23 @@ src/
components/phlo/ shared app pieces (PageHeader, KpiCard, RunStrip, SeverityBadge, Sidebar…)
components/<area>/ pieces used by one area only
lib/data/types.ts domain types
lib/data/fixtures/ mock data
lib/data/fixtures/ design fixtures; not a source for migrated screen data
lib/data/api/ createServerFn wrappers — the only thing routes call for data
design-reference/ the original design boards (.dc.html + theme.css) — the visual spec
docs/CONVENTIONS.md rules for tokens, status colours, data, responsive design, accessibility
```

Read `docs/CONVENTIONS.md` before adding screens. In short: use colours only through tokens, keep to the fixed status language (bad/warn/ok/branch/info/neutral), fetch data only through server functions, and make every page work at 390 px.

## Switching to real data
## API integration status

Routes never import fixtures directly; they call server functions in `src/lib/data/api/*.ts`, for example:
The shared server-side client in `src/lib/data/api/client.ts` is the integration seam for the canonical Phlo API. Set `PHLO_API_BASE_URL` to the API's HTTPS origin; do not point the app directly at Dagster, Nessie or the query engine. The client forwards the incoming OAuth2 Proxy session cookie to that configured origin, disables caching, and never forwards a browser-supplied `Authorization` header. In local development, `http://localhost` is accepted for a disposable API; production requires HTTPS. See `.env.example`.

```ts
export const getIncidents = createServerFn({ method: 'GET' }).handler(async () => incidents)
```

To go live, replace each handler body with a real call and keep the return type from `types.ts`. Server functions run only on the server, so credentials stay out of the browser. Where each part would likely come from:
Run `pnpm test` for API client and drift-check unit tests. To check a running API's OpenAPI contract, use `PHLO_API_BASE_URL=<disposable-api-origin> pnpm test:api-contract`; to check an OpenAPI document exported from a local disposable API, pass its path as the command's argument. The checker verifies that app-required paths and methods remain present.

- **Pipelines, runs, assets, freshness:** Dagster GraphQL (`DAGSTER_URL`)
- **Branches, tags, commits, merges:** Nessie REST API v2 (`NESSIE_URI`)
- **Incidents, members, audit log, settings:** the Postgres metadata DB (`POSTGRES_DSN`)
- **Query:** the engine you use for Iceberg (Trino, DuckDB and so on)
Run `pnpm test:browser` with the disposable API and app preview running to verify production/staging separation, read-only identity and audit screens, and stale branch selection. Playwright Chromium must be installed; `CHROME_PATH` can select an existing Chromium binary.

Mutations such as materialize, merge, promote and invite are currently UI-only. Add them as `createServerFn({ method: 'POST' })` functions and call `router.invalidate()` afterwards.
The app uses typed API adapters for the migrated screens and omits mutations that are unavailable or not yet integrated. Do not add local-only substitutes for Phlo actions or reimplement API permissions, signatures, or idempotency in the UI. Promotion stays unavailable while the staging/promotion API is deferred.

## Charts, graphs and editor

Expand Down Expand Up @@ -101,7 +93,8 @@ npx shadcn@latest add <component>

## Known gaps

- Everything runs on mock data; buttons that change things only update the UI.
- There's no auth yet. The "You" in members is hard-coded as Gareth.
- This is not yet a complete Wave 9 cutover: some unused design components and fixture files remain, and real-service integration/security/browser checks still need to pass before a cutover PR.
- The preview demo uses disposable API responses and is not evidence of production service connectivity, authorization, or multi-replica behavior.
- Several API actions and evidence fields are not exposed in the UI yet; these are shown as unavailable rather than simulated.
- React Flow shows a small attribution link (MIT licence; it can be hidden with a React Flow Pro subscription).
- Possible shared refactors: a `size` prop on `Stat`; PageHeader actions that collapse into a menu on phones; a title slot on PageHeader; an inverse token for the selected filter chip.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"build": "vite build",
"start": "vite preview --port 3000",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test src/lib/data/api/*.test.mjs scripts/architecture-smoke.test.mjs",
"test:browser": "node --test scripts/browser-smoke.test.mjs",
"test:api-contract": "node scripts/check-api-contract.mjs",
"screenshots": "node scripts/screenshot.mjs http://localhost:3000 shots"
},
"dependencies": {
Expand Down
24 changes: 24 additions & 0 deletions scripts/architecture-smoke.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import test from 'node:test'

async function routeFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const nested = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? routeFiles(path) : path.endsWith('.tsx') ? [path] : []
}))
return nested.flat()
}

test('replacement routes use API adapters, not fixtures or ad hoc API/mutation calls', async () => {
const routes = await routeFiles(new URL('../src/routes', import.meta.url).pathname)
for (const file of routes) {
const source = await readFile(file, 'utf8')
if (file.includes('/_app/')) assert.match(source, /from\s+['"][^'"]*\/data\/api\//, `${file} must load data through a Phlo API adapter`)
assert.doesNotMatch(source, /from\s+['"][^'"]*data\/fixtures\//, `${file} must not import demo fixtures directly`)
assert.doesNotMatch(source, /['"]\/api\/(?:v1|legacy)\//, `${file} must not call API URLs directly`)
assert.doesNotMatch(source, /\bfetch\s*\(/, `${file} must route requests through the server API adapters`)
}
})
93 changes: 93 additions & 0 deletions scripts/browser-smoke.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { chromium } from 'playwright'

const baseUrl = process.env.OBSERVATORY_BASE_URL ?? 'http://localhost:3000'

test('replacement routes render separate environment data and read-only admin facts', async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH })
try {
const page = await browser.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))

for (const [env, expected, absent] of [
['prod', 'gold.batch_release_metrics', 'silver.qc_results'],
['staging', 'silver.qc_results', 'gold.batch_release_metrics'],
]) {
const response = await page.goto(new URL(`/assets?env=${env}`, baseUrl).href)
assert.equal(response?.status(), 200, `${env} assets route should load`)
const content = await page.locator('body').innerText()
assert.ok(content.includes(expected), `${env} should display its own asset data`)
assert.ok(!content.includes(absent), `${env} must not display the other environment's asset`)
}

for (const [env, expected, absent] of [
['prod', 'batch-prod-042', 'batch-stage-017'],
['staging', 'batch-stage-017', 'batch-prod-042'],
]) {
await page.goto(new URL(`/query?env=${env}`, baseUrl).href)
await page.locator('.cm-content').fill('SELECT 1;')
await page.getByRole('button', { name: /^Run/ }).click()
await page.getByText(expected, { exact: true }).waitFor({ timeout: 5000 })
assert.ok(!(await page.locator('main').innerText()).includes(absent), `${env} query results must not contain the other environment's row`)
}

for (const path of [
'/?env=prod',
'/staging',
'/incidents?env=prod',
'/incidents/demo-prod-214?env=prod',
'/incidents?env=staging',
'/incidents/demo-stage-007?env=staging',
'/assets?env=prod',
'/assets/gold.batch_release_metrics?env=prod',
'/assets?env=staging',
'/assets/silver.qc_results?env=staging',
'/pipelines?env=prod',
'/pipelines/refresh-release-metrics?env=prod',
'/pipelines/timeline?env=prod',
'/pipelines?env=staging',
'/pipelines/validate-qc-results?env=staging',
'/pipelines/timeline?env=staging',
'/branches?env=prod',
'/branches?env=staging',
'/settings?env=prod',
'/settings/members',
'/settings/audit-log',
]) {
const response = await page.goto(new URL(path, baseUrl).href)
assert.equal(response?.status(), 200, `${path} should render`)
assert.ok((await page.locator('main').innerText()).trim().length > 0, `${path} should contain rendered screen content`)
}

await page.goto(new URL('/incidents?env=staging', baseUrl).href)
const stagingIncidents = await page.locator('main').innerText()
assert.ok(stagingIncidents.includes('demo-stage-007'))
assert.ok(!stagingIncidents.includes('demo-prod-214'))

await page.goto(new URL('/pipelines?env=staging', baseUrl).href)
const stagingPipelines = await page.locator('main').innerText()
assert.ok(stagingPipelines.includes('validate-qc-results'))
assert.ok(!stagingPipelines.includes('refresh-release-metrics'))

await page.goto(new URL('/settings/members', baseUrl).href)
await page.getByText('Identity records are loaded from Phlo.', { exact: false }).waitFor()
const identity = await page.locator('body').innerText()
assert.ok(identity.includes('demo:operator'))
assert.equal(await page.getByRole('button', { name: 'Invite unavailable' }).isDisabled(), true)
assert.ok(identity.includes('demo-automation'))

await page.goto(new URL('/settings/audit-log', baseUrl).href)
await page.getByText('Verified · 0 records', { exact: true }).waitFor()
await page.getByText('No audit records match these filters.', { exact: true }).waitFor()

await page.goto(new URL('/branches?branch=missing-branch', baseUrl).href)
await page.getByText('Branch unavailable', { exact: true }).waitFor()
assert.ok(!(await page.locator('main').innerText()).includes('Head prod-a1b2c3'), 'a stale selection must not silently show the default branch')

assert.deepEqual(errors, [], 'routes should not throw client-side errors')
} finally {
await browser.close()
}
})
Loading