diff --git a/.changeset/effect-sdk-foldkit.md b/.changeset/effect-sdk-foldkit.md new file mode 100644 index 0000000000..0fc83003a8 --- /dev/null +++ b/.changeset/effect-sdk-foldkit.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-sdk": minor +--- + +New `@maple-dev/effect-sdk/foldkit` entry point for Foldkit apps. `MapleFoldkit.make(config).instrument(appConfig)` wraps a `Runtime.makeApplication` config: Command spans are grouped under the page load or navigation that caused them, Commands that finished without a child span (focus, scroll, hover delays) are dropped, Command args export only ids and flags unless allowed by name, runtime crashes are reported with the recent Message tags, and slow `update`, `view` and DOM patch phases become spans. diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml index 0c30740b90..09893faee3 100644 --- a/.github/workflows/deploy-pr-preview.yml +++ b/.github/workflows/deploy-pr-preview.yml @@ -190,6 +190,7 @@ jobs: SMOKE_OUTCOME: ${{ steps.smoke.outcome }} DESTROY_OUTCOME: ${{ steps.destroy.outcome }} WEB_URL: ${{ steps.deploy.outputs.web_url }} + WEB_FOLDKIT_URL: ${{ steps.deploy.outputs.web_foldkit_url }} API_URL: ${{ steps.deploy.outputs.api_url }} SYNC_URL: ${{ steps.deploy.outputs.sync_url }} COMMIT_SHA: ${{ github.event.pull_request.head.sha }} @@ -231,6 +232,9 @@ jobs: echo "| Service | Preview URL |" echo "| :-- | :-- |" printf '| Web | [%s](%s) |\n' "${WEB_URL#https://}" "$WEB_URL" + if [ -n "$WEB_FOLDKIT_URL" ]; then + printf '| Web (Foldkit) | [%s](%s) |\n' "${WEB_FOLDKIT_URL#https://}" "$WEB_FOLDKIT_URL" + fi printf '| API | [%s](%s) |\n' "${API_URL#https://}" "$API_URL" printf '| Electric Sync | [%s](%s) |\n' "${SYNC_URL#https://}" "$SYNC_URL" if [ -n "$INGEST_URL" ]; then diff --git a/.oxlintrc.json b/.oxlintrc.json index 0d16e99132..c8c3aa7154 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -136,6 +136,7 @@ { "files": [ "apps/web/**", + "packages/web-data/**", "apps/cli/**", "packages/ui/**", "packages/domain/**", @@ -170,7 +171,14 @@ // ~460 `try`/`catch` blocks and ~500 non-null assertions between them. // Drop entries from this list as each is converted rather than widening it. { - "files": ["apps/**", "scripts/**", "**/scripts/**", "examples/**", "alchemy.run.ts"], + "files": [ + "apps/**", + "packages/web-data/**", + "scripts/**", + "**/scripts/**", + "examples/**", + "alchemy.run.ts" + ], "rules": { "maple/no-try-catch": "off", "typescript/no-non-null-assertion": "off" diff --git a/alchemy.run.ts b/alchemy.run.ts index 557cc3fb98..9dc5aefd43 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -52,6 +52,7 @@ import { createMapleIngest } from "./apps/ingest/alchemy.run.ts" import Landing from "./apps/landing/src/worker.ts" import LocalUi from "./apps/local-ui/src/worker.ts" import Web from "./apps/web/src/worker.ts" +import WebFoldkit from "./apps/web-foldkit/src/worker.ts" // Infisical defines CLOUDFLARE_DEFAULT_ACCOUNT_ID; alchemy reads CLOUDFLARE_ACCOUNT_ID. if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID) { @@ -340,6 +341,9 @@ export default Alchemy.Stack( // A vite-source Worker: alchemy owns its build and dev server. Binds api as `API`. const web = yield* Effect.provideService(Web, ApiWorker, api) yield* serveWorker("web", web) + // The Foldkit dashboard beside it, on PR previews only (`fk-pr-.maple.dev`). + const webFoldkit = + stage.kind === "pr" ? yield* Effect.provideService(WebFoldkit, ApiWorker, api) : undefined // StaticSites run their production build even on `alchemy dev`, so dev uses // `DEV_PROCESS_APPS` instead. Shared across instances: deployed by `us` alone. @@ -371,6 +375,7 @@ export default Alchemy.Stack( ingestUrl: urls.ingest, electricSyncUrl: urls.electricSync, webUrl: domains.web ? `https://${domains.web}` : "", + webFoldkitUrl: stage.kind === "pr" ? `https://fk-pr-${stage.prNumber}.maple.dev` : "", landingUrl: domains.landing ? `https://${domains.landing}` : "", localUiUrl: domains.local ? `https://${domains.local}` : "", } @@ -379,6 +384,7 @@ export default Alchemy.Stack( yield* Effect.sync(() => appendStepOutputs([ `web_url=${summary.webUrl}`, + `web_foldkit_url=${summary.webFoldkitUrl}`, `api_url=${summary.apiUrl}`, `sync_url=${summary.electricSyncUrl}`, `ingest_url=${ingest && domains.ingest ? `https://${domains.ingest}` : ""}`, @@ -396,6 +402,7 @@ export default Alchemy.Stack( apiWorker: api.workerName, electricSyncWorker: electricSync.workerName, webWorker: web?.workerName, + webFoldkitWorker: webFoldkit?.workerName, landingWorker: landing?.workerName, localUiWorker: localUi?.workerName, alertingWorker: alerting.workerName, diff --git a/apps/landing/project.inlang/.gitignore b/apps/landing/project.inlang/.gitignore index 668473eece..04df3303b0 100644 --- a/apps/landing/project.inlang/.gitignore +++ b/apps/landing/project.inlang/.gitignore @@ -14,10 +14,6 @@ # # Inlang handles the gitignore itself starting with version ^2.5. # -# everything is ignored except settings.json and Paraglide configuration +# everything is ignored except settings.json * -!settings.json -!paraglide.config.js -!paraglide.config.mjs -!paraglide.config.ts -!paraglide.config.cjs \ No newline at end of file +!settings.json \ No newline at end of file diff --git a/apps/local-ui-foldkit/index.html b/apps/local-ui-foldkit/index.html new file mode 100644 index 0000000000..e7ad608d93 --- /dev/null +++ b/apps/local-ui-foldkit/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + Maple Local + + + + +
+ + + diff --git a/apps/local-ui-foldkit/package.json b/apps/local-ui-foldkit/package.json new file mode 100644 index 0000000000..48d2c05526 --- /dev/null +++ b/apps/local-ui-foldkit/package.json @@ -0,0 +1,38 @@ +{ + "name": "@maple/local-ui-foldkit", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port ${PORT:-4329} --host ${HOST:-127.0.0.1}", + "build": "vite build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsrs --noEmit" + }, + "dependencies": { + "@floating-ui/dom": "1.8.0", + "@foldkit/ui": "0.168.0", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@maple/domain": "workspace:*", + "@maple/local-ui": "workspace:*", + "@maple/query-engine": "workspace:*", + "@maple/ui": "workspace:*", + "@maple/web-foldkit": "workspace:*", + "@tanstack/charts": "catalog:charts", + "@tanstack/charts-scales": "catalog:charts", + "effect": "catalog:effect", + "foldkit": "0.168.0", + "sugar-high": "^2.5.1" + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@foldkit/vite-plugin": "0.27.1", + "@tailwindcss/vite": "catalog:tailwind", + "@types/node": "catalog:tooling", + "tailwindcss": "catalog:tailwind", + "tw-animate-css": "catalog:tailwind", + "typescript": "catalog:tooling", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/local-ui-foldkit/src/command.ts b/apps/local-ui-foldkit/src/command.ts new file mode 100644 index 0000000000..6fa976edca --- /dev/null +++ b/apps/local-ui-foldkit/src/command.ts @@ -0,0 +1,100 @@ +import { writeLocalStorage } from "@maple/ui/lib/local-storage" +import { Clock, Duration, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { load, pushUrl, replaceUrl } from "foldkit/navigation" +import { fetchSignalPresence, PRESENCE_STALE_MS } from "./data/signal-presence" +import { RANGE_STORAGE_KEY, Theme } from "./env" +import { Message } from "./message" +import { probeServer } from "./server/probe" + +/** `navigate(path, query, { replace })`: page moves push, in-place filter tweaks replace. */ +export const Navigate = Command.define("NavigateLocal", { + args: { hash: Schema.String, isReplace: Schema.Boolean }, + messages: [Message.CompletedNavigate], + execute: ({ hash, isReplace }) => + (isReplace ? replaceUrl(hash) : pushUrl(hash)).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** A link to another origin (docs) is a full navigation. */ +export const LoadExternal = Command.define("LoadLocalExternal", { + args: { href: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ href }) => load(href).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** `goBack`: history back within the app. */ +export const GoBack = Command.define("GoBackLocal", { + messages: [Message.CompletedNavigate], + execute: Effect.sync(() => { + history.back() + return Message.CompletedNavigate() + }), +}) + +export const RememberRange = Command.define("RememberLocalRange", { + args: { range: Schema.String }, + messages: [Message.CompletedRememberRange], + execute: ({ range }) => + Effect.sync(() => { + writeLocalStorage(RANGE_STORAGE_KEY, range) + return Message.CompletedRememberRange() + }), +}) + +export const ProbeServer = Command.define("ProbeLocalServer", { + args: { apiBase: Schema.String }, + messages: [Message.CompletedProbeServer], + execute: ({ apiBase }) => + Effect.gen(function* () { + const outcome = yield* probeServer(apiBase) + const checkedAtMs = yield* Clock.currentTimeMillis + return Message.CompletedProbeServer({ outcome, checkedAtMs }) + }), +}) + +/** `useSignalPresence`: a read younger than `staleTime` is reused, else the store is asked again. */ +export const FetchSignalPresence = Command.define("FetchLocalSignalPresence", { + args: { apiBase: Schema.String, lastReadAtMs: Schema.NullOr(Schema.Number) }, + messages: [Message.SucceededFetchSignalPresence, Message.FailedFetchSignalPresence, Message.ReusedSignalPresence], + execute: ({ apiBase, lastReadAtMs }) => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis + if (lastReadAtMs !== null && nowMs - lastReadAtMs < PRESENCE_STALE_MS) return Message.ReusedSignalPresence() + return yield* fetchSignalPresence(apiBase).pipe( + Effect.map((result) => Message.SucceededFetchSignalPresence({ result })), + Effect.catch((failure) => Effect.succeed(Message.FailedFetchSignalPresence({ failure }))), + ) + }), +}) + +/** Apply the OS theme the way `setTheme(theme, { persist: false })` does. */ +export const ApplyTheme = Command.define("ApplyLocalTheme", { + args: { theme: Theme }, + messages: [Message.CompletedApplyTheme], + execute: ({ theme }) => + Effect.sync(() => { + const root = document.documentElement + root.classList.remove("light", "dark") + root.classList.add(theme) + root.style.colorScheme = theme + return Message.CompletedApplyTheme() + }), +}) + +/** `SidebarProvider` keeps the open state in a cookie for a week. */ +export const SaveSidebarState = Command.define("SaveLocalSidebarState", { + args: { isOpen: Schema.Boolean }, + messages: [Message.CompletedSaveSidebarState], + execute: ({ isOpen }) => + Effect.sync(() => { + document.cookie = `sidebar_state=${String(isOpen)}; path=/; max-age=${60 * 60 * 24 * 7}` + return Message.CompletedSaveSidebarState() + }), +}) + +export const WaitBeforeProbe =Command.define("WaitBeforeLocalProbe", { + args: { version: Schema.Number, delayMs: Schema.Number }, + messages: [Message.ElapsedProbeDelay], + execute: ({ version, delayMs }) => + Effect.as(Effect.sleep(Duration.millis(delayMs)), Message.ElapsedProbeDelay({ version })), +}) diff --git a/apps/local-ui-foldkit/src/data/cache-store.test.ts b/apps/local-ui-foldkit/src/data/cache-store.test.ts new file mode 100644 index 0000000000..1767af4d80 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/cache-store.test.ts @@ -0,0 +1,50 @@ +import { DateTime, Option } from "effect" +import { describe, expect, test } from "vitest" +import { type CacheEntry, type CacheValue, hashKey, MAX_AGE_MS, newestOlderWindow, parseSaved, serialize } from "./cache-store" + +const NOW = Date.parse("2026-10-01T12:00:00Z") +const entry = (key: ReadonlyArray, updatedAtMs: number, data: CacheValue = []): CacheEntry => ({ key, data, updatedAtMs }) +const mapOf = (...entries: ReadonlyArray) => new Map(entries.map((item) => [hashKey(item.key), item])) + +describe("hashKey", () => { + test("ignores object key order and undefined fields, like React Query", () => { + expect(hashKey(["local", { b: 1, a: undefined, c: "x" }])).toBe(hashKey(["local", { c: "x", b: 1 }])) + }) +}) + +describe("newestOlderWindow", () => { + const filters = { errorsOnly: false } + const current = ["local", "traces", filters, "2026-10-01 11:00:00/2026-10-01 12:00:00"] + + test("picks the newest earlier window of the same length for the same view", () => { + const older = entry(["local", "traces", filters, "2026-10-01 09:00:00/2026-10-01 10:00:00"], NOW - 7_200_000) + const newer = entry(["local", "traces", filters, "2026-10-01 10:00:00/2026-10-01 11:00:00"], NOW - 3_600_000) + expect(newestOlderWindow(mapOf(older, newer), current)).toEqual(Option.some(newer)) + }) + + test("skips other views, other lengths and later windows", () => { + const otherFilters = entry(["local", "traces", { errorsOnly: true }, "2026-10-01 10:00:00/2026-10-01 11:00:00"], NOW) + const longer = entry(["local", "traces", filters, "2026-10-01 05:00:00/2026-10-01 11:00:00"], NOW) + const later = entry(["local", "traces", filters, "2026-10-01 11:30:00/2026-10-01 12:30:00"], NOW) + expect(newestOlderWindow(mapOf(otherFilters, longer, later), current)).toEqual(Option.none()) + }) + + test("a key without a window has no stand-in", () => { + expect(newestOlderWindow(mapOf(entry(["local", "trace", "a"], NOW)), ["local", "trace", "b"])).toEqual(Option.none()) + }) +}) + +describe("saved form", () => { + test("round-trips Effect values and drops entries older than a day", () => { + const startTime = DateTime.makeUnsafe(NOW - 1_000) + const fresh = entry(["local", "trace", "t1"], NOW - 60_000, { rows: [{ startTime, maybe: Option.some(1) }] }) + const stale = entry(["local", "trace", "t2"], NOW - MAX_AGE_MS - 1) + const restored = parseSaved(serialize(mapOf(fresh, stale)), NOW) + expect([...restored.keys()]).toEqual([hashKey(fresh.key)]) + expect(restored.get(hashKey(fresh.key))?.data).toEqual(fresh.data) + }) + + test("unreadable text restores nothing", () => { + expect(parseSaved("{not json", NOW).size).toBe(0) + }) +}) diff --git a/apps/local-ui-foldkit/src/data/cache-store.ts b/apps/local-ui-foldkit/src/data/cache-store.ts new file mode 100644 index 0000000000..6a6ccc81fd --- /dev/null +++ b/apps/local-ui-foldkit/src/data/cache-store.ts @@ -0,0 +1,129 @@ +import { type CacheValue, decodeCacheValue, encodeCacheValue } from "@maple/local-ui/lib/cache-codec" +import { parseClickHouseDateTime } from "@maple/local-ui/lib/time" +import { trySync } from "@maple/ui/lib/try-sync" +import { Array, Option, Order, Schema } from "effect" + +// The pure half of apps/local-ui/src/lib/query-cache.ts: React Query keys and their hash, the +// saved form in localStorage, and the "newest older window" a windowed query opens on. + +/** What the cache codec rebuilds: plain data plus `DateTime.Utc`, `Option` and collections. */ +export type { CacheValue } + +/** A React Query `queryKey` (`["local", "traces", filters, "start/end"]`). */ +export type QueryKey = ReadonlyArray + +/** One result: the key, the hook's data (codec values rebuilt) and when it was fetched. */ +export type CacheEntry = Readonly<{ key: QueryKey; data: CacheValue; updatedAtMs: number }> + +/** By `hashKey(key)`. */ +export type CacheEntries = ReadonlyMap + +/** One per server port, as legacy keys it. */ +export const storageKeyFor = (port: string): string => `maple-local:query-cache:${port}` + +/** Saved results older than this are dropped on load. */ +export const MAX_AGE_MS = 24 * 60 * 60 * 1000 +/** UTF-16 units; leaves room under the ~5M per-origin localStorage quota. */ +const STORAGE_BUDGET = 2_000_000 +const MAX_ENTRIES = 150 + +const isPlainObject = (value: unknown): value is Readonly> => { + if (value === null || typeof value !== "object") return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +/** React Query's `hashKey`: JSON with object keys sorted (undefined fields drop out). */ +export const hashKey = (key: QueryKey): string => + JSON.stringify(key, (_, value: unknown) => + isPlainObject(value) ? Object.fromEntries(Object.keys(value).sort().map((name) => [name, value[name]])) : value, + ) + +/** Epoch ms of a `boundsKey` ("start/end") window, or none for any other key part. */ +const windowOf = (part: unknown): Option.Option> => { + if (typeof part !== "string") return Option.none() + const [start, end, extra] = part.split("/") + if (start === undefined || end === undefined || extra !== undefined) return Option.none() + const startMs = parseClickHouseDateTime(start) + const endMs = parseClickHouseDateTime(end) + return startMs === null || endMs === null ? Option.none() : Option.some({ start: startMs, end: endMs }) +} + +const byNewest = Order.mapInput(Order.flip(Order.Number), (entry: CacheEntry) => entry.updatedAtMs) + +/** + * `newestOlderWindow`: the same key except an earlier window of the same length as its last part. + * Relative ranges re-anchor to the current minute on every page open, so a page would start empty. + */ +export const newestOlderWindow = (entries: CacheEntries, key: QueryKey): Option.Option => + Option.flatMap(windowOf(key.at(-1)), (window) => { + const prefix = hashKey(key.slice(0, -1)) + const older = Array.fromIterable(entries.values()).filter( + (entry) => + entry.key.length === key.length && + hashKey(entry.key.slice(0, -1)) === prefix && + Option.exists( + windowOf(entry.key.at(-1)), + (other) => other.end < window.end && other.end - other.start === window.end - window.start, + ), + ) + return Array.head(Array.sort(older, byNewest)) + }) + +const SavedCache = Schema.Struct({ + version: Schema.Literal(1), + entries: Schema.Array(Schema.Struct({ key: Schema.Array(Schema.Unknown), data: Schema.Unknown, updatedAt: Schema.Number })), +}) +const decodeSaved = Schema.decodeUnknownOption(Schema.fromJsonString(SavedCache)) + +/** `restoreQueryCache`: the saved results younger than a day; an entry the codec cannot rebuild is skipped. */ +export const parseSaved = (text: string, nowMs: number): CacheEntries => + new Map( + Option.match(decodeSaved(text), { + onNone: () => [], + onSome: (saved) => + Array.getSomes( + saved.entries + .filter((entry) => nowMs - entry.updatedAt <= MAX_AGE_MS) + .map((entry) => + trySync((): readonly [string, CacheEntry] => { + const key = entry.key.map(decodeCacheValue) + return [hashKey(key), { key, data: decodeCacheValue(entry.data), updatedAtMs: entry.updatedAt }] + }), + ), + ), + }), + ) + +/** What `restore` would rebuild from `data`: none when the codec cannot encode it (`serialize` skips it). */ +export const asCacheValue = (data: A): Option.Option => Option.map(encodeCacheValue(data), decodeCacheValue) + +/** `serializeQueryCache`: the newest results that fit the budget, in legacy's saved form. */ +export const serialize = (entries: CacheEntries): string => { + const encoded = Array.getSomes( + Array.sort(Array.fromIterable(entries.values()), byNewest).map((entry) => + Option.zipWith(encodeCacheValue(entry.key), encodeCacheValue(entry.data), (key, data) => + JSON.stringify({ key, data, updatedAt: entry.updatedAtMs }), + ), + ), + ) + const kept = encoded.reduce<{ readonly items: ReadonlyArray; readonly size: number }>( + (acc, item) => + acc.items.length >= MAX_ENTRIES || acc.size + item.length > STORAGE_BUDGET + ? acc + : { items: [...acc.items, item], size: acc.size + item.length }, + { items: [], size: 0 }, + ) + return `{"version":1,"entries":[${kept.items.join(",")}]}` +} + +/** The client's `gcTime`: a result nothing has read or written for an hour leaves the cache. */ +export const GC_MS = 60 * 60 * 1000 + +/** `gcTime` applied: the entries used (read or written) within the last hour, by `lastUsedAtMs`. */ +export const dropUnused = ( + entries: CacheEntries, + lastUsedAtMs: ReadonlyMap, + nowMs: number, +): CacheEntries => + new Map(Array.filter(Array.fromIterable(entries), ([hash]) => nowMs - (lastUsedAtMs.get(hash) ?? 0) < GC_MS)) diff --git a/apps/local-ui-foldkit/src/data/cached.test.ts b/apps/local-ui-foldkit/src/data/cached.test.ts new file mode 100644 index 0000000000..6ba8754fd1 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/cached.test.ts @@ -0,0 +1,15 @@ +import { Option, Schema } from "effect" +import { describe, expect, test } from "vitest" +import { cachedAs } from "./cached" + +describe("cachedAs", () => { + const Row = Schema.Struct({ name: Schema.String, count: Schema.Number }) + + test("accepts a legacy result that carries more fields than the slot reads", () => { + expect(cachedAs(Row)({ name: "a", count: 1, extra: true })).toEqual(Option.some({ name: "a", count: 1, extra: true })) + }) + + test("rejects a result of another shape, so the page fetches instead", () => { + expect(cachedAs(Row)({ name: "a", count: "1" })).toEqual(Option.none()) + }) +}) diff --git a/apps/local-ui-foldkit/src/data/cached.ts b/apps/local-ui-foldkit/src/data/cached.ts new file mode 100644 index 0000000000..8f327717c8 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/cached.ts @@ -0,0 +1,67 @@ +import { Clock, Effect, Option, Schema } from "effect" +import type { CacheValue, QueryKey } from "./cache-store" +import { LocalQueryCache } from "./query-cache" + +// What a page's fetch Command does with the result cache: read before fetching, record after. + +/** The client's default `staleTime`: a younger exact result is shown without a refetch. */ +export const STALE_MS = 30_000 + +/** A cached result decoded for a slot; `isFresh` means no fetch follows. */ +export const CachedOf = (data: S) => + Schema.Struct({ data, updatedAtMs: Schema.Number, isStandIn: Schema.Boolean, isFresh: Schema.Boolean }) +export type Cached = Readonly<{ data: A; updatedAtMs: number; isStandIn: boolean; isFresh: boolean }> + +/** The cached data as the slot's type, or none when it has another shape (an older build's). */ +export const cachedAs = + (schema: S) => + (value: CacheValue): Option.Option => + Schema.is(Schema.toType(schema))(value) ? Option.some(value) : Option.none() + +export const readCached = ( + key: QueryKey, + fromCached: (value: CacheValue) => Option.Option, + staleMs = STALE_MS, +): Effect.Effect>, never, LocalQueryCache> => + Effect.gen(function* () { + const hit = yield* (yield* LocalQueryCache).lookup(key) + const nowMs = yield* Clock.currentTimeMillis + return Option.flatMap(hit, (found) => + Option.map(fromCached(found.data), (data) => ({ + data, + updatedAtMs: found.updatedAtMs, + isStandIn: found.isStandIn, + isFresh: !found.isStandIn && nowMs - found.updatedAtMs < staleMs, + })), + ) + }) + +/** + * A fetch Command's body with the cache in front: with `readsCache`, a cached answer comes back + * first (`found`); else the fetch runs and its result is recorded (`succeeded`). + */ +export const withCache = ( + options: Readonly<{ + key: QueryKey + readsCache: boolean + fromCached: (value: CacheValue) => Option.Option + staleMs?: number + toCached: (data: A) => B + fetch: Effect.Effect + found: (cached: Cached) => Found + succeeded: (data: A, fetchedAtMs: number) => Succeeded + }>, +): Effect.Effect => + Effect.gen(function* () { + const maybeCached = options.readsCache ? yield* readCached(options.key, options.fromCached, options.staleMs) : Option.none() + if (Option.isSome(maybeCached)) return options.found(maybeCached.value) + const data = yield* options.fetch + const fetchedAtMs = yield* Clock.currentTimeMillis + yield* recordCached(options.key, options.toCached(data), fetchedAtMs) + return options.succeeded(data, fetchedAtMs) + }) + +export const recordCached =(key: QueryKey, data: A, updatedAtMs: number): Effect.Effect => + Effect.gen(function* () { + yield* (yield* LocalQueryCache).record(key, data, updatedAtMs) + }) diff --git a/apps/local-ui-foldkit/src/data/lanes.ts b/apps/local-ui-foldkit/src/data/lanes.ts new file mode 100644 index 0000000000..fc572eba0f --- /dev/null +++ b/apps/local-ui-foldkit/src/data/lanes.ts @@ -0,0 +1,37 @@ +import { Array, Function } from "effect" +import { Command } from "foldkit" +import { isInFlight, type Slot } from "./slot" + +// React Query aborts a query nothing reads any more (its `signal`), and chDB runs one query at a +// time, so a superseded or abandoned request must not hold up the ones behind it. + +type Outcome = Command.Interruptible.Outcome + +/** An Interrupt's result as is; the root lifts it into its own Message. */ +export const outcomeOf: (outcome: Outcome) => Outcome = Function.identity + +/** One request a page may have in flight: the Command it runs as, the key it answers, how to stop it. */ +export type Lane = Readonly<{ + scope: string + key: string + isInFlight: boolean + stop: () => Command.Command +}> + +/** A slot's lane: `stop` interrupts the slot's own Command under the slot's current key. */ +export const slotLane = (scope: string, slot: Slot, stop: (key: string) => Command.Command): ReadonlyArray => { + const { key } = slot + return key === null ? [] : [{ scope, key, isInFlight: isInFlight(slot), stop: () => stop(key) }] +} + +/** The requests in flight before a step whose key no lane after it still holds: stop each once. */ +export const stopSuperseded = ( + before: ReadonlyArray, + after: ReadonlyArray, + done: (outcome: Outcome) => M, +): ReadonlyArray> => { + const held = new Set(after.map((lane) => `${lane.scope}\n${lane.key}`)) + const stale = before.filter((lane) => lane.isInFlight && !held.has(`${lane.scope}\n${lane.key}`)) + const once = Array.dedupeWith(stale, (a, b) => a.scope === b.scope && a.key === b.key) + return once.map((lane) => Command.mapMessage(lane.stop(), done)) +} diff --git a/apps/local-ui-foldkit/src/data/local-query.ts b/apps/local-ui-foldkit/src/data/local-query.ts new file mode 100644 index 0000000000..01a87be486 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/local-query.ts @@ -0,0 +1,59 @@ +import type { CompiledQuery, CompiledQueryInput } from "@maple/query-engine/ch" +import { executeLocalQuery, LOCAL_ORG_ID, type LocalQueryError } from "@maple/query-engine/local" +import { Duration, Effect, type Option, Schedule, Schema } from "effect" +import type { HttpClient } from "effect/http" +import type { TimeBounds } from "@maple/local-ui/lib/time" + +// apps/local-ui/src/lib/query.ts as Effects: compile, POST `/local/query`, decode the rows. + +/** A failed load as the Model keeps it: the text `ErrorState` shows (`error.message`). */ +export const LoadFailure = Schema.Struct({ message: Schema.String }) +export type LoadFailure = typeof LoadFailure.Type + +const failureOf = (error: { readonly message: string }): LoadFailure => ({ message: error.message }) + +const resolve = (compiled: CompiledQueryInput): Effect.Effect, LoadFailure> => + (Effect.isEffect(compiled) ? compiled : Effect.succeed(compiled)).pipe(Effect.mapError(failureOf)) + +/** + * `shouldRetryLocalQuery` under React Query's default delays: only a request that never got an + * answer is retried, twice, after 1 s and 2 s. + */ +const run = (sql: string, apiBase: string) => + executeLocalQuery(sql, apiBase).pipe( + Effect.retry({ + schedule: Schedule.exponential(Duration.seconds(1)), + times: 2, + while: (error: LocalQueryError) => error._tag === "@maple/query-engine/LocalQueryUnreachable", + }), + Effect.mapError(failureOf), + ) + +/** `executeLocalCompiledQuery`: every row, decoded with the query's own row schema. */ +export const queryRows = ( + compiled: CompiledQueryInput, + apiBase: string, +): Effect.Effect, LoadFailure, HttpClient.HttpClient> => + Effect.gen(function* () { + const query = yield* resolve(compiled) + const rows = yield* run(query.sql, apiBase) + return yield* query.decodeRows(rows).pipe(Effect.mapError(failureOf)) + }) + +/** `executeLocalCompiledFirstRow`. */ +export const queryFirstRow = ( + compiled: CompiledQueryInput, + apiBase: string, +): Effect.Effect, LoadFailure, HttpClient.HttpClient> => + Effect.gen(function* () { + const query = yield* resolve(compiled) + const rows = yield* run(query.sql, apiBase) + return yield* query.decodeFirstRow(rows).pipe(Effect.mapError(failureOf)) + }) + +/** `localParams`: the org and window nearly every local query compiles with. */ +export const localParams = (bounds: TimeBounds) => ({ + orgId: LOCAL_ORG_ID, + startTime: bounds.startTime, + endTime: bounds.endTime, +}) diff --git a/apps/local-ui-foldkit/src/data/query-cache.test.ts b/apps/local-ui-foldkit/src/data/query-cache.test.ts new file mode 100644 index 0000000000..b7d781d39e --- /dev/null +++ b/apps/local-ui-foldkit/src/data/query-cache.test.ts @@ -0,0 +1,55 @@ +import { Effect, Layer, Option } from "effect" +import { TestClock } from "effect/testing" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { LocalQueryCache } from "./query-cache" + +// The cache as the runtime builds it, on a test clock: `gcTime` drops what nothing used for an hour. + +const KEY = ["local", "trace", "t1"] +const OTHER = ["local", "trace", "t2"] + +beforeEach(() => { + vi.stubGlobal("window", { location: new URL("http://127.0.0.1:4318/") }) + vi.stubGlobal("document", { visibilityState: "visible", addEventListener: () => undefined, removeEventListener: () => undefined }) +}) +afterEach(() => vi.unstubAllGlobals()) + +const run = (program: Effect.Effect) => + Effect.runPromise(program.pipe(Effect.provide(LocalQueryCache.layer.pipe(Layer.provideMerge(TestClock.layer()))), Effect.scoped)) + +const isCached = (key: ReadonlyArray) => + Effect.gen(function* () { + return Option.isSome(yield* (yield* LocalQueryCache).lookup(key)) + }) + +describe("LocalQueryCache gcTime", () => { + test("a result nothing reads or writes for an hour is dropped", async () => { + const seen = await run( + Effect.gen(function* () { + const cache = yield* LocalQueryCache + yield* cache.record(KEY, { rows: [1] }, 0) + yield* cache.record(OTHER, { rows: [2] }, 0) + yield* TestClock.adjust("59 minutes") + const kept = yield* isCached(KEY) + yield* TestClock.adjust("59 minutes") + // KEY was read 59 minutes ago; OTHER has been idle for 118. + return { kept, stillKept: yield* isCached(KEY), other: yield* isCached(OTHER) } + }), + ) + expect(seen).toEqual({ kept: true, stillKept: true, other: false }) + }) + + test("a read keeps the result an hour from then, and an idle hour after it drops it", async () => { + const gone = await run( + Effect.gen(function* () { + const cache = yield* LocalQueryCache + yield* cache.record(KEY, { rows: [1] }, 0) + yield* TestClock.adjust("30 minutes") + yield* isCached(KEY) + yield* TestClock.adjust("61 minutes") + return !(yield* isCached(KEY)) + }), + ) + expect(gone).toBe(true) + }) +}) diff --git a/apps/local-ui-foldkit/src/data/query-cache.ts b/apps/local-ui-foldkit/src/data/query-cache.ts new file mode 100644 index 0000000000..c28eb579b2 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/query-cache.ts @@ -0,0 +1,119 @@ +import { localServerPortForLocation } from "@maple/local-ui/lib/constants" +import { readLocalStorage, writeLocalStorage } from "@maple/ui/lib/local-storage" +import { Clock, Context, Duration, Effect, Layer, Option, Ref } from "effect" +import { + asCacheValue, + type CacheEntries, + type CacheValue, + type CacheEntry, + dropUnused, + hashKey, + newestOlderWindow, + parseSaved, + type QueryKey, + serialize, + storageKeyFor, +} from "./cache-store" + +// apps/local-ui/src/lib/query-cache.ts as a runtime resource: every result a page fetched, kept +// so a page opens on the newest one for its view, and saved in localStorage across reloads. + +const SAVE_DELAY_MS = 2_000 + +/** A cached result: exact for the key, or an older window standing in for it. */ +export type CacheHit = Readonly<{ data: CacheValue; updatedAtMs: number; isStandIn: boolean }> + +/** The saved results this page was opened with (Flags: `hasCache` before anything loads). */ +export const readSavedEntries: Effect.Effect = Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis + const key = storageKeyFor(localServerPortForLocation(window.location)) + return Option.match(readLocalStorage(key), { onNone: () => new Map(), onSome: (text) => parseSaved(text, nowMs) }) +}) + +export class LocalQueryCache extends Context.Service< + LocalQueryCache, + { + /** The exact result for `key`, else (windowed keys) its view's newest older window. */ + readonly lookup: (key: QueryKey) => Effect.Effect> + /** A fetch landed: keep it, and save shortly after (`persistQueryCache`). */ + readonly record: (key: QueryKey, data: A, updatedAtMs: number) => Effect.Effect + } +>()("@maple/local-ui-foldkit/LocalQueryCache") { + static readonly layer = Layer.effect( + this, + Effect.gen(function* () { + const storageKey = storageKeyFor(localServerPortForLocation(window.location)) + const restored = yield* readSavedEntries + const entries = yield* Ref.make(restored) + // `gcTime`'s clock per entry: a restored result starts unobserved at load. + const startedAtMs = yield* Clock.currentTimeMillis + const lastUsed = yield* Ref.make>(new Map([...restored.keys()].map((hash) => [hash, startedAtMs]))) + const isSaveScheduled = yield* Ref.make(false) + const scope = yield* Effect.scope + /** Drops what nothing used for an hour; returns what is left. */ + const collect = Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis + const used = yield* Ref.get(lastUsed) + return yield* Ref.updateAndGet(entries, (current) => dropUnused(current, used, nowMs)) + }) + const save = Effect.flatMap(collect, (current) => Effect.sync(() => writeLocalStorage(storageKey, serialize(current)))) + // `visibilitychange` to hidden saves at once: the tab may not come back. + const services = yield* Effect.context() + yield* Effect.acquireRelease( + Effect.sync(() => { + const onHide = () => { + if (document.visibilityState === "hidden") Effect.runSyncWith(services)(save) + } + document.addEventListener("visibilitychange", onHide) + return onHide + }), + (onHide) => Effect.sync(() => document.removeEventListener("visibilitychange", onHide)), + ) + // The browser runtime closes this scope on `beforeunload`, before a reload's hidden event. + yield* Effect.addFinalizer(() => save) + const scheduleSave = Effect.flatMap( + Ref.getAndSet(isSaveScheduled, true), + (wasScheduled) => + wasScheduled + ? Effect.void + : Effect.asVoid( + Effect.forkIn( + Effect.andThen(Effect.sleep(Duration.millis(SAVE_DELAY_MS)), Effect.andThen(Ref.set(isSaveScheduled, false), save)), + scope, + ), + ), + ) + const touch = (hash: string, nowMs: number) => Ref.update(lastUsed, (used) => new Map(used).set(hash, nowMs)) + return { + lookup: (key) => + Effect.gen(function* () { + const current = yield* collect + const hash = hashKey(key) + const toHit = (entry: CacheEntry, isStandIn: boolean): CacheHit => ({ + data: entry.data, + updatedAtMs: entry.updatedAtMs, + isStandIn, + }) + const exact = Option.fromNullishOr(current.get(hash)) + // A page reading its own key observes it; a stand-in is only read (`getQueryData`). + if (Option.isSome(exact)) yield* touch(hash, yield* Clock.currentTimeMillis) + return Option.isSome(exact) + ? Option.some(toHit(exact.value, false)) + : Option.map(newestOlderWindow(current, key), (entry) => toHit(entry, true)) + }), + // Kept as `restore` would rebuild it, so a reload reads the same values back. + record: (key, data, updatedAtMs) => + Option.match(asCacheValue(data), { + onNone: () => Effect.void, + onSome: (value) => + Effect.gen(function* () { + yield* touch(hashKey(key), yield* Clock.currentTimeMillis) + yield* Ref.update(entries, (current) => new Map([...current, [hashKey(key), { key, data: value, updatedAtMs }]])) + yield* collect + yield* scheduleSave + }), + }), + } + }), + ) +} diff --git a/apps/local-ui-foldkit/src/data/shown.ts b/apps/local-ui-foldkit/src/data/shown.ts new file mode 100644 index 0000000000..88fb8965a4 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/shown.ts @@ -0,0 +1,50 @@ +import { Array, Option } from "effect" +import { AsyncData } from "foldkit" +import { STALE_MS } from "./cached" +import { shownOf, type Slot } from "./slot" + +// `useShownData` (apps/local-ui/src/hooks/use-shown-data.ts): what the open page shows from its +// queries, and how old it is. A page's queries are the Slots in its Model (and its panel's). + +/** The page's fields, nested state (the span panel) and keyed state (an error card under its hash). */ +const SLOT_DEPTH = 3 + +const isSlot = (value: unknown): value is Slot => + typeof value === "object" && + value !== null && + "key" in value && + "isPlaceholder" in value && + "isStandIn" in value && + "data" in value && + AsyncData.isAsyncData(value.data) + +/** Every Slot in a page Model, down to keyed nested state. Rows arrays never hold one, so they are skipped. */ +export const slotsOf = (model: unknown, depth = SLOT_DEPTH): ReadonlyArray> => { + if (typeof model !== "object" || model === null || Array.isArray(model) || depth === 0) return [] + return Object.values(model).flatMap((value) => (isSlot(value) ? [value] : slotsOf(value, depth - 1))) +} + +export type ShownData = Readonly<{ + /** Any result is on hand, so the views have something to show without the server. */ + hasData: boolean + /** Fetch time of the oldest result on screen, or null when nothing is shown. */ + shownAsOfMs: number | null + /** Fetch time of the oldest result standing in for one that has not loaded or failed to refresh. */ + staleAsOfMs: number | null + /** A shown result's refresh failed. */ + refreshFailed: boolean +}> + +const minOf = (values: ReadonlyArray): number | null => (values.length === 0 ? null : Math.min(...values)) + +export const shownDataOf = (model: unknown, nowMs: number): ShownData => { + const slots = slotsOf(model) + const shown = Array.getSomes(slots.map(shownOf)) + const stale = shown.filter((item) => item.isStandIn || item.isFailed || (item.isRefreshing && nowMs - item.atMs >= STALE_MS)) + return { + hasData: slots.some((slot) => Option.isSome(AsyncData.getData(slot.data))), + shownAsOfMs: minOf(shown.map((item) => item.atMs)), + staleAsOfMs: minOf(stale.map((item) => item.atMs)), + refreshFailed: shown.some((item) => item.isFailed), + } +} diff --git a/apps/local-ui-foldkit/src/data/signal-presence.ts b/apps/local-ui-foldkit/src/data/signal-presence.ts new file mode 100644 index 0000000000..44546014e6 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/signal-presence.ts @@ -0,0 +1,51 @@ +import { CH } from "@maple/query-engine" +import { boundsForRange, parseClickHouseDateTime, snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import { Array, Clock, Effect, Option, Schema } from "effect" +import type { HttpClient } from "effect/http" +import { type LoadFailure, localParams, queryRows } from "./local-query" + +// apps/local-ui/src/hooks/use-signal-presence.ts: whether the store holds a signal anywhere in the +// widest range, so an empty list can tell "never connected" from "nothing in this window". + +export const TelemetrySignal = Schema.Literals(["traces", "logs", "metrics", "sessions"]) +export type TelemetrySignal = typeof TelemetrySignal.Type + +export const SignalPresence = Schema.Struct({ + signal: Schema.String, + status: Schema.Literals(["present", "absent"]), + lastSeenMs: Schema.NullOr(Schema.Number), +}) +export type SignalPresence = typeof SignalPresence.Type + +export const PresenceResult = Schema.Struct({ + rows: Schema.Array(SignalPresence), + /** The clock at the read, for "last received 3m ago". */ + nowMs: Schema.Number, +}) +export type PresenceResult = typeof PresenceResult.Type + +/** `staleTime: 60_000`: a read younger than this answers every empty state without a request. */ +export const PRESENCE_STALE_MS = 60_000 + +export const fetchSignalPresence = (apiBase: string): Effect.Effect => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis + const bounds = boundsForRange(WIDEST_RANGE, snapToMinute(nowMs)) + const rows = yield* queryRows(CH.compileUnion(CH.signalPresenceQuery(), localParams(bounds)), apiBase) + return { + nowMs, + rows: rows.map( + (row): SignalPresence => + row.count > 0 + ? { signal: row.signal, status: "present", lastSeenMs: parseClickHouseDateTime(row.lastSeen) } + : { signal: row.signal, status: "absent", lastSeenMs: null }, + ), + } + }) + +/** `data?.get(signal) ?? UNKNOWN` (a Map keeps the last row): none while loading, failed or missing. */ +export const presenceOf = ( + maybeResult: Option.Option, + signal: TelemetrySignal, +): Option.Option => + Option.flatMap(maybeResult, (result) => Array.findLast(result.rows, (row) => row.signal === signal)) diff --git a/apps/local-ui-foldkit/src/data/slot.ts b/apps/local-ui-foldkit/src/data/slot.ts new file mode 100644 index 0000000000..4b848f7ca0 --- /dev/null +++ b/apps/local-ui-foldkit/src/data/slot.ts @@ -0,0 +1,116 @@ +import { Option, Result, Schema } from "effect" +import { AsyncData } from "foldkit" +import { LoadFailure } from "./local-query" + +/** + * One React Query `useQuery` as Model data: the `AsyncData` and the key it was last requested + * under. A new key keeps the previous rows on screen (`placeholderData: keepPreviousData`). + */ +export type Slot = Readonly<{ + key: string | null + data: AsyncData.AsyncData + /** React Query's `isPlaceholderData`: the rows on screen are another key's. */ + isPlaceholder: boolean + /** `dataUpdatedAt` of the rows on screen; null where the page does not track it. */ + updatedAtMs: number | null + /** The rows on screen are an older window's, opened from the cache (`standInSince`). */ + isStandIn: boolean +}> + +export const SlotSchema = (dataSchema: Schema.Codec) => + Schema.Struct({ + key: Schema.NullOr(Schema.String), + data: AsyncData.Schema(dataSchema, LoadFailure).schema, + isPlaceholder: Schema.Boolean, + updatedAtMs: Schema.NullOr(Schema.Number), + isStandIn: Schema.Boolean, + }) + +export const idle = (): Slot => ({ key: null, data: AsyncData.Idle(), isPlaceholder: false, updatedAtMs: null, isStandIn: false }) + +/** A key for one query: its name and inputs, as React Query hashes `queryKey`. */ +export const keyOf = (name: string, input: unknown): string => `${name}:${JSON.stringify(input)}` + +/** + * `Some(next)` when the key changed (or `force` refetches): put it in the Model and fetch. + * `None` when the slot already holds or is fetching this key. + */ +export const request = (slot: Slot, key: string, force = false): Option.Option> => { + if (slot.key === key && !force) return Option.none() + const maybePrevious = AsyncData.getData(slot.data) + const data = Option.match(maybePrevious, { + onNone: (): AsyncData.AsyncData => AsyncData.Loading(), + onSome: (previous) => AsyncData.Refreshing({ data: previous }), + }) + const isNewKey = slot.key !== key + return Option.some({ + key, + data, + isPlaceholder: isNewKey && Option.isSome(maybePrevious), + updatedAtMs: slot.updatedAtMs, + isStandIn: !isNewKey && slot.isStandIn, + }) +} + +/** + * Folds a result in only if it answers the slot's current key; a superseded answer is dropped. + * A failed new key drops the placeholder rows (they were another key's); a failed refetch keeps them. + */ +export const accept = (slot: Slot, key: string, result: Result.Result, atMs: number | null = null): Slot => { + if (slot.key !== key) return slot + const data = + slot.isPlaceholder && Result.isFailure(result) + ? AsyncData.fail(result.failure) + : AsyncData.settle(slot.data, result) + return Result.isSuccess(result) + ? { key, data, isPlaceholder: false, updatedAtMs: atMs ?? slot.updatedAtMs, isStandIn: false } + : { ...slot, key, data, isPlaceholder: false } +} + +/** + * The cache answered for the current key before the fetch did: show it (an exact result, or an + * older window standing in), unless a fetch already filled the slot. `isFresh`: no fetch follows. + */ +export const acceptCached = ( + slot: Slot, + key: string, + cached: Readonly<{ data: A; updatedAtMs: number; isStandIn: boolean }>, + isFresh: boolean, +): Slot => { + if (slot.key !== key || (Option.isSome(AsyncData.getData(slot.data)) && !slot.isPlaceholder)) return slot + return { + key, + data: isFresh ? AsyncData.Success({ data: cached.data }) : AsyncData.Refreshing({ data: cached.data }), + isPlaceholder: false, + updatedAtMs: cached.updatedAtMs, + isStandIn: cached.isStandIn, + } +} + +/** What `useShownData` reads off one query on screen. */ +export type Shown = Readonly<{ atMs: number; isStandIn: boolean; isRefreshing: boolean; isFailed: boolean }> + +/** The slot's rows as the stale-data notice sees them: none for placeholders or untracked slots. */ +export const shownOf = (slot: Slot): Option.Option => { + if (slot.isPlaceholder || slot.updatedAtMs === null || Option.isNone(AsyncData.getData(slot.data))) return Option.none() + return Option.some({ + atMs: slot.updatedAtMs, + isStandIn: slot.isStandIn, + isRefreshing: AsyncData.isRefreshing(slot.data), + isFailed: AsyncData.isStale(slot.data), + }) +} + +/** React Query's `isPending`: no data yet, nothing to show. */ +export const isPending = (slot: Slot): boolean => + Option.isNone(AsyncData.getData(slot.data)) && !AsyncData.isFailure(slot.data) + +/** `failedWithoutData`: a failure with nothing to show (a failed refresh keeps its rows). */ +export const failureWithoutData = (slot: Slot): Option.Option => + AsyncData.isFailure(slot.data) ? AsyncData.getError(slot.data) : Option.none() + +/** A fetch for the slot's key is in flight (a first load, a refetch or a new key). */ +export const isInFlight = (slot: Slot): boolean => AsyncData.isLoading(slot.data) || AsyncData.isRefreshing(slot.data) + +/** `fetchNextPage` may extend the rows: they are this key's own and no fetch will replace them. */ +export const canExtend = (slot: Slot): boolean => !slot.isPlaceholder && !isInFlight(slot) diff --git a/apps/local-ui-foldkit/src/entry.ts b/apps/local-ui-foldkit/src/entry.ts new file mode 100644 index 0000000000..0c1bb98ca1 --- /dev/null +++ b/apps/local-ui-foldkit/src/entry.ts @@ -0,0 +1,26 @@ +import "./styles.css" + +import { ResourcesLive } from "./platform/resources" +import { Runtime } from "foldkit" +import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" + +const container = document.getElementById("app") ?? document.body + +Runtime.run( + Runtime.makeApplication({ + Model, + Flags, + init, + update, + view, + subscriptions, + resources: ResourcesLive, + container, + routing: { + onUrlRequest: (request) => Message.ClickedLink({ request }), + onUrlChange: (url) => Message.ChangedUrl({ url }), + }, + devTools: { Message }, + }), + { flags }, +) diff --git a/apps/local-ui-foldkit/src/env.ts b/apps/local-ui-foldkit/src/env.ts new file mode 100644 index 0000000000..b23a580ded --- /dev/null +++ b/apps/local-ui-foldkit/src/env.ts @@ -0,0 +1,60 @@ +import { + isHostedLocation, + localApiBaseForLocation, + localOtlpEndpointForLocation, + localServerPortForLocation, +} from "@maple/local-ui/lib/constants" +import { DEFAULT_RANGE, isRangeKey } from "@maple/local-ui/lib/time" +import { readLocalStorage } from "@maple/ui/lib/local-storage" +import { Effect, Option, Schema } from "effect" +import type { TimeFormat } from "./page/kit/local-time" + +/** + * Where this page reaches its server (apps/local-ui/src/lib/constants.ts), read once from + * `window.location` before init: same origin, or loopback from a hosted origin. + */ +export const LocalEnv = Schema.Struct({ + apiBase: Schema.String, + port: Schema.String, + otlpEndpoint: Schema.String, + isHosted: Schema.Boolean, +}) +export type LocalEnv = typeof LocalEnv.Type + +export const readLocalEnv: Effect.Effect = Effect.sync(() => ({ + apiBase: localApiBaseForLocation(window.location), + port: localServerPortForLocation(window.location), + otlpEndpoint: localOtlpEndpointForLocation(window.location), + isHosted: isHostedLocation(window.location), +})) + +/** `useRange`'s per-browser memory of the last picked range. */ +export const RANGE_STORAGE_KEY = "maple-local:range" + +export const readRememberedRange: Effect.Effect = Effect.sync(() => + Option.getOrElse(Option.filter(readLocalStorage(RANGE_STORAGE_KEY), isRangeKey), () => DEFAULT_RANGE), +) + +/** The shared provider persists the collapsed state in this cookie but leaves reading it to the app. */ +export const readSidebarOpen: Effect.Effect = Effect.sync( + () => !document.cookie.split("; ").includes("sidebar_state=false"), +) + +export const Theme = Schema.Literals(["light", "dark"]) +export type Theme = typeof Theme.Type + +/** Follow the OS theme; dark stays the default when nothing says light. */ +export const readTheme: Effect.Effect = Effect.sync(() => + window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", +) + +/** The locale and zone the timestamps are shown in, read once with the other flags. */ +export const readTimeFormat: Effect.Effect = Effect.sync(() => { + const resolved = new Intl.DateTimeFormat().resolvedOptions() + return { locale: resolved.locale, timeZone: resolved.timeZone } +}) + +/** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ +export const MOBILE_QUERY = "(max-width: 767px)" + +export const readIsMobile: Effect.Effect = Effect.sync(() => window.matchMedia(MOBILE_QUERY).matches) diff --git a/apps/local-ui-foldkit/src/main.ts b/apps/local-ui-foldkit/src/main.ts new file mode 100644 index 0000000000..063ecd67c1 --- /dev/null +++ b/apps/local-ui-foldkit/src/main.ts @@ -0,0 +1,81 @@ +import type { Resources } from "./platform/resources" +import * as Popover from "@maple/web-foldkit/ui/popover" +import { INSTALL_METHODS } from "@maple/local-ui/lib/links" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import * as Toast from "@maple/web-foldkit/ui/toast" +import { Effect, Option, Schema } from "effect" +import { AsyncData, type Runtime } from "foldkit" +import type { Url } from "foldkit/url" +import { ApplyTheme, ProbeServer } from "./command" +import { LocalEnv, readIsMobile, readLocalEnv, readRememberedRange, readSidebarOpen, readTheme, readTimeFormat, Theme } from "./env" +import { TimeFormat } from "./page/kit/local-time" +import type { Message } from "./message" +import { initFilterDrawer, type Model } from "./model" +import { hashOf, locationOf, routeOf } from "./navigation" +import { CONNECT_POPOVER_ID } from "./shell/connect" +import { enterLocation } from "./update-page" +import { placeholder } from "./page/registry" +import { readSavedEntries } from "./data/query-cache" + +export { Message } from "./message" +export { Model } from "./model" +export { subscriptions } from "./subscription" +export { update } from "./update" +export { view } from "./view" + +/** Read before the first render: where the server is, the remembered range and the sidebar. */ +export const Flags = Schema.Struct({ + env: LocalEnv, + rememberedRange: Schema.String, + isSidebarOpen: Schema.Boolean, + isMobile: Schema.Boolean, + theme: Theme, + timeFormat: TimeFormat, + /** Saved results younger than a day: the views stay up even if the server never answers. */ + hasSavedResults: Schema.Boolean, +}) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.all({ + env: readLocalEnv, + rememberedRange: readRememberedRange, + isSidebarOpen: readSidebarOpen, + isMobile: readIsMobile, + theme: readTheme, + timeFormat: readTimeFormat, + hasSavedResults: Effect.map(readSavedEntries, (entries) => entries.size > 0), +}) + +export const init: Runtime.RoutingApplicationInit = (flags, url: Url) => { + const location = locationOf(url) + const model: Model = { + location, + route: routeOf(location), + visited: [hashOf(url)], + isReplacing: false, + env: flags.env, + rememberedRange: flags.rememberedRange, + theme: flags.theme, + timeFormat: flags.timeFormat, + isSidebarOpen: flags.isSidebarOpen, + isSidebarOpenMobile: false, + isMobile: flags.isMobile, + filterDrawer: initFilterDrawer(), + maybeServer: Option.none(), + probe: { isInFlight: true, version: 0 }, + hasIngested: false, + connect: Popover.init(CONNECT_POPOVER_ID, { side: "right", align: "end", initialFocus: "first-tabbable" }), + installTabs: Tabs.init({ idBase: 0, values: INSTALL_METHODS.map((method) => method.id), selected: INSTALL_METHODS[0]?.id ?? "" }), + presence: AsyncData.Idle(), + isPresenceStale: false, + hasCache: flags.hasSavedResults, + copies: {}, + toasts: Toast.init("toasts"), + page: placeholder(""), + } + const entered = enterLocation(model, null) + return { + model: entered.model, + commands: [ApplyTheme({ theme: flags.theme }), ProbeServer({ apiBase: flags.env.apiBase }), ...(entered.commands ?? [])], + } +} diff --git a/apps/local-ui-foldkit/src/message.ts b/apps/local-ui-foldkit/src/message.ts new file mode 100644 index 0000000000..9b54e49e52 --- /dev/null +++ b/apps/local-ui-foldkit/src/message.ts @@ -0,0 +1,45 @@ +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Popover from "@maple/web-foldkit/ui/popover" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import * as Toast from "@maple/web-foldkit/ui/toast" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { Schema } from "effect" +import { Command } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { UrlRequest } from "foldkit/navigation" +import { Url } from "foldkit/url" +import { LoadFailure } from "./data/local-query" +import { PresenceResult } from "./data/signal-presence" +import { Theme } from "./env" +import { PageMessage } from "./page/registry" +import { ProbeOutcome } from "./server/state" + +export const Message = defineMessageUnion({ + ClickedLink: { request: UrlRequest }, + ChangedUrl: { url: Url }, + CompletedNavigate: {}, + CompletedRememberRange: {}, + CompletedProbeServer: { outcome: ProbeOutcome, checkedAtMs: Schema.Number }, + ElapsedProbeDelay: { version: Schema.Number }, + ClickedRetryConnection: {}, + ClickedRetryStaleData: {}, + ClickedToggleSidebar: {}, + PressedSidebarShortcut: {}, + CompletedSaveSidebarState: {}, + ChangedViewport: { isMobile: Schema.Boolean }, + DismissedMobileNav: {}, + GotFilterDrawerMessage: { message: Dialog.Message }, + GotConnectMessage: { message: Popover.Message }, + GotInstallTabsMessage: { message: Tabs.Message }, + ChangedColorScheme: { theme: Theme }, + CompletedApplyTheme: {}, + SucceededFetchSignalPresence: { result: PresenceResult }, + FailedFetchSignalPresence: { failure: LoadFailure }, + ReusedSignalPresence: {}, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, + GotToastMessage: { message: Toast.Message }, + GotPageMessage: { message: PageMessage }, + /** A request the open page superseded or left behind was stopped. */ + CompletedCancelPageFetch: { outcome: Command.Interruptible.Outcome }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/model.ts b/apps/local-ui-foldkit/src/model.ts new file mode 100644 index 0000000000..9493edee47 --- /dev/null +++ b/apps/local-ui-foldkit/src/model.ts @@ -0,0 +1,63 @@ +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Popover from "@maple/web-foldkit/ui/popover" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import * as Toast from "@maple/web-foldkit/ui/toast" +import { Schema } from "effect" +import { AsyncData } from "foldkit" +import { LoadFailure } from "./data/local-query" +import { PresenceResult } from "./data/signal-presence" +import { Copies } from "./page/kit/copies" +import { LocalEnv, Theme } from "./env" +import { TimeFormat } from "./page/kit/local-time" +import { FILTER_DRAWER_ID } from "./page/kit/page-shell" +import { PageModel } from "./page/registry" +import { AppRoute, Location } from "./route" +import { ServerState } from "./server/state" + +/** The server-status poll: the probe in flight, and a version that retires superseded timers. */ +export const Probe = Schema.Struct({ isInFlight: Schema.Boolean, version: Schema.Number }) +export type Probe = typeof Probe.Type + +export const Model = Schema.Struct({ + location: Location, + route: AppRoute, + /** + * Hashes seen in this document (lib/router.ts `visited`), so a detail page's Back returns to + * where it was opened from. A reload starts it over. + */ + visited: Schema.Array(Schema.String), + /** The next URL change replaces the last entry (a filter tweak) instead of adding one. */ + isReplacing: Schema.Boolean, + env: LocalEnv, + /** `useRange`'s remembered range for pages opened without one. */ + rememberedRange: Schema.String, + theme: Theme, + /** The locale and zone timestamps render in (Flags). */ + timeFormat: TimeFormat, + isSidebarOpen: Schema.Boolean, + isSidebarOpenMobile: Schema.Boolean, + /** Below `md` the sidebar is a sheet (`useIsMobile`). */ + isMobile: Schema.Boolean, + /** `PageShell`'s Filters drawer below `md`; a new page starts it closed. */ + filterDrawer: Dialog.Model, + maybeServer: Schema.Option(ServerState), + probe: Probe, + /** `ConnectButton`'s latch: loud until the first telemetry lands, quiet after. */ + hasIngested: Schema.Boolean, + connect: Popover.Model, + /** `InstallCommands` on the disconnected screen. */ + installTabs: Tabs.Model, + /** `useSignalPresence`'s shared query (`["local", "signal-presence"]`). */ + presence: AsyncData.Schema(PresenceResult, LoadFailure).schema, + /** A refresh invalidated the presence read: the next request fetches even inside `staleTime`. */ + isPresenceStale: Schema.Boolean, + /** `useShownData().hasCache`: a result was saved or loaded, so a gone server keeps the views. */ + hasCache: Schema.Boolean, + /** The connect popover's copy buttons. */ + copies: Copies, + toasts: Toast.Model, + page: PageModel, +}) +export type Model = typeof Model.Type + +export const initFilterDrawer = (): Dialog.Model => Dialog.init(FILTER_DRAWER_ID, "sheet") diff --git a/apps/local-ui-foldkit/src/navigation.ts b/apps/local-ui-foldkit/src/navigation.ts new file mode 100644 index 0000000000..f508e6f92e --- /dev/null +++ b/apps/local-ui-foldkit/src/navigation.ts @@ -0,0 +1,27 @@ +import { Option } from "effect" +import type { Url } from "foldkit/url" +import { parseLocation, parseRoute, type AppRoute, type Location } from "./route" + +/** The fragment of a Foldkit `Url`, without its `#`. */ +export const hashOf = (url: Url): string => Option.getOrElse(url.hash, () => "") + +export const locationOf = (url: Url): Location => parseLocation(hashOf(url)) + +export const routeOf = (location: Location): AppRoute => parseRoute(location.path) + +/** + * `trackHashChange` (lib/router.ts): a replace rewrites the last entry, a return to the previous + * hash pops it (Back), anything else is a new entry. + */ +export const trackVisit = ( + visited: ReadonlyArray, + hash: string, + isReplacing: boolean, +): ReadonlyArray => { + if (isReplacing) return [...visited.slice(0, -1), hash] + if (visited.length > 1 && visited[visited.length - 2] === hash) return visited.slice(0, -1) + return [...visited, hash] +} + +/** `canGoBackInApp`: the previous history entry is a page of this app. */ +export const canGoBack = (visited: ReadonlyArray): boolean => visited.length > 1 diff --git a/apps/local-ui-foldkit/src/page/contract.ts b/apps/local-ui-foldkit/src/page/contract.ts new file mode 100644 index 0000000000..2ecbe5c448 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/contract.ts @@ -0,0 +1,63 @@ +import { isRangeKey } from "@maple/local-ui/lib/time" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import type { LocalEnv } from "../env" +import type { TimeFormat } from "./kit/local-time" +import { type Location, queryOf } from "../route" + +/** + * What every page reads from the root: where the server is, the fragment it was opened on and + * the viewer's remembered range. Never copied into a page Model. + */ +export type PageContext = Readonly<{ + env: LocalEnv + location: Location + rememberedRange: string + /** The locale and zone timestamps render in. */ + timeFormat: TimeFormat + /** In-app history exists, so a detail page's Back steps back instead of opening the list. */ + canGoBack: boolean +}> + +/** `rangeFromQuery`: the hash's own `range`, else the viewer's last pick. */ +export const rangeOf = (context: PageContext): string => { + const fromUrl = queryOf(context.location).get("range") + return isRangeKey(fromUrl) ? fromUrl : context.rememberedRange +} + +/** A page never touches the URL or the root; it asks with one of these. */ +export const PageOutMessage = defineMessageUnion({ + /** `navigate(path, query, { replace })`. */ + RequestedNavigation: { hash: Schema.String, isReplace: Schema.Boolean }, + /** `goBack(listPath, query)`: history back when in-app history exists, else to the list. */ + RequestedBack: { fallbackHash: Schema.String }, + /** `setRange`: remember the pick for pages opened without one, then navigate. */ + RememberedRange: { range: Schema.String, hash: Schema.String }, + /** A `SignalEmptyState` is on screen: `useSignalPresence` reads (or reuses) the shared result. */ + RequestedSignalPresence: {}, + /** + * Refresh refetched the page in place (`invalidateQueries(["local"])`): the root re-probes the + * server and marks the signal presence stale, so the next empty state reads it again. + */ + RefetchedInPlace: {}, + /** `PageShell`'s Filters button below `md`: the root opens the drawer around the page's rail. */ + RequestedFilterDrawer: {}, +}) +export type PageOutMessage = typeof PageOutMessage.Type + +export type ParamUpdates = Readonly> + +/** `setParams` (lib/router.ts `useQueryParams`): same path, keys set or (empty) removed. */ +export const withParams = (location: Location, updates: ParamUpdates): string => { + const next = queryOf(location) + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") next.delete(key) + else next.set(key, value) + }) + const search = next.toString() + return `#${location.path}${search ? `?${search}` : ""}` +} + +/** A filter tweak: the same page with new params, replacing the history entry. */ +export const replaceParams = (location: Location, updates: ParamUpdates): PageOutMessage => + PageOutMessage.RequestedNavigation({ hash: withParams(location, updates), isReplace: true }) diff --git a/apps/local-ui-foldkit/src/page/errors/cache.ts b/apps/local-ui-foldkit/src/page/errors/cache.ts new file mode 100644 index 0000000000..623d5bc04c --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/cache.ts @@ -0,0 +1,32 @@ +import { Schema } from "effect" +import type { QueryKey } from "../../data/cache-store" +import { cachedAs } from "../../data/cached" +import { errorsFiltersOf } from "./command" +import { ErrorsFacets, ErrorsSummary, ErrorTrace, ErrorTypeRow } from "./fetch" + +// The errors hooks' React Query keys and data, as the result cache keeps them. + +type Window = Readonly<{ search: string; startTime: string; endTime: string }> + +const windowOf = (window: Window) => `${window.startTime}/${window.endTime}` +const filtersFrom = (window: Window) => errorsFiltersOf(new URLSearchParams(window.search)) + +/** `["local", "errors", part, filters, boundsKey]` for the summary, the types and the facets. */ +export const errorsKey = (part: "summary" | "by-type" | "facets", window: Window): QueryKey => [ + "local", + "errors", + part, + filtersFrom(window), + windowOf(window), +] + +/** `["local", "errors", "traces", fingerprintHash, rootOnly, service, env, boundsKey]`. */ +export const tracesKey = (fingerprintHash: string, window: Window): QueryKey => { + const filters = filtersFrom(window) + return ["local", "errors", "traces", fingerprintHash, filters.rootOnly, filters.service, filters.env, windowOf(window)] +} + +export const summaryFromCached = cachedAs(Schema.NullOr(ErrorsSummary)) +export const byTypeFromCached = cachedAs(Schema.Array(ErrorTypeRow)) +export const facetsFromCached = cachedAs(ErrorsFacets) +export const tracesFromCached = cachedAs(Schema.Array(ErrorTrace)) diff --git a/apps/local-ui-foldkit/src/page/errors/command.ts b/apps/local-ui-foldkit/src/page/errors/command.ts new file mode 100644 index 0000000000..87172974d9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/command.ts @@ -0,0 +1,100 @@ +import { byTypeFromCached, errorsKey, facetsFromCached, summaryFromCached, tracesFromCached, tracesKey } from "./cache" +import { withCache } from "../../data/cached" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { type ErrorsFilters, fetchErrorsByType, fetchErrorsFacets, fetchErrorsSummary, fetchErrorTraces } from "./fetch" +import { Message } from "./message" + +/** The view's filters from the hash query (`ErrorsView`). */ +export const errorsFiltersOf = (query: URLSearchParams): ErrorsFilters => ({ + service: query.get("service") || undefined, + env: query.get("env") || undefined, + rootOnly: query.get("root") === "1", +}) + +/** What a request reads: the hash query (filters), the window and the server. */ +const RequestArgs = { + key: Schema.String, + search: Schema.String, + startTime: Schema.String, + endTime: Schema.String, + apiBase: Schema.String, +} + +const filtersOf = (search: string) => errorsFiltersOf(new URLSearchParams(search)) + +export const ReadClock = Command.define("ReadErrorsClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadErrorsRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + +const same = (data: A) => data + +export const FetchSummary = Command.define("FetchLocalErrorsSummary", { + args: { ...RequestArgs, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchSummary, Message.FailedFetchSummary, Message.FoundCachedSummary], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: errorsKey("summary", { search, startTime, endTime }), + readsCache, + fromCached: summaryFromCached, + toCached: same, + fetch: fetchErrorsSummary(filtersOf(search), { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedSummary({ key, cached }), + succeeded: (summary, fetchedAtMs) => Message.SucceededFetchSummary({ key, summary, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchSummary({ key, failure })))), +}) + +export const FetchByType = Command.define("FetchLocalErrorsByType", { + args: { ...RequestArgs, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchByType, Message.FailedFetchByType, Message.FoundCachedByType], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: errorsKey("by-type", { search, startTime, endTime }), + readsCache, + fromCached: byTypeFromCached, + toCached: same, + fetch: fetchErrorsByType(filtersOf(search), { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedByType({ key, cached }), + succeeded: (rows, fetchedAtMs) => Message.SucceededFetchByType({ key, rows, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchByType({ key, failure })))), +}) + +export const FetchFacets = Command.define("FetchLocalErrorsFacets", { + args: { ...RequestArgs, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchFacets, Message.FailedFetchFacets, Message.FoundCachedFacets], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: errorsKey("facets", { search, startTime, endTime }), + readsCache, + fromCached: facetsFromCached, + toCached: same, + fetch: fetchErrorsFacets(filtersOf(search), { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedFacets({ key, cached }), + succeeded: (facets, fetchedAtMs) => Message.SucceededFetchFacets({ key, facets, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchFacets({ key, failure })))), +}) + +export const FetchTraces = Command.define("FetchLocalErrorTraces", { + args: { ...RequestArgs, fingerprintHash: Schema.String, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTraces, Message.FailedFetchTraces, Message.FoundCachedTraces], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, fingerprintHash, search, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: tracesKey(fingerprintHash, { search, startTime, endTime }), + readsCache, + fromCached: tracesFromCached, + toCached: same, + fetch: fetchErrorTraces(fingerprintHash, filtersOf(search), { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedTraces({ fingerprintHash, key, cached }), + succeeded: (traces, fetchedAtMs) => Message.SucceededFetchTraces({ fingerprintHash, key, traces, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTraces({ fingerprintHash, key, failure })))), +}) diff --git a/apps/local-ui-foldkit/src/page/errors/errors.scene.test.ts b/apps/local-ui-foldkit/src/page/errors/errors.scene.test.ts new file mode 100644 index 0000000000..b98b32e82c --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/errors.scene.test.ts @@ -0,0 +1,88 @@ +import { DateTime, Option } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../contract" +import { PageOutMessage } from "../contract" +import type { ErrorTypeRow } from "./fetch" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T12:00:00Z")) +const env = { apiBase: "", port: "4318", otlpEndpoint: "http://localhost:4318", isHosted: false } +const contextAt = (search: string): PageContext => ({ + env, + location: { path: "/errors", search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +}) +const context = contextAt("") +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const inputs = { context, presenceOf: () => Option.none(), presenceNowMs: 0, clockMs: 0, hasNewDataSince: () => false } +const view = withViewInputs(pageView, inputs)() + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +const row = (fingerprintHash: string, errorLabel: string): ErrorTypeRow => ({ + fingerprintHash, + errorLabel, + sampleMessage: "timed out acquiring a connection", + count: 8, + affectedServicesCount: 1, + serviceNames: ["payment-svc"], + lastSeen: DateTime.makeUnsafe(NOW - 60_000), +}) + +const entered = () => pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + +const loaded = (rows: ReadonlyArray): Model => { + const model = entered().model + return pageUpdate(model, Message.SucceededFetchByType({ key: model.byType.key ?? "", rows, fetchedAtMs: NOW })).model +} + +describe("local errors", () => { + test("entry reads the clock, then asks for the summary, the types and the facets, and the presence", () => { + expect(names(init(context).commands)).toContain("ReadErrorsClock") + const step = entered() + expect(names(step.commands)).toEqual(["FetchLocalErrorsSummary", "FetchLocalErrorsByType", "FetchLocalErrorsFacets"]) + expect(step.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + }) + + test("an answer for a superseded filter is dropped", () => { + const first = entered().model + const filtered = update(first, Message.CompletedReadClock({ nowMs: NOW }), contextAt("service=checkout-api")).model + const dropped = pageUpdate( + filtered, + Message.SucceededFetchByType({ key: first.byType.key ?? "", rows: [row("a", "Stale")], fetchedAtMs: NOW }), + ) + expect(dropped.model.byType).toEqual(filtered.byType) + }) + + test("no errors and no telemetry shows the unknown-presence empty state", () => { + scene({ update: pageUpdate, view }, given(loaded([])), expectView(text("No errors found")).toExist()) + }) + + test("a card opens to its traces, asked for once", () => { + const model = loaded([row("fp1", "ConnectionTimeout")]) + const opened = pageUpdate(model, Message.ClickedErrorType({ fingerprintHash: "fp1" })) + expect(names(opened.commands)).toEqual(["FetchLocalErrorTraces"]) + const key = opened.model.cards.fp1?.traces.key ?? "" + const answered = pageUpdate(opened.model, Message.SucceededFetchTraces({ fingerprintHash: "fp1", key, traces: [], fetchedAtMs: 0 })) + scene( + { update: pageUpdate, view }, + given(answered.model), + expectView(role("button", { name: /ConnectionTimeout/ })).toHaveAttr("aria-expanded", "true"), + expectView(text("No traces found for this error in the selected range.")).toExist(), + ) + const closed = pageUpdate(answered.model, Message.ClickedErrorType({ fingerprintHash: "fp1" })) + const reopened = pageUpdate(closed.model, Message.ClickedErrorType({ fingerprintHash: "fp1" })) + expect(names(reopened.commands)).toEqual([]) + }) + + test("last seen reads the root's newer clock, as legacy reads the time at each render", () => { + const later = withViewInputs(pageView, { ...inputs, clockMs: NOW + 20 * 60_000 })() + scene({ update: pageUpdate, view: later }, given(loaded([row("fp1", "ConnectionTimeout")])), expectView(text("21m ago")).toExist()) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/errors/fetch.ts b/apps/local-ui-foldkit/src/page/errors/fetch.ts new file mode 100644 index 0000000000..090c918267 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/fetch.ts @@ -0,0 +1,129 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { Effect, Option, Schema } from "effect" +import { localParams, queryFirstRow, queryRows } from "../../data/local-query" + +// The queries of apps/local-ui/src/hooks/use-local-errors.ts. + +export interface ErrorsFilters { + readonly service?: string + readonly env?: string + readonly rootOnly?: boolean +} + +const sharedFilters = (filters: ErrorsFilters) => ({ + services: filters.service ? [filters.service] : undefined, + deploymentEnvs: filters.env ? [filters.env] : undefined, +}) + +export const ErrorsSummary = Schema.Struct({ + totalErrors: Schema.Number, + errorRate: Schema.Number, + affectedTracesCount: Schema.Number, +}) +export type ErrorsSummary = typeof ErrorsSummary.Type + +export const ErrorTypeRow = Schema.Struct({ + fingerprintHash: Schema.String, + errorLabel: Schema.String, + sampleMessage: Schema.String, + count: Schema.Number, + affectedServicesCount: Schema.Number, + serviceNames: Schema.Array(Schema.String), + lastSeen: Schema.DateTimeUtc, +}) +export type ErrorTypeRow = typeof ErrorTypeRow.Type + +export const FacetOption = Schema.Struct({ name: Schema.String, count: Schema.Number }) + +export const ErrorsFacets = Schema.Struct({ services: Schema.Array(FacetOption), environments: Schema.Array(FacetOption) }) +export type ErrorsFacets = typeof ErrorsFacets.Type + +export const ErrorTrace = Schema.Struct({ + traceId: Schema.String, + startTime: Schema.DateTimeUtc, + durationMicros: Schema.Number, + spanCount: Schema.Number, + rootSpanName: Schema.String, + errorSpanId: Schema.String, + errorSpanName: Schema.String, + errorServiceName: Schema.String, +}) +export type ErrorTrace = typeof ErrorTrace.Type + +/** `useLocalErrorsSummary`: the headline stats (error_events and service_usage). */ +export const fetchErrorsSummary = (filters: ErrorsFilters, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryFirstRow( + CH.compile(CH.errorsSummaryQuery({ ...sharedFilters(filters), rootOnly: filters.rootOnly }), localParams(bounds)), + apiBase, + ), + (row): ErrorsSummary | null => + Option.match(row, { + onNone: () => null, + onSome: (found) => ({ + totalErrors: found.totalErrors, + errorRate: found.errorRate, + affectedTracesCount: found.affectedTracesCount, + }), + }), + ) + +/** `useLocalErrorsByType`: fingerprint-grouped error types, most frequent first. */ +export const fetchErrorsByType = (filters: ErrorsFilters, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows( + CH.compile( + CH.errorsByTypeQuery({ ...sharedFilters(filters), rootOnly: filters.rootOnly, limit: 50 }), + localParams(bounds), + ), + apiBase, + ), + (rows): ReadonlyArray => + rows.map((row) => ({ + fingerprintHash: row.fingerprintHash, + errorLabel: row.errorLabel, + sampleMessage: row.sampleMessage, + count: row.count, + affectedServicesCount: row.affectedServicesCount, + serviceNames: row.serviceNames, + lastSeen: row.lastSeen, + })), + ) + +/** `useLocalErrorsFacets`: each section counts under the other filters but not its own. */ +export const fetchErrorsFacets = (filters: ErrorsFilters, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows( + CH.compileUnion(CH.errorsFacetsQuery({ ...sharedFilters(filters), rootOnly: filters.rootOnly }), localParams(bounds)), + apiBase, + ), + (rows): ErrorsFacets => { + const pick = (facetType: string) => + rows.filter((r) => r.facetType === facetType).map((r) => ({ name: r.name, count: r.count })) + return { services: pick("service"), environments: pick("environment") } + }, + ) + +/** `useLocalErrorTraces`: the most recently errored traces of one fingerprint. */ +export const fetchErrorTraces = (fingerprintHash: string, filters: ErrorsFilters, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows( + CH.compile( + CH.errorDetailTracesQuery({ fingerprintHash, rootOnly: filters.rootOnly, ...sharedFilters(filters), limit: 10 }), + localParams(bounds), + ), + apiBase, + ), + (rows): ReadonlyArray => + rows.map((row) => ({ + traceId: row.traceId, + startTime: row.startTime, + durationMicros: row.durationMicros, + spanCount: row.spanCount, + rootSpanName: row.rootSpanName, + errorSpanId: row.errorSpanId, + errorSpanName: row.errorSpanName, + errorServiceName: row.errorServiceName, + })), + ) diff --git a/apps/local-ui-foldkit/src/page/errors/index.ts b/apps/local-ui-foldkit/src/page/errors/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/errors/lanes.ts b/apps/local-ui-foldkit/src/page/errors/lanes.ts new file mode 100644 index 0000000000..9cddc9a595 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/lanes.ts @@ -0,0 +1,13 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchByType, FetchFacets, FetchSummary, FetchTraces } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`), each expanded card's traces too. */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchSummary.name, model.summary, (key) => FetchSummary.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchByType.name, model.byType, (key) => FetchByType.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchFacets.name, model.facets, (key) => FetchFacets.Interrupt({ key }, outcomeOf)), + ...Object.values(model.cards).flatMap((card) => + slotLane(FetchTraces.name, card.traces, (key) => FetchTraces.Interrupt({ key }, outcomeOf)), + ), +] diff --git a/apps/local-ui-foldkit/src/page/errors/load.ts b/apps/local-ui-foldkit/src/page/errors/load.ts new file mode 100644 index 0000000000..739bdec188 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/load.ts @@ -0,0 +1,80 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, type TimeBounds } from "@maple/local-ui/lib/time" +import { Array, Option, Record } from "effect" +import type { Command } from "foldkit" +import { idle, keyOf, request } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, rangeOf } from "../contract" +import { errorsFiltersOf, FetchByType, FetchFacets, FetchSummary, FetchTraces } from "./command" +import type { Message } from "./message" +import type { Card, Model } from "./model" + +type Commands = ReadonlyArray> +type Step = Readonly<{ model: Model; commands?: Commands }> + +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +const boundsKey = (bounds: TimeBounds) => `${bounds.startTime}/${bounds.endTime}` + +export const cardOf = (model: Model, fingerprintHash: string): Card => + Option.getOrElse(Record.get(model.cards, fingerprintHash), () => ({ isExpanded: false, traces: idle() })) + +/** `useLocalErrorTraces` for one open card, under the view's filters and window. */ +export const loadCard = (model: Model, context: PageContext, fingerprintHash: string, force = false): Step => { + const card = cardOf(model, fingerprintHash) + const maybeBounds = boundsOf(model, context) + if (!card.isExpanded || Option.isNone(maybeBounds)) return { model } + const bounds = maybeBounds.value + const { rootOnly, service, env } = errorsFiltersOf(queryOf(context.location)) + const key = keyOf("error-traces", { + fingerprintHash, + rootOnly, + service: service ?? null, + env: env ?? null, + bounds: boundsKey(bounds), + }) + return Option.match(request(card.traces, key, force), { + onNone: () => ({ model }), + onSome: (traces) => ({ + model: { ...model, cards: Record.set(model.cards, fingerprintHash, { ...card, traces }) }, + commands: [ + FetchTraces({ key, fingerprintHash, search: context.location.search, ...bounds, apiBase: context.env.apiBase, readsCache: card.traces.key !== key }), + ], + }), + }) +} + +/** The summary, the error types and the facets, then every open card's traces. */ +export const load = (model: Model, context: PageContext, force = false): Step => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const filters = errorsFiltersOf(queryOf(context.location)) + const identity = { filters, bounds: boundsKey(bounds) } + const args = { search: context.location.search, ...bounds, apiBase: context.env.apiBase } + const summaryKey = keyOf("errors-summary", identity) + const byTypeKey = keyOf("errors-by-type", identity) + const facetsKey = keyOf("errors-facets", identity) + const summary = request(model.summary, summaryKey, force) + const byType = request(model.byType, byTypeKey, force) + const facets = request(model.facets, facetsKey, force) + const base: Step = { + model: { + ...model, + summary: Option.getOrElse(summary, () => model.summary), + byType: Option.getOrElse(byType, () => model.byType), + facets: Option.getOrElse(facets, () => model.facets), + }, + commands: Array.getSomes([ + Option.map(summary, () => FetchSummary({ key: summaryKey, ...args, readsCache: model.summary.key !== summaryKey })), + Option.map(byType, () => FetchByType({ key: byTypeKey, ...args, readsCache: model.byType.key !== byTypeKey })), + Option.map(facets, () => FetchFacets({ key: facetsKey, ...args, readsCache: model.facets.key !== facetsKey })), + ]), + } + return Object.keys(model.cards).reduce((step, fingerprintHash) => { + const card = loadCard(step.model, context, fingerprintHash, force) + return { model: card.model, commands: [...(step.commands ?? []), ...(card.commands ?? [])] } + }, base) + }, + }) diff --git a/apps/local-ui-foldkit/src/page/errors/message.ts b/apps/local-ui-foldkit/src/page/errors/message.ts new file mode 100644 index 0000000000..ae1156dea3 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/message.ts @@ -0,0 +1,40 @@ +import { CachedOf } from "../../data/cached" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "../../data/local-query" +import { ErrorsFacets, ErrorsSummary, ErrorTrace, ErrorTypeRow } from "./fetch" + +export const FacetParam = Schema.Literals(["service", "env"]) +export type FacetParam = typeof FacetParam.Type + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchSummary: { key: Schema.String, summary: Schema.NullOr(ErrorsSummary), fetchedAtMs: Schema.Number }, + FailedFetchSummary: { key: Schema.String, failure: LoadFailure }, + /** The result cache held a key (or an older window of its view) before the fetch ran. */ + FoundCachedSummary: { key: Schema.String, cached: CachedOf(Schema.NullOr(ErrorsSummary)) }, + SucceededFetchByType: { key: Schema.String, rows: Schema.Array(ErrorTypeRow), fetchedAtMs: Schema.Number }, + FailedFetchByType: { key: Schema.String, failure: LoadFailure }, + FoundCachedByType: { key: Schema.String, cached: CachedOf(Schema.Array(ErrorTypeRow)) }, + SucceededFetchFacets: { key: Schema.String, facets: ErrorsFacets, fetchedAtMs: Schema.Number }, + FailedFetchFacets: { key: Schema.String, failure: LoadFailure }, + FoundCachedFacets: { key: Schema.String, cached: CachedOf(ErrorsFacets) }, + ClickedErrorType: { fingerprintHash: Schema.String }, + SucceededFetchTraces: { fingerprintHash: Schema.String, key: Schema.String, traces: Schema.Array(ErrorTrace), fetchedAtMs: Schema.Number }, + FoundCachedTraces: { fingerprintHash: Schema.String, key: Schema.String, cached: CachedOf(Schema.Array(ErrorTrace)) }, + FailedFetchTraces: { fingerprintHash: Schema.String, key: Schema.String, failure: LoadFailure }, + ClickedRetryTraces: { fingerprintHash: Schema.String }, + ClickedRefresh: {}, + ClickedOpenFilters: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ToggledRootOnly: {}, + ToggledFacetOption: { param: FacetParam, name: Schema.String }, + ClickedClearFilters: {}, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + GotFilterSectionsMessage: { message: FilterSections.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/errors/model.ts b/apps/local-ui-foldkit/src/page/errors/model.ts new file mode 100644 index 0000000000..ba9188dea4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/model.ts @@ -0,0 +1,30 @@ +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" +import { ErrorsFacets, ErrorsSummary, ErrorTrace, ErrorTypeRow } from "./fetch" + +/** One `ErrorTypeCard`'s own state: open or not, and its traces query (kept while closed). */ +export const Card = Schema.Struct({ + isExpanded: Schema.Boolean, + traces: SlotSchema(Schema.Array(ErrorTrace)), +}) +export type Card = typeof Card.Type + +export const Model = Schema.TaggedStruct("ErrorsPage", { + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + /** The clock at the last read, for relative times. */ + nowMs: Schema.Number, + summary: SlotSchema(Schema.NullOr(ErrorsSummary)), + byType: SlotSchema(Schema.Array(ErrorTypeRow)), + /** `dataUpdatedAt` of the error types, for the "New data" pill. */ + byTypeUpdatedAtMs: Schema.Number, + facets: SlotSchema(ErrorsFacets), + /** Cards by fingerprint hash, as React keys them. */ + cards: Schema.Record(Schema.String, Card), + sections: FilterSections.Model, + isRefreshing: Schema.Boolean, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/errors/update.ts b/apps/local-ui-foldkit/src/page/errors/update.ts new file mode 100644 index 0000000000..461ca3ccaa --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/update.ts @@ -0,0 +1,183 @@ +import type { Cached } from "../../data/cached" +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Option, Record, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { accept, idle, acceptCached } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage, replaceParams, withParams } from "../contract" +import { updateCopy } from "../kit/copies" +import { ReadClock, ReadRefreshClock, FetchByType, FetchFacets, FetchSummary, FetchTraces } from "./command" +import type { ErrorTrace, ErrorTypeRow } from "./fetch" +import type { LoadFailure } from "../../data/local-query" +import { cardOf, load, loadCard, boundsOf } from "./load" +import { Message } from "./message" +import type { Model } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +export const init = ( + _context: PageContext, +): Readonly<{ model: Model; commands: ReadonlyArray> }> => ({ + model: { + _tag: "ErrorsPage", + maybeAnchorMs: Option.none(), + nowMs: 0, + summary: idle(), + byType: idle(), + byTypeUpdatedAtMs: 0, + facets: idle(), + cards: {}, + sections: FilterSections.init(), + isRefreshing: false, + copies: {}, + }, + commands: [ReadClock(), Command.mapMessage(FilterSections.ReadRemembered(), toSections)], +}) + +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => load(model, context) + +const navigate = (model: Model, context: PageContext, updates: Record): UpdateReturn => ({ + model, + outMessage: replaceParams(context.location, updates), +}) + +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + const slots = [model.summary.data, model.byType.data, model.facets.data, ...Object.values(model.cards).map((card) => card.traces.data)] + return model.isRefreshing && !slots.some(isLoading) ? { ...model, isRefreshing: false } : model +} + +/** Cards are keyed by fingerprint: a type no longer listed unmounts, and its state goes with it. */ +const pruneCards = (model: Model, rows: ReadonlyArray): Model => { + const listed = new Set(rows.map((row) => row.fingerprintHash)) + return { ...model, cards: Record.filter(model.cards, (_, fingerprintHash) => listed.has(fingerprintHash)) } +} + +/** `ErrorTypeCard`'s toggle: opening it asks for its traces (a held answer is reused). */ +const toggleCard = (model: Model, context: PageContext, fingerprintHash: string): UpdateReturn => { + const card = cardOf(model, fingerprintHash) + const next = { ...model, cards: Record.set(model.cards, fingerprintHash, { ...card, isExpanded: !card.isExpanded }) } + return loadCard(next, context, fingerprintHash) +} + +const settleTraces = ( + model: Model, + fingerprintHash: string, + key: string, + result: Result.Result, LoadFailure>, + atMs: number | null = null, +): Model => { + const card = cardOf(model, fingerprintHash) + return Record.has(model.cards, fingerprintHash) + ? settleRefresh({ ...model, cards: Record.set(model.cards, fingerprintHash, { ...card, traces: accept(card.traces, key, result, atMs) }) }) + : model +} + +type Slotted = "summary" | "byType" | "facets" + +/** A cached result is on screen: fetch the real one (`readsCache: false`), unless it is fresh. */ +const refetchAfterCache = (model: Model, context: PageContext, which: Slotted, isFresh: boolean): UpdateReturn => { + const maybeBounds = boundsOf(model, context) + if (isFresh || Option.isNone(maybeBounds)) return { model: settleRefresh(model) } + const args = { key: model[which].key ?? "", search: context.location.search, ...maybeBounds.value, apiBase: context.env.apiBase, readsCache: false } + const command = which === "summary" ? FetchSummary(args) : which === "byType" ? FetchByType(args) : FetchFacets(args) + return { model: settleRefresh(model), commands: [command] } +} + +/** A card's cached traces: shown, then fetched unless fresh. */ +const cardFromCache = (model: Model, context: PageContext, fingerprintHash: string, key: string, cached: Cached>): UpdateReturn => { + const card = cardOf(model, fingerprintHash) + const maybeBounds = boundsOf(model, context) + if (!Record.has(model.cards, fingerprintHash) || card.traces.key !== key || Option.isNone(maybeBounds)) return { model } + const next = { ...model, cards: Record.set(model.cards, fingerprintHash, { ...card, traces: acceptCached(card.traces, key, cached, cached.isFresh) }) } + const fetch = FetchTraces({ key, fingerprintHash, search: context.location.search, ...maybeBounds.value, apiBase: context.env.apiBase, readsCache: false }) + return { model: settleRefresh(next), commands: cached.isFresh ? [] : [fetch] } +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadClock: ({ nowMs }) => { + const loaded = load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context) + // `ErrorsView` reads `useSignalPresence("traces")` on mount, empty or not. + return { ...loaded, outMessage: PageOutMessage.RequestedSignalPresence() } + }, + SucceededFetchSummary: ({ key, summary, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, summary: accept(model.summary, key, Result.succeed(summary), fetchedAtMs) }) }), + FoundCachedSummary: ({ key, cached }) => + model.summary.key === key + ? refetchAfterCache({ ...model, summary: acceptCached(model.summary, key, cached, cached.isFresh) }, context, "summary", cached.isFresh) + : { model }, + FoundCachedByType: ({ key, cached }) => + model.byType.key === key + ? refetchAfterCache( + pruneCards({ ...model, byType: acceptCached(model.byType, key, cached, cached.isFresh), byTypeUpdatedAtMs: cached.updatedAtMs }, cached.data), + context, + "byType", + cached.isFresh, + ) + : { model }, + FoundCachedFacets: ({ key, cached }) => + model.facets.key === key + ? refetchAfterCache({ ...model, facets: acceptCached(model.facets, key, cached, cached.isFresh) }, context, "facets", cached.isFresh) + : { model }, + FoundCachedTraces: ({ fingerprintHash, key, cached }) => cardFromCache(model, context, fingerprintHash, key, cached), + FailedFetchSummary: ({ key, failure }) => + ({ model: settleRefresh({ ...model, summary: accept(model.summary, key, Result.fail(failure)) }) }), + SucceededFetchByType: ({ key, rows, fetchedAtMs }) => { + if (model.byType.key !== key) return { model } + const next = { ...model, byType: accept(model.byType, key, Result.succeed(rows), fetchedAtMs), byTypeUpdatedAtMs: fetchedAtMs } + return { model: settleRefresh(pruneCards(next, rows)) } + }, + FailedFetchByType: ({ key, failure }) => { + const byType = accept(model.byType, key, Result.fail(failure)) + // The error state replaces the list, unmounting every card. + const cards = AsyncData.isFailure(byType.data) ? {} : model.cards + return { model: settleRefresh({ ...model, byType, cards }) } + }, + SucceededFetchFacets: ({ key, facets, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, facets: accept(model.facets, key, Result.succeed(facets), fetchedAtMs) }) }), + FailedFetchFacets: ({ key, failure }) => + ({ model: settleRefresh({ ...model, facets: accept(model.facets, key, Result.fail(failure)) }) }), + ClickedErrorType: ({ fingerprintHash }) => toggleCard(model, context, fingerprintHash), + SucceededFetchTraces: ({ fingerprintHash, key, traces, fetchedAtMs }) => + ({ model: settleTraces(model, fingerprintHash, key, Result.succeed(traces), fetchedAtMs) }), + FailedFetchTraces: ({ fingerprintHash, key, failure }) => + ({ model: settleTraces(model, fingerprintHash, key, Result.fail(failure)) }), + ClickedRetryTraces: ({ fingerprintHash }) => loadCard(model, context, fingerprintHash, true), + ClickedOpenFilters: () => ({ model, outMessage: PageOutMessage.RequestedFilterDrawer() }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ToggledRootOnly: () => + navigate(model, context, { root: queryOf(context.location).get("root") === "1" ? null : "1" }), + ToggledFacetOption: ({ param, name }) => + navigate(model, context, { [param]: queryOf(context.location).get(param) === name ? null : name }), + ClickedClearFilters: () => navigate(model, context, { service: null, env: null, root: null }), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + GotFilterSectionsMessage: ({ message: child }) => { + const next = FilterSections.update(model.sections, child) + return { model: { ...model, sections: next.model }, commands: Command.mapMessages(next.commands ?? [], toSections) } + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/errors/view-card.ts b/apps/local-ui-foldkit/src/page/errors/view-card.ts new file mode 100644 index 0000000000..9d8ddd798a --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/view-card.ts @@ -0,0 +1,88 @@ +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { ErrorTypeRow } from "./fetch" +import { cardTraces, relativeTime } from "./view-traces" +import { Message } from "./message" +import type { Card } from "./model" + +// `ErrorTypeCard` (apps/local-ui/src/views/errors-view.tsx): the header toggles the traces panel. + +type H = HtmlBuilder + +const services = (h: H, row: ErrorTypeRow): ReadonlyArray => + row.serviceNames.length > 0 + ? [ + ...row.serviceNames.map((serviceName) => + badge(h, { variant: "outline", size: "xs", mono: true, className: "gap-1.5" }, [ + serviceDot(h, { serviceName }), + serviceName, + ]), + ), + ...(row.affectedServicesCount > row.serviceNames.length + ? [ + h.span( + [h.Class("shrink-0 text-xs text-muted-foreground")], + ["+", String(row.affectedServicesCount - row.serviceNames.length), " more"], + ), + ] + : []), + ] + : [ + h.span( + [h.Class("shrink-0 text-xs text-muted-foreground")], + [row.affectedServicesCount === 1 ? "1 service" : `${row.affectedServicesCount} services`], + ), + ] + +const header = (h: H, row: ErrorTypeRow, card: Card, panelId: string, nowMs: number): Html => + h.button( + [ + h.Type("button"), + h.OnClick(Message.ClickedErrorType({ fingerprintHash: row.fingerprintHash })), + h.AriaExpanded(card.isExpanded), + h.AriaControls(panelId), + h.Class("flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/40"), + ], + [ + icon(h, "CircleWarningIcon", { className: "size-4 shrink-0 text-severity-error" }), + h.span( + [h.Class("min-w-0 flex-1")], + [ + h.span( + [h.Class("flex flex-wrap items-baseline gap-x-2 gap-y-1")], + [h.span([h.Class("truncate text-sm font-medium")], [row.errorLabel || "Unknown Error"]), ...services(h, row)], + ), + ...(row.sampleMessage + ? [h.span([h.Class("block truncate font-mono text-xs text-muted-foreground")], [row.sampleMessage])] + : []), + ], + ), + h.span( + [h.Class("shrink-0 text-right")], + [ + h.span([h.Class("block text-sm font-semibold tabular-nums text-severity-error")], [formatNumber(row.count)]), + h.span([h.Class("block text-[10px] text-muted-foreground")], ["last seen ", relativeTime(row.lastSeen, nowMs)]), + ], + ), + icon(h, "ChevronDownIcon", { + className: cn("size-4 shrink-0 text-muted-foreground transition-transform", card.isExpanded && "rotate-180"), + }), + ], + ) + +export const errorTypeCard = (h: H, row: ErrorTypeRow, card: Card, query: URLSearchParams, nowMs: number): Html => { + const panelId = `error-traces-${row.fingerprintHash}` + return h.div( + [h.Key(row.fingerprintHash), h.Class("rounded-md border bg-card")], + [ + header(h, row, card, panelId, nowMs), + ...(card.isExpanded + ? [h.div([h.Id(panelId), h.Class("border-t px-4 py-2")], [cardTraces(h, row, card, query, nowMs)])] + : []), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/errors/view-sidebar.ts b/apps/local-ui-foldkit/src/page/errors/view-sidebar.ts new file mode 100644 index 0000000000..8b532e7a7f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/view-sidebar.ts @@ -0,0 +1,53 @@ +import { getServiceColor } from "@maple/ui/lib/colors" +import { filterSection, singleCheckboxFilter } from "@maple/web-foldkit/ui/filters/filter-section" +import type * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { filterSidebarBody, filterSidebarFrame, filterSidebarHeader } from "@maple/web-foldkit/ui/filters/filter-sidebar" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { ErrorsFilters } from "./fetch" +import { Message } from "./message" +import type { Model } from "./model" + +// The filter rail of `ErrorsView` (apps/local-ui/src/views/errors-view.tsx). + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +export const sidebar = (h: HtmlBuilder, model: Model, filters: ErrorsFilters, activeFilterCount: number): Html => { + const facets = Option.getOrNull(AsyncData.getData(model.facets.data)) + const isFetching = AsyncData.isLoading(model.facets.data) || AsyncData.isRefreshing(model.facets.data) + const sections = [ + singleCheckboxFilter(h, { + title: "Root spans only", + isChecked: filters.rootOnly === true, + onToggle: Message.ToggledRootOnly(), + }), + filterSection(h, { + title: "Service", + options: facets?.services ?? [], + selected: filters.service ? [filters.service] : [], + isSearchable: true, + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param: "service", name }), + colorOf: getServiceColor, + }), + filterSection(h, { + title: "Environment", + options: facets?.environments ?? [], + selected: filters.env ? [filters.env] : [], + isSearchable: true, + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param: "env", name }), + }), + ] + return filterSidebarFrame(h, { className: "w-56 shrink-0 px-4", isWaiting: isFetching }, [ + filterSidebarHeader(h, activeFilterCount > 0 ? { onClear: Message.ClickedClearFilters() } : {}), + ...filterSidebarBody( + h, + "errors-filters", + sections.filter((section) => section !== null), + ), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/errors/view-traces.ts b/apps/local-ui-foldkit/src/page/errors/view-traces.ts new file mode 100644 index 0000000000..3feffb4c30 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/view-traces.ts @@ -0,0 +1,76 @@ +import { parseClickHouseDateTime } from "@maple/local-ui/lib/time" +import { formatDuration } from "@maple/ui/lib/format" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import type { DateTime } from "effect" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { hrefFor } from "../../route" +import { errorState } from "../kit/view-states" +import type { ErrorTrace, ErrorTypeRow } from "./fetch" +import { Message } from "./message" +import type { Card } from "./model" + +// The open panel of `ErrorTypeCard`: the most recent traces of one error type. + +type H = HtmlBuilder + +/** `formatRelativeTime` against the clock the page last read. */ +export const relativeTime = (value: DateTime.Utc, nowMs: number): string => { + const ms = parseClickHouseDateTime(value) + return ms === null ? "-" : formatRelativeFrom(ms, nowMs) +} + +/** `traceLinkParams`: the view's params, plus the error span to focus when the trace has one. */ +const traceLinkHref = (trace: ErrorTrace, query: URLSearchParams): string => { + const params = new URLSearchParams(Object.fromEntries(query)) + if (trace.errorSpanId) params.set("spanId", trace.errorSpanId) + return hrefFor(`/traces/${encodeURIComponent(trace.traceId)}`, params) +} + +const traceItem = (h: H, trace: ErrorTrace, query: URLSearchParams, nowMs: number): Html => + h.li( + [h.Key(trace.traceId)], + [ + h.a( + [ + h.Href(traceLinkHref(trace, query)), + h.Class( + "flex w-full items-center gap-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:text-foreground", + ), + ], + [ + h.span( + [h.Class("min-w-0 flex-1 truncate font-mono")], + [ + trace.errorSpanName || trace.rootSpanName || trace.traceId, + ...(trace.errorServiceName + ? [h.span([h.Class("ml-2 text-muted-foreground/70")], [trace.errorServiceName])] + : []), + ], + ), + h.span([h.Class("shrink-0 tabular-nums")], [String(trace.spanCount), " spans"]), + h.span([h.Class("shrink-0 tabular-nums")], [formatDuration(trace.durationMicros / 1000)]), + h.span([h.Class("shrink-0 tabular-nums")], [relativeTime(trace.startTime, nowMs)]), + ], + ), + ], + ) + +export const cardTraces = (h: H, row: ErrorTypeRow, card: Card, query: URLSearchParams, nowMs: number): Html => { + if (isPending(card.traces)) + return h.div([h.Class("flex h-16 items-center justify-center")], [spinner(h, "size-4")]) + const failure = failureWithoutData(card.traces) + if (Option.isSome(failure)) + return errorState(h, { + label: "traces", + failure: failure.value, + onRetry: Message.ClickedRetryTraces({ fingerprintHash: row.fingerprintHash }), + }) + const traces = Option.getOrElse(AsyncData.getData(card.traces.data), () => []) + if (traces.length === 0) + return h.p([h.Class("py-2 text-xs text-muted-foreground")], ["No traces found for this error in the selected range."]) + return h.ul([h.Class("divide-y")], traces.map((trace) => traceItem(h, trace, query, nowMs))) +} diff --git a/apps/local-ui-foldkit/src/page/errors/view.ts b/apps/local-ui-foldkit/src/page/errors/view.ts new file mode 100644 index 0000000000..2511cff276 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/errors/view.ts @@ -0,0 +1,103 @@ +import { formatErrorRate } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { icon } from "@maple/web-foldkit/ui/icon" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { queryOf } from "../../route" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { pageShell } from "../kit/page-shell" +import { signalEmptyState } from "../kit/signal-empty-state" +import { refreshButton, timeRangeSelect, toolbar, toolbarStat, toolbarStats } from "../kit/toolbar" +import { emptyState, errorState, listSkeleton } from "../kit/view-states" +import { errorsFiltersOf } from "./command" +import { cardOf } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { errorTypeCard } from "./view-card" +import { sidebar } from "./view-sidebar" + +// `ErrorsView` (apps/local-ui/src/views/errors-view.tsx). + +const toCopy = (id: string, message: Parameters[0]["message"]) => + Message.GotCopyMessage({ id, message }) + +export const view = Submodel.defineView((model, inputs, h) => { + const { context } = inputs + const query = queryOf(context.location) + const filters = errorsFiltersOf(query) + const range = rangeOf(context) + const activeFilterCount = [filters.service, filters.env, filters.rootOnly].filter(Boolean).length + const stats = Option.getOrNull(Option.flatMap(AsyncData.getData(model.summary.data), Option.fromNullishOr)) + const rows = Option.getOrElse(AsyncData.getData(model.byType.data), () => []) + const tracesPresence = inputs.presenceOf("traces") + const content = (): Html => { + if (isPending(model.byType)) return listSkeleton(h, { variant: "card", rows: 6 }) + const failure = failureWithoutData(model.byType) + if (Option.isSome(failure)) + return errorState(h, { label: "errors", failure: failure.value, onRetry: Message.ClickedRefresh() }) + if (rows.length === 0) + return activeFilterCount === 0 && Option.exists(tracesPresence, (presence) => presence.status === "present") + ? emptyState(h, { + icon: icon(h, "CircleWarningIcon"), + title: "No errors in this range", + hint: ["Errors appear when spans arrive with an Error status. None did in the selected window."], + }) + : signalEmptyState(h, { + signal: "traces", + noun: "errors", + isFiltered: activeFilterCount > 0, + onClearFilters: Message.ClickedClearFilters(), + range, + onWidenRange: Message.ClickedWidenRange(), + maybePresence: tracesPresence, + nowMs: inputs.presenceNowMs, + guide: { id: "errors-empty", endpoint: context.env.otlpEndpoint, copies: model.copies, toCopyMessage: toCopy }, + }) + return h.div( + [h.Class(cn("space-y-2 p-4", model.byType.isPlaceholder && "opacity-60 transition-opacity"))], + rows.map((row) => errorTypeCard(h, row, cardOf(model, row.fingerprintHash), query, Math.max(model.nowMs, inputs.clockMs))), + ) + } + return pageShell( + h, + { + onOpenFilters: Message.ClickedOpenFilters(), + sidebar: sidebar(h, model, filters, activeFilterCount), + toolbar: toolbar(h, [ + toolbarStats( + h, + [ + toolbarStat(h, { value: Math.round(stats?.totalErrors ?? 0), label: "errors", isDanger: true }), + toolbarStat(h, { value: Math.round(stats?.affectedTracesCount ?? 0), label: "affected traces" }), + h.span( + [h.Class("text-sm whitespace-nowrap text-muted-foreground")], + [ + h.span([h.Class("font-medium tabular-nums text-foreground")], [formatErrorRate(stats?.errorRate ?? 0)]), + " ", + "error rate", + ], + ), + ], + "flex-wrap", + ), + toolbarStats( + h, + [ + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.byTypeUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + }), + timeRangeSelect(h, range, (next) => Message.ChangedRange({ range: next })), + ], + "shrink-0", + ), + ]), + activeFilterCount, + }, + [content()], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/kit/connect-guide.ts b/apps/local-ui-foldkit/src/page/kit/connect-guide.ts new file mode 100644 index 0000000000..7ab613057b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/connect-guide.ts @@ -0,0 +1,87 @@ +import { DOCS_LOCAL_MODE_SEND_TELEMETRY } from "@maple/local-ui/lib/links" +import { copyableField } from "@maple/web-foldkit/ui/copyable-field" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import type { Html, HtmlBuilder } from "foldkit/html" +import { type Copies, copyOf } from "./copies" + +// apps/local-ui/src/components/connect-guide.tsx: the one "how to send telemetry here" block. + +/** `otelEnvBlock`: `http/protobuf` spelled out, the server speaks OTLP over HTTP only. */ +export const otelEnvBlock = (endpoint: string, serviceName = "my-service"): string => + [ + `OTEL_EXPORTER_OTLP_ENDPOINT=${endpoint}`, + "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf", + `OTEL_SERVICE_NAME=${serviceName}`, + ].join("\n") + +export type ConnectGuideOptions = Readonly<{ + /** Prefix for this instance's copy buttons and input id. */ + id: string + endpoint: string + copies: Copies + toCopyMessage: (id: string, message: CopyButton.Message) => M + note?: Html +}> + +export const connectGuide = (h: HtmlBuilder, options: ConnectGuideOptions): Html => { + const env = otelEnvBlock(options.endpoint) + const endpointId = `${options.id}-endpoint` + const envId = `${options.id}-env` + return h.div( + [h.Class("w-full space-y-3 text-left")], + [ + copyableField(h, { + id: endpointId, + label: "OTLP/HTTP endpoint", + value: options.endpoint, + copy: copyOf(options.copies, endpointId), + toCopyMessage: (message) => options.toCopyMessage(endpointId, message), + }), + h.div( + [h.Class("space-y-1")], + [ + h.div( + [h.Class("flex items-center justify-between")], + [ + h.span([h.Class("text-xs text-muted-foreground")], ["Exporter environment"]), + CopyButton.view(h, { + model: copyOf(options.copies, envId), + toParentMessage: (message) => options.toCopyMessage(envId, message), + value: env, + label: "Environment variables", + idleLabel: "Copy", + iconSize: 12, + }), + ], + ), + h.pre( + [ + h.Class( + "overflow-x-auto rounded-md border bg-muted/40 px-3 py-2 font-mono text-[11px] leading-relaxed", + ), + ], + [env], + ), + ], + ), + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + "No API key needed. Local Maple takes OTLP over HTTP only, so keep the protocol line: .NET and the Python distro default to gRPC.", + ], + ), + ...(options.note === undefined ? [] : [options.note]), + h.a( + [ + h.Href(DOCS_LOCAL_MODE_SEND_TELEMETRY), + h.Target("_blank"), + h.Rel("noopener noreferrer"), + h.Class( + "inline-block text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground", + ), + ], + ["OTLP setup docs"], + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/kit/copies.ts b/apps/local-ui-foldkit/src/page/kit/copies.ts new file mode 100644 index 0000000000..0311032a55 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/copies.ts @@ -0,0 +1,27 @@ +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { Option, Record, Schema } from "effect" +import { Command, type Update } from "foldkit" + +/** + * Every copy button a view shows, by id: each `` keeps its own status in React; here + * the owning Model keeps them in one record and folds `CopyButton.update` by id. + */ +export const Copies = Schema.Record(Schema.String, CopyButton.Model) +export type Copies = typeof Copies.Type + +export const copyOf = (copies: Copies, id: string): CopyButton.Model => + Option.getOrElse(Record.get(copies, id), CopyButton.init) + +/** Folds one copy button's Message; `toMessage` lifts its Commands back to the owner. */ +export const updateCopy = ( + copies: Copies, + id: string, + message: CopyButton.Message, + toMessage: (id: string, message: CopyButton.Message) => M, +): Readonly<{ copies: Copies; commands: ReadonlyArray> }> => { + const next: Update.Return = CopyButton.update(copyOf(copies, id), message) + return { + copies: Record.set(copies, id, next.model), + commands: Command.mapMessages(next.commands ?? [], (child) => toMessage(id, child)), + } +} diff --git a/apps/local-ui-foldkit/src/page/kit/inputs.ts b/apps/local-ui-foldkit/src/page/kit/inputs.ts new file mode 100644 index 0000000000..56bf5ccd29 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/inputs.ts @@ -0,0 +1,16 @@ +import type { Option } from "effect" +import type { SignalPresence, TelemetrySignal } from "../../data/signal-presence" +import type { PageContext } from "../contract" + +/** What a page view reads from the root besides its own Model. */ +export type PageViewInputs = Readonly<{ + context: PageContext + /** `useSignalPresence(signal)`: none while unknown (loading or unreadable). */ + presenceOf: (signal: TelemetrySignal) => Option.Option + /** The clock at the presence read, for "last received 3m ago". */ + presenceNowMs: number + /** The root's latest clock read (each server probe): legacy's per-render `Date.now()` for relative times. */ + clockMs: number + /** `useNewDataSince(sinceMs)`: telemetry arrived after a list loaded. */ + hasNewDataSince: (sinceMs: number) => boolean +}> diff --git a/apps/local-ui-foldkit/src/page/kit/local-time.test.ts b/apps/local-ui-foldkit/src/page/kit/local-time.test.ts new file mode 100644 index 0000000000..0d0f3c2d98 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/local-time.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" +import { formatDateTime, formatTimestamp } from "./local-time" + +const utc = { locale: "en-US", timeZone: "UTC" } +const tokyo = { locale: "en-US", timeZone: "Asia/Tokyo" } +const at = "2026-10-01 23:30:05.042" +const atMs = Date.UTC(2026, 9, 1, 23, 30, 5, 42) + +describe("formatDateTime", () => { + it("renders the wall clock of the given zone with millis", () => { + expect(formatDateTime(utc, at)).toBe("Oct 1, 2026 23:30:05.042") + expect(formatDateTime(tokyo, at)).toBe("Oct 2, 2026 08:30:05.042") + }) + + it("renders a dash for an unparseable time", () => { + expect(formatDateTime(utc, "not a time")).toBe("-") + }) +}) + +describe("formatTimestamp", () => { + it("drops the day when it is the same local day as now", () => { + expect(formatTimestamp(utc, at, { nowMs: atMs + 60_000 })).toBe("23:30:05.042") + expect(formatTimestamp(utc, at, { nowMs: atMs + 60_000, precision: "s" })).toBe("23:30:05") + }) + + it("adds the day once the zone has crossed midnight", () => { + expect(formatTimestamp(tokyo, at, { nowMs: atMs - 9 * 60 * 60 * 1000 })).toBe("Oct 2 08:30:05.042") + }) +}) diff --git a/apps/local-ui-foldkit/src/page/kit/local-time.ts b/apps/local-ui-foldkit/src/page/kit/local-time.ts new file mode 100644 index 0000000000..e1146ad774 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/local-time.ts @@ -0,0 +1,63 @@ +import { parseClickHouseDateTime, type WarehouseTime } from "@maple/local-ui/lib/time" +import { Schema } from "effect" + +// lib/time.ts `formatLocalDateTime` / `formatLocalTimestamp` over a locale and zone read once in +// Flags, so the views never read the browser's locale or zone themselves. + +/** The browser's default locale and time zone (`Intl.DateTimeFormat().resolvedOptions()`). */ +export const TimeFormat = Schema.Struct({ locale: Schema.String, timeZone: Schema.String }) +export type TimeFormat = typeof TimeFormat.Type + +const pad = (value: number, width = 2) => String(value).padStart(width, "0") + +type Clock = Readonly<{ dayKey: string; hours: number; minutes: number; seconds: number }> + +/** Wall-clock fields of `ms` in the zone (no zone has a sub-second offset, so millis carry over). */ +const clockOf = (format: TimeFormat, ms: number): Clock => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: format.timeZone, + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hourCycle: "h23", + }).formatToParts(ms) + const field = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value ?? 0) + return { + dayKey: `${field("year")}-${field("month")}-${field("day")}`, + hours: field("hour"), + minutes: field("minute"), + seconds: field("second"), + } +} + +const dayLabel = (format: TimeFormat, ms: number, options: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat(format.locale, { ...options, timeZone: format.timeZone }).format(ms) + +const millisOf = (ms: number) => pad(((ms % 1000) + 1000) % 1000, 3) + +/** Full local date and time with millis, for detail panels. */ +export const formatDateTime = (format: TimeFormat, chDateTime: WarehouseTime): string => { + const ms = parseClickHouseDateTime(chDateTime) + if (ms === null) return "-" + const clock = clockOf(format, ms) + const day = dayLabel(format, ms, { year: "numeric", month: "short", day: "numeric" }) + return `${day} ${pad(clock.hours)}:${pad(clock.minutes)}:${pad(clock.seconds)}.${millisOf(ms)}` +} + +/** `14:05:01.940` today, `Sep 25 14:05:01.940` on other days; `precision: "s"` drops the millis. */ +export const formatTimestamp = ( + format: TimeFormat, + chDateTime: WarehouseTime, + options: Readonly<{ precision?: "ms" | "s"; nowMs: number }>, +): string => { + const ms = parseClickHouseDateTime(chDateTime) + if (ms === null) return "-" + const clock = clockOf(format, ms) + const hms = `${pad(clock.hours)}:${pad(clock.minutes)}:${pad(clock.seconds)}` + const time = options.precision === "s" ? hms : `${hms}.${millisOf(ms)}` + if (clock.dayKey === clockOf(format, options.nowMs).dayKey) return time + return `${dayLabel(format, ms, { month: "short", day: "numeric" })} ${time}` +} diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/hero.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/hero.ts new file mode 100644 index 0000000000..2bd865699f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/hero.ts @@ -0,0 +1,94 @@ +import { highlightJson } from "@maple/local-ui/lib/highlight" +import { getSeverityColor } from "@maple/ui/lib/severity" +import { cn } from "@maple/ui/lib/utils" +import { button } from "@maple/web-foldkit/ui/button" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "./message" +import type { Model } from "./model" +import { copyableValue, heroBody, heroToneStyle, severityLabel } from "./parts" + +/** `SeverityBadge`: the label in its colour on a 10% wash. */ +const severityBadge = (h: HtmlBuilder, label: string): Html => { + const color = getSeverityColor(label) + return badge( + h, + { + variant: "secondary", + className: "font-mono text-3xs uppercase", + attributes: [h.Style({ color, "background-color": `color-mix(in oklch, ${color} 10%, transparent)` })], + }, + [label], + ) +} + +const message = (h: HtmlBuilder, hero: ReturnType, isClamped: boolean): Html => + hero.isJson + ? h.pre( + [h.Class(cn("font-mono text-ui leading-relaxed whitespace-pre-wrap break-words", isClamped && "line-clamp-6"))], + [h.code([h.InnerHTML(highlightJson(hero.formatted))], [])], + ) + : h.p( + [h.Class(cn("font-mono text-sm leading-relaxed whitespace-pre-wrap break-words", isClamped && "line-clamp-4"))], + [hero.body], + ) + +/** `LogHeroHeader`: severity, service, close, and the (clamped) message. */ +export const heroHeader = (h: HtmlBuilder, model: Model): Html => { + const { log } = model + const severity = severityLabel(log.severityText, log.severityNumber) + const hero = heroBody(log) + const tone = heroToneStyle(severity) + return h.div( + [h.Class("shrink-0 border-b px-4 py-3"), ...(tone === undefined ? [] : [h.Style(tone)])], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + severityBadge(h, severity), + badge(h, { variant: "outline", size: "xs", mono: true }, [ + serviceDot(h, { serviceName: log.serviceName, size: "sm" }), + copyableValue(h, model.copies, { id: "detail-service", value: log.serviceName }, [log.serviceName]), + ]), + button( + h, + { + variant: "ghost", + size: "icon", + className: "ml-auto shrink-0", + attributes: [h.AriaLabel("Close log details"), h.OnClick(Message.ClosedSheet())], + }, + [icon(h, "XmarkIcon")], + ), + ], + ), + h.div( + [h.Class("mt-3")], + [ + copyableValue(h, model.copies, { id: "detail-body", value: hero.copyValue }, [ + message(h, hero, hero.isLong && !model.isBodyExpanded), + ]), + ...(hero.isLong + ? [ + h.button( + [ + h.Type("button"), + h.OnClick(Message.ToggledBodyExpanded()), + h.Class( + "mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground transition-colors hover:text-foreground", + ), + ], + [ + model.isBodyExpanded ? "Show less" : "Show full message", + icon(h, model.isBodyExpanded ? "ChevronUpIcon" : "ChevronDownIcon", { size: 10 }), + ], + ), + ] + : []), + ], + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/log-sheet.scene.test.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/log-sheet.scene.test.ts new file mode 100644 index 0000000000..ae2c073790 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/log-sheet.scene.test.ts @@ -0,0 +1,57 @@ +import { Option } from "effect" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { Message } from "./message" +import type { LocalLog } from "./model" +import { isOpen, openLog, update } from "./update" +import { view as sheetView } from "./view" + +const log = (severityText: string, logAttributes: Record = {}): LocalLog => ({ + timestamp: "2026-10-01 11:59:30.000000000", + severityText, + severityNumber: 0, + serviceName: "payment-svc", + body: "pool exhausted", + traceId: "0eab694ff33721d5a836ce7444afadcc", + spanId: "1111111111111111", + recordIdentity: "1", + logAttributes, + resourceAttributes: {}, +}) + +const RECT = { left: 0, right: 80, top: 0, bottom: 32, width: 80, height: 32 } +const mounted = [ + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Tabs.MeasureTabIndicator, Tabs.Message.MeasuredIndicator({ rect: RECT })), +] as const +const view = withViewInputs(sheetView, { timeFormat: { locale: "en-US", timeZone: "UTC" } })() + +describe("log detail sheet", () => { + test("an ERROR log shows the error banner with the exception message and type", () => { + const opened = openLog(Option.none(), log("ERROR", { "exception.message": "timeout after 30s", "exception.type": "PoolTimeout" })) + expect(isOpen(opened.model)).toBe(true) + scene( + { update, view }, + given(opened.model), + ...mounted, + expectView(text("timeout after 30s")).toExist(), + expectView(text("PoolTimeout")).toExist(), + expectView(role("button", { name: "Copy error message" })).toExist(), + ) + }) + + test("an INFO log has no banner", () => { + scene({ update, view }, given(openLog(Option.none(), log("INFO")).model), ...mounted, expectView(role("button", { name: "Copy error message" })).not.toExist()) + }) + + test("the close button closes it; reopening on the same log keeps its expansion", () => { + const opened = openLog(Option.none(), log("ERROR")) + const expanded = update(opened.model, Message.ToggledBodyExpanded()).model + const closed = update(expanded, Message.ClosedSheet()).model + expect(isOpen(closed)).toBe(false) + const reopened = openLog(Option.some(closed), log("ERROR")).model + expect([isOpen(reopened), reopened.isBodyExpanded]).toEqual([true, true]) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/message.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/message.ts new file mode 100644 index 0000000000..e256b7afa4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/message.ts @@ -0,0 +1,19 @@ +import * as AttributesPanel from "@maple/web-foldkit/ui/attributes/attributes-panel" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" + +export const Message = defineMessageUnion({ + /** The close button, or a trace link that leaves the page. */ + ClosedSheet: {}, + /** The backdrop, Escape and the transition, through the kit's Dialog. */ + GotDialogMessage: { message: Dialog.Message }, + ToggledBodyExpanded: {}, + ToggledErrorExpanded: {}, + GotTabsMessage: { message: Tabs.Message }, + GotAttributesMessage: { message: AttributesPanel.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/model.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/model.ts new file mode 100644 index 0000000000..885fa3593a --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/model.ts @@ -0,0 +1,70 @@ +import * as AttributesPanel from "@maple/web-foldkit/ui/attributes/attributes-panel" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { Option, Schema } from "effect" +import { Copies } from "../copies" + +// `LogDetailSheet` (apps/local-ui/src/components/log-detail-sheet.tsx) as data, shared by the +// Logs page and the span panel's Logs tab. + +/** `LocalLog` (lib/log-shape.ts): one log row, attribute maps already decoded. */ +export const LocalLog = Schema.Struct({ + /** The stored `DateTime64(9)` value with its nanoseconds: shown verbatim and the keyset cursor. */ + timestamp: Schema.String, + severityText: Schema.String, + severityNumber: Schema.Number, + serviceName: Schema.String, + body: Schema.String, + traceId: Schema.String, + spanId: Schema.String, + recordIdentity: Schema.String, + logAttributes: Schema.Record(Schema.String, Schema.String), + resourceAttributes: Schema.Record(Schema.String, Schema.String), +}) +export type LocalLog = typeof LocalLog.Type + +export const logKey = (log: LocalLog): string => + `${log.timestamp}|${log.serviceName}|${log.traceId}|${log.spanId}|${log.recordIdentity}` + +export const SHEET_ID = "log-detail-sheet" + +/** The sheet: the log, its dialog, the body and error expansions, tabs, attributes, copies. */ +export const Model = Schema.Struct({ + log: LocalLog, + dialog: Dialog.Model, + isBodyExpanded: Schema.Boolean, + isErrorExpanded: Schema.Boolean, + tabs: Tabs.Model, + attributes: AttributesPanel.Model, + copies: Copies, +}) +export type Model = typeof Model.Type + +/** The sheet's tabs: Trace only when the log carries a trace id. */ +export const tabValuesOf = (log: LocalLog): ReadonlyArray => + log.traceId ? ["attributes", "trace", "raw"] : ["attributes", "raw"] + +/** The sheet with `log` in it, closed: `update.openLog` opens it. */ +export const withLog = (maybeSheet: Option.Option, log: LocalLog): Model => + Option.match(maybeSheet, { + onNone: () => ({ + log, + dialog: Dialog.init(SHEET_ID, "sheet"), + isBodyExpanded: false, + isErrorExpanded: false, + tabs: Tabs.init({ idBase: 900, values: tabValuesOf(log), selected: "attributes" }), + attributes: AttributesPanel.init("log-detail-attributes"), + copies: {}, + }), + // A sheet that stays mounted keeps its tab and body expansion; the rest is per log. + onSome: (sheet) => { + const isSameLog = logKey(sheet.log) === logKey(log) + return { + ...sheet, + log, + isErrorExpanded: isSameLog && sheet.isErrorExpanded, + tabs: { ...sheet.tabs, values: tabValuesOf(log) }, + attributes: isSameLog ? sheet.attributes : AttributesPanel.init("log-detail-attributes"), + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/parts.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/parts.ts new file mode 100644 index 0000000000..39a9780355 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/parts.ts @@ -0,0 +1,114 @@ +import { trySync } from "@maple/ui/lib/try-sync" +import { stripAnsi } from "@maple/ui/components/logs/strip-ansi" +import { SEVERITY_COLORS } from "@maple/ui/lib/severity" +import { cn } from "@maple/ui/lib/utils" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../../route" +import { type Copies, copyOf } from "../copies" +import { Message } from "./message" +import type { LocalLog } from "./model" + +// Pure helpers and the copy-on-click value of `LogDetailSheet`. + +const SEVERITY_NUMBER_LABELS = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"] as const + +/** copied: `severityLabel` (packages/ui/src/components/logs/severity-badge.tsx, a React module). */ +export const severityLabel = (severityText: string, severityNumber?: number): string => { + if (severityText.trim() !== "") return severityText + if (severityNumber !== undefined && severityNumber >= 1 && severityNumber <= 24) + return SEVERITY_NUMBER_LABELS[Math.floor((severityNumber - 1) / 4)] ?? "UNSET" + return "UNSET" +} + +/** A faint wash of the severity colour behind the hero; unknown levels keep the plain border. */ +export const heroToneStyle = (severity: string): Readonly> | undefined => { + const key = severity.toUpperCase() + if (!Object.hasOwn(SEVERITY_COLORS, key)) return undefined + const color = SEVERITY_COLORS[key] + return { + "background-color": `color-mix(in oklch, ${color} 5%, transparent)`, + "border-color": `color-mix(in oklch, ${color} 20%, transparent)`, + } +} + +/** The body pretty-printed when it is a JSON object or array; none otherwise. */ +export const prettyJson = (value: string): Option.Option => { + const trimmed = value.trimStart() + if (trimmed[0] !== "{" && trimmed[0] !== "[") return Option.none() + return trySync(() => JSON.stringify(JSON.parse(value), null, 2)) +} + +/** What the hero shows and copies: ANSI stripped for display, pretty JSON when it is JSON. */ +export const heroBody = (log: LocalLog) => { + const body = stripAnsi(log.body ?? "") + const pretty = prettyJson(body) + const isJson = Option.isSome(pretty) + const formatted = Option.getOrElse(pretty, () => body) + return { + body, + isJson, + formatted, + // Pretty JSON is the friendlier copy, but only when it is the original bytes reformatted. + copyValue: isJson && body === (log.body ?? "") ? formatted : (log.body ?? ""), + isLong: formatted.length > BODY_LINE_THRESHOLD || formatted.includes("\n"), + } +} + +const BODY_LINE_THRESHOLD = 280 + +/** The trace page, with the log's span pre-selected. */ +export const traceHrefOf = (log: LocalLog): Option.Option => + log.traceId + ? Option.some( + hrefFor(`/traces/${encodeURIComponent(log.traceId)}`, new URLSearchParams(log.spanId ? { spanId: log.spanId } : {})), + ) + : Option.none() + +/** `buildLogJsonPayload`: the full log as pretty JSON. */ +export const buildLogJsonPayload = (log: LocalLog): string => + JSON.stringify( + { + timestamp: log.timestamp, + severityText: log.severityText, + severityNumber: log.severityNumber, + serviceName: log.serviceName, + body: log.body, + traceId: log.traceId || undefined, + spanId: log.spanId || undefined, + logAttributes: log.logAttributes, + resourceAttributes: log.resourceAttributes, + }, + null, + 2, + ) + +/** `CopyableValue`: inline text that copies itself, tinted by the copy's outcome. */ +export const copyableValue = ( + h: HtmlBuilder, + copies: Copies, + options: Readonly<{ id: string; value: string; label?: string }>, + children: ReadonlyArray, +): Html => { + const { status } = copyOf(copies, options.id) + return h.button( + [ + h.Type("button"), + h.AriaLabel(`Copy ${options.label ?? "value"}`), + h.Class( + cn( + "-mx-0.5 cursor-pointer rounded px-0.5 text-left transition-colors", + status === "idle" && "hover:bg-muted/50", + status === "copied" && "bg-severity-info/20", + status === "error" && "bg-severity-error/20", + ), + ), + h.OnClick(Message.GotCopyMessage({ id: options.id, message: CopyButton.Message.ClickedCopy({ text: options.value }) }), { + propagation: "Stop", + }), + h.Title("Click to copy"), + ], + children, + ) +} diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/tabs.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/tabs.ts new file mode 100644 index 0000000000..8fca783433 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/tabs.ts @@ -0,0 +1,107 @@ +import { highlightJson } from "@maple/local-ui/lib/highlight" +import * as AttributesView from "@maple/web-foldkit/ui/attributes/attributes-view" +import { linkButton } from "@maple/web-foldkit/ui/button" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { icon, type IconName } from "@maple/web-foldkit/ui/icon" +import { keyValue, keyValueList } from "@maple/web-foldkit/ui/key-value" +import { scrollArea } from "@maple/web-foldkit/ui/scroll-area" +import * as TabsView from "@maple/web-foldkit/ui/tabs-view" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { copyOf } from "../copies" +import { Message } from "./message" +import type { LocalLog, Model } from "./model" +import { buildLogJsonPayload, copyableValue, traceHrefOf } from "./parts" + +// The Attributes / Trace / Raw tabs of `LogDetailSheet`. + +const TABS: ReadonlyMap> = new Map([ + ["attributes", { label: "Attributes", icon: "CircleInfoIcon" }], + ["trace", { label: "Trace", icon: "PulseIcon" }], + ["raw", { label: "Raw", icon: "CodeIcon" }], +] as const) + +const padded = (h: HtmlBuilder, id: string, children: ReadonlyArray): ReadonlyArray => [ + scrollArea(h, { className: "h-full", idPrefix: id }, [h.div([h.Class("p-3")], children)]), +] + +const tracePanel = (h: HtmlBuilder, model: Model, log: LocalLog, href: string): Html => + h.div([h.Class("space-y-3")], [ + keyValueList(h, { className: "gap-1 rounded-md border p-2" }, [ + keyValue(h, { label: "Trace ID", mono: true }, [ + copyableValue(h, model.copies, { id: "detail-trace-id", value: log.traceId }, [log.traceId]), + ]), + ...(log.spanId + ? [ + keyValue(h, { label: "Span ID", mono: true }, [ + copyableValue(h, model.copies, { id: "detail-span-id", value: log.spanId }, [log.spanId]), + ]), + ] + : []), + ]), + linkButton( + h, + { variant: "outline", size: "sm", className: "w-full gap-1.5", href, attributes: [h.OnClick(Message.ClosedSheet())] }, + [icon(h, "PulseIcon"), "Open trace"], + ), + ]) + +const rawPanel = (h: HtmlBuilder, model: Model, log: LocalLog): Html => { + const payload = buildLogJsonPayload(log) + return h.div([], [ + h.div([h.Class("mb-2 flex items-center justify-between")], [ + h.span([h.Class("text-xs font-medium text-muted-foreground")], ["JSON Payload"]), + CopyButton.view(h, { + model: copyOf(model.copies, "detail-raw"), + toParentMessage: (message) => Message.GotCopyMessage({ id: "detail-raw", message }), + value: payload, + label: "Log JSON", + idleLabel: "Copy", + iconSize: 10, + className: "h-5 px-1.5 text-[10px]", + }), + ]), + h.pre( + [h.Class("whitespace-pre-wrap break-all rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed")], + [h.code([h.InnerHTML(highlightJson(payload))], [])], + ), + ]) +} + +export const detailTabs = (h: HtmlBuilder, model: Model): Html => { + const { log } = model + const maybeHref = traceHrefOf(log) + const panel = (value: string): ReadonlyArray | undefined => { + if (value === "attributes") + return padded(h, "log-detail-attributes", [ + h.submodel({ + slotId: "log-detail-attributes-panel", + model: model.attributes, + view: AttributesView.view, + viewInputs: { logAttributes: log.logAttributes, resourceAttributes: log.resourceAttributes, canFilter: false, logTitle: "Log attributes", hasCommitShaValues: false }, + toParentMessage: (message) => Message.GotAttributesMessage({ message }), + }), + ]) + if (value === "trace") + return Option.match(maybeHref, { onNone: () => undefined, onSome: (href) => padded(h, "log-detail-trace", [tracePanel(h, model, log, href)]) }) + return padded(h, "log-detail-raw", [rawPanel(h, model, log)]) + } + return h.submodel({ + slotId: "log-detail-tabs", + model: model.tabs, + view: TabsView.view, + viewInputs: { + variant: "underline", + labels: model.tabs.values.map((value) => TABS.get(value)?.label ?? value), + tabContent: (value, label) => { + const tab = TABS.get(value) + return tab === undefined ? [label] : [icon(h, tab.icon, { size: 14 }), " ", label] + }, + className: "flex min-h-0 flex-1 flex-col", + listClassName: "shrink-0 px-4", + panelClassName: "mt-0 min-h-0 flex-1", + panel, + }, + toParentMessage: (message) => Message.GotTabsMessage({ message }), + }) +} diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/update.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/update.ts new file mode 100644 index 0000000000..515c6c0c75 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/update.ts @@ -0,0 +1,50 @@ +import * as AttributesPanel from "@maple/web-foldkit/ui/attributes/attributes-panel" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import type { Option } from "effect" +import { Command, type Update } from "foldkit" +import { updateCopy } from "../copies" +import { Message } from "./message" +import { type LocalLog, type Model, withLog } from "./model" + +export type UpdateReturn = Update.Return + +const liftDialog = (model: Model, step: ReturnType): UpdateReturn => ({ + model: { ...model, dialog: step.model }, + commands: Command.mapMessages(step.commands ?? [], (message) => Message.GotDialogMessage({ message })), +}) + +/** A row click: the sheet shows `log`, opening if it was closed. */ +export const openLog = (maybeSheet: Option.Option, log: LocalLog): UpdateReturn => { + const sheet = withLog(maybeSheet, log) + return liftDialog(sheet, Dialog.open(sheet.dialog)) +} + +export const isOpen = (model: Model): boolean => model.dialog.isOpen + +/** The local sheet has no attribute filters, so the panel's filter OutMessage is never raised. */ +export const update = (model: Model, message: Message): UpdateReturn => + Message.match(message, { + ClosedSheet: () => liftDialog(model, Dialog.close(model.dialog)), + GotDialogMessage: ({ message: child }) => liftDialog(model, Dialog.update(model.dialog, child)), + ToggledBodyExpanded: () => ({ model: { ...model, isBodyExpanded: !model.isBodyExpanded } }), + ToggledErrorExpanded: () => ({ model: { ...model, isErrorExpanded: !model.isErrorExpanded } }), + GotTabsMessage: ({ message: child }) => { + const next = Tabs.update(model.tabs, child) + return { + model: { ...model, tabs: next.model }, + commands: Command.mapMessages(next.commands ?? [], (tabs) => Message.GotTabsMessage({ message: tabs })), + } + }, + GotAttributesMessage: ({ message: child }) => { + const next = AttributesPanel.update(model.attributes, child) + return { + model: { ...model, attributes: next.model }, + commands: Command.mapMessages(next.commands ?? [], (panel) => Message.GotAttributesMessage({ message: panel })), + } + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/kit/log-sheet/view.ts b/apps/local-ui-foldkit/src/page/kit/log-sheet/view.ts new file mode 100644 index 0000000000..50a90d9ba5 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/log-sheet/view.ts @@ -0,0 +1,126 @@ +import { formatUtcTitle } from "@maple/local-ui/lib/time" +import { parseErrorBody } from "@maple/ui/lib/error-body" +import { formatErrorPrompt } from "@maple/ui/lib/error-prompt" +import { shortId } from "@maple/ui/lib/ids" +import { cn } from "@maple/ui/lib/utils" +import { sheetTitleStyles } from "@maple/ui/styles/ui/sheet" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { titleId } from "@maple/web-foldkit/ui/dialog" +import * as DialogView from "@maple/web-foldkit/ui/dialog-view" +import { errorSectionView } from "@maple/web-foldkit/ui/error-section" +import { icon } from "@maple/web-foldkit/ui/icon" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { copyOf } from "../copies" +import { formatDateTime, type TimeFormat } from "../local-time" +import { heroHeader } from "./hero" +import { Message } from "./message" +import type { LocalLog, Model } from "./model" +import { buildLogJsonPayload, copyableValue, severityLabel, traceHrefOf } from "./parts" +import { detailTabs } from "./tabs" + +// `LogDetailSheet` (apps/local-ui/src/components/log-detail-sheet.tsx) in the kit's Sheet, which +// portals it to ``, marks the page outside it, locks scroll and traps focus. + +type H = HtmlBuilder + +const copyClick = (id: string, text: string) => Message.GotCopyMessage({ id, message: CopyButton.Message.ClickedCopy({ text }) }) + +const metaStrip = (h: H, model: Model, timeFormat: TimeFormat): Html => { + const { log } = model + return h.div([h.Class("flex shrink-0 items-center gap-2 overflow-x-auto whitespace-nowrap border-b px-4 py-1.5 text-xs")], [ + h.div([h.Class("flex shrink-0 items-center gap-1.5")], [ + icon(h, "ClockIcon", { size: 12, className: "text-muted-foreground" }), + h.span([h.Class("font-mono"), h.Title(formatUtcTitle(log.timestamp))], [ + copyableValue(h, model.copies, { id: "detail-time", value: log.timestamp }, [formatDateTime(timeFormat, log.timestamp)]), + ]), + ]), + ...Option.match(traceHrefOf(log), { + onNone: () => [], + onSome: (href) => [ + h.a( + [ + h.Href(href), + h.OnClick(Message.ClosedSheet()), + h.Class( + "inline-flex shrink-0 items-center gap-1 rounded border border-primary/20 bg-primary/5 px-1.5 py-0.5 font-mono text-[11px] text-primary transition-colors hover:bg-primary/10", + ), + h.Title(`View trace ${log.traceId}`), + ], + [icon(h, "PulseIcon", { size: 10 }), "trace:", shortId(log.traceId, "trace")], + ), + ], + }), + ...(log.spanId + ? [ + h.span([h.Class("shrink-0 font-mono text-[11px] text-muted-foreground")], [ + copyableValue(h, model.copies, { id: "detail-span", value: log.spanId }, ["span:", shortId(log.spanId, "span")]), + ]), + ] + : []), + h.div([h.Class("ml-auto flex shrink-0 items-center gap-0.5")], [ + // TODO(tooltip): legacy wraps this copy button in a tooltip. + CopyButton.view(h, { + model: copyOf(model.copies, "detail-json"), + toParentMessage: (message) => Message.GotCopyMessage({ id: "detail-json", message }), + value: buildLogJsonPayload(log), + label: "Log JSON", + iconSize: 13, + }), + ]), + ]) +} + +/** `LogErrorBanner` for ERROR / FATAL / CRITICAL: the exception message (else the body). */ +const errorBanner = (h: H, model: Model, log: LocalLog): ReadonlyArray => { + const severity = severityLabel(log.severityText, log.severityNumber).toUpperCase() + if (severity !== "ERROR" && severity !== "FATAL" && severity !== "CRITICAL") return [] + const message = log.logAttributes["exception.message"] ?? log.logAttributes["error.message"] ?? log.body + if (message === "") return [] + const prompt = formatErrorPrompt({ message, serviceName: log.serviceName, attributes: { ...log.logAttributes } }) + return [ + errorSectionView(h, { + message, + title: log.severityText.toUpperCase() === "FATAL" ? "Fatal" : "Error", + badge: log.logAttributes["exception.type"] ?? log.logAttributes["error.type"], + isExpanded: model.isErrorExpanded, + onToggle: Message.ToggledErrorExpanded(), + bodyStatus: copyOf(model.copies, "error-body").status, + onCopyBody: copyClick("error-body", parseErrorBody(message).full), + // TODO(tooltip): legacy's prompt copy button carries a hover tooltip. + prompt: { status: copyOf(model.copies, "error-prompt").status, onCopy: copyClick("error-prompt", prompt) }, + }), + ] +} + +const content = (h: H, model: Model, timeFormat: TimeFormat): ReadonlyArray => [ + h.h2([h.Class(cn(sheetTitleStyles, "sr-only")), h.DataAttribute("slot", "sheet-title"), h.Id(titleId(model.dialog.id))], [ + "Log: ", + model.log.body.slice(0, 80), + ]), + heroHeader(h, model), + metaStrip(h, model, timeFormat), + ...errorBanner(h, model, model.log), + detailTabs(h, model), +] + +export type ViewInputs = Readonly<{ timeFormat: TimeFormat }> + +export const view = Submodel.defineView((model, inputs, h) => + h.submodel({ + slotId: `${model.dialog.id}-sheet`, + model: model.dialog, + view: DialogView.view, + viewInputs: { + className: "flex flex-col p-0 sm:max-w-2xl", + side: "right", + hasTrigger: false, + showCloseButton: false, + hasDescription: false, + toView: ({ portal }) => portal, + toContent: () => content(h, model, inputs.timeFormat), + }, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + }), +) diff --git a/apps/local-ui-foldkit/src/page/kit/page-shell.ts b/apps/local-ui-foldkit/src/page/kit/page-shell.ts new file mode 100644 index 0000000000..18d73d1a5b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/page-shell.ts @@ -0,0 +1,58 @@ +import { button } from "@maple/web-foldkit/ui/button" +import { triggerId } from "@maple/web-foldkit/ui/dialog" +import { icon } from "@maple/web-foldkit/ui/icon" +import type { Html, HtmlBuilder } from "foldkit/html" + +// apps/local-ui/src/components/page-shell.tsx: filter rail beside a toolbar over the scrolling +// content. Below `md` the rail moves behind a Filters button; the root draws the drawer. + +/** The drawer's Dialog id; the root lifts the `page-filter-sidebar` rail into it while open. */ +export const FILTER_DRAWER_ID = "local-filter-drawer" + +export type PageShellOptions = Readonly<{ + sidebar: Html + toolbar: Html + /** Shown on the small-screen Filters button, so hidden filters are never invisible. */ + activeFilterCount?: number + /** The page's `ClickedOpenFilters`, answered with `RequestedFilterDrawer`. */ + onOpenFilters: M +}> + +export const pageShell = (h: HtmlBuilder, options: PageShellOptions, children: ReadonlyArray): Html => { + const count = options.activeFilterCount ?? 0 + return h.div( + [h.Class("flex h-full min-h-0"), h.DataAttribute("slot", "page-body")], + [ + h.aside([h.Class("hidden h-full border-r py-1 md:block"), h.DataAttribute("slot", "page-filter-sidebar")], [options.sidebar]), + h.div( + [h.Class("flex min-w-0 flex-1 flex-col")], + [ + h.div( + [h.Class("flex items-center border-b px-4 py-2 md:hidden")], + [ + button( + h, + { + variant: "outline", + size: "sm", + className: "gap-1.5", + // The drawer hands focus back here on close. + attributes: [h.Id(triggerId(FILTER_DRAWER_ID)), h.OnClick(options.onOpenFilters)], + }, + [ + icon(h, "FilterIcon"), + "Filters", + ...(count > 0 + ? [h.span([h.Class("rounded-sm bg-primary/15 px-1 text-[10px] tabular-nums text-primary")], [String(count)])] + : []), + ], + ), + ], + ), + options.toolbar, + h.div([h.Class("min-h-0 flex-1 overflow-auto")], children), + ], + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/kit/row-link.ts b/apps/local-ui-foldkit/src/page/kit/row-link.ts new file mode 100644 index 0000000000..0f86ce0118 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/row-link.ts @@ -0,0 +1,32 @@ +import { cn } from "@maple/ui/lib/utils" +import { tableRow } from "@maple/web-foldkit/ui/table" +import type { Html, HtmlBuilder } from "foldkit/html" + +// apps/local-ui/src/components/row-link.tsx: a row that navigates is a real link, its first cell's +// anchor stretched over the row (keyboard focus, cmd-click and "copy link" for free). + +export const linkRow = (h: HtmlBuilder, className: string | undefined, children: ReadonlyArray): Html => + tableRow( + h, + children, + cn( + "relative cursor-pointer focus-within:bg-muted/50 has-[a[data-row-link]:focus-visible]:outline-2 has-[a[data-row-link]:focus-visible]:-outline-offset-2 has-[a[data-row-link]:focus-visible]:outline-ring", + className, + ), + ) + +/** The row's anchor. Put it in the first cell; its `::after` covers the row. */ +export const rowLink = ( + h: HtmlBuilder, + options: Readonly<{ href: string; label?: string; className?: string }>, + children: ReadonlyArray, +): Html => + h.a( + [ + h.Href(options.href), + ...(options.label === undefined ? [] : [h.AriaLabel(options.label)]), + h.DataAttribute("row-link", ""), + h.Class(cn("outline-none after:absolute after:inset-0 after:content-['']", options.className)), + ], + children, + ) diff --git a/apps/local-ui-foldkit/src/page/kit/series-chart.ts b/apps/local-ui-foldkit/src/page/kit/series-chart.ts new file mode 100644 index 0000000000..69badded35 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/series-chart.ts @@ -0,0 +1,74 @@ +import { type GapFill, pivotSeries, type SeriesPoint } from "@maple/local-ui/lib/chart-series" +import type { ChartWindow } from "@maple/local-ui/lib/time" +import { cn } from "@maple/ui/lib/utils" +import * as AreaChart from "@maple/web-foldkit/charts/area-chart" +import type * as Chart from "@maple/web-foldkit/charts/chart" +import type { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { chartSkeleton } from "@maple/web-foldkit/ui/chart-skeleton" +import type { Html, HtmlBuilder } from "foldkit/html" + +// `SeriesChart` (apps/local-ui/src/components/series-chart.tsx): the pivot, gap handling and the +// loading / not-enough-data states the detail pages share, over `QueryBuilderLineChart`. + +/** The tokens the line chart resolves; a page reads them with `ResolveThemeColors`. */ +export const SERIES_CHART_TOKENS = AreaChart.AREA_CHART_TOKENS + +export type SeriesChartOptions = Readonly<{ + chartId: string + points: ReadonlyArray | undefined + window: ChartWindow + fill: GapFill + isPending: boolean + heightClassName?: string + unit?: string + /** Metric detail only; other charts keep the default axis. */ + fitYAxisToData?: boolean + chart: Chart.Model + toChartMessage: (message: Chart.Message) => M + /** Theme colours and the chart runtime; null until both are ready (the skeleton stays up). */ + theme: ThemeColors | null + /** The clock the in-flight tail is judged against (`Date.now()` in the React chart). */ + nowMs: number +}> + +export const seriesChart = (h: HtmlBuilder, options: SeriesChartOptions): Html => { + const heightClassName = options.heightClassName ?? "h-56" + const rows = pivotSeries(options.points ?? [], options.window, options.fill) + const skeleton = h.div([h.Class("rounded-md border p-3")], [chartSkeleton(h, "line", heightClassName)]) + if (options.isPending) return skeleton + if (rows.length < 2) + return h.div( + [ + h.Class( + cn("flex items-center justify-center rounded-md border text-sm text-muted-foreground", heightClassName), + ), + ], + ["No datapoints in this range yet."], + ) + if (options.theme === null) return skeleton + return h.div( + [h.Class("rounded-md border p-3")], + [ + h.submodel({ + slotId: `series-chart-${options.chartId}`, + model: options.chart, + view: AreaChart.view, + toParentMessage: options.toChartMessage, + viewInputs: { + chartId: options.chartId, + data: rows, + theme: options.theme, + unit: options.unit, + nowMs: options.nowMs, + className: cn(heightClassName, "w-full"), + legend: "visible", + mark: "line", + curveType: "monotone", + ...(options.fitYAxisToData === undefined + ? undefined + : { axis: { thresholds: [], logScale: false, softMin: null, softMax: null, fitYAxisToData: options.fitYAxisToData } }), + }, + }), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/kit/signal-empty-state.ts b/apps/local-ui-foldkit/src/page/kit/signal-empty-state.ts new file mode 100644 index 0000000000..4048da9db8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/signal-empty-state.ts @@ -0,0 +1,135 @@ +import { resolveRange, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { button } from "@maple/web-foldkit/ui/button" +import { empty, emptyContent, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/web-foldkit/ui/empty" +import { icon, type IconName } from "@maple/web-foldkit/ui/icon" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { SignalPresence, TelemetrySignal } from "../../data/signal-presence" +import { type ConnectGuideOptions, connectGuide } from "./connect-guide" + +// apps/local-ui/src/components/signal-empty-state.tsx. "No traces" means three things: filters +// excluded everything, this window is quiet, or nothing was ever sent (the on-ramp). + +const code = (h: HtmlBuilder, text: string): Html => + h.code([h.Class("rounded bg-muted px-1 font-mono text-[0.85em]")], [text]) + +const COPY: ReadonlyMap> = new Map([ + ["traces", { noun: "traces", icon: "NetworkNodesIcon" }], + ["logs", { noun: "logs", icon: "CodeIcon" }], + ["metrics", { noun: "metrics", icon: "PulseIcon" }], + ["sessions", { noun: "sessions", icon: "EyeIcon" }], +] as const) + +const sourceOf = (h: HtmlBuilder, signal: TelemetrySignal): ReadonlyArray => { + if (signal === "traces") return ["Traces come from an OpenTelemetry SDK in your app, exporting to this endpoint."] + if (signal === "logs") + return ["Logs come from an OTLP log bridge under your logger. Printing to stdout alone never reaches Maple."] + if (signal === "metrics") return ["Metrics come from an OpenTelemetry metric reader exporting to this endpoint."] + // The JSX's text nodes, `{" "}` separators included: they decide where the line breaks. + return [ + "Sessions come from the browser SDK: install", + " ", + code(h, "@maple-dev/browser"), + " and call", + " ", + code(h, "MapleBrowser.init()"), + " ", + "with this endpoint.", + ] +} + +export type SignalEmptyStateOptions = Readonly<{ + signal: TelemetrySignal + noun?: string + isFiltered?: boolean + onClearFilters?: M + range: string + onWidenRange?: M + detail?: Html + maybePresence: Option.Option + nowMs: number + guide: Omit, "note"> +}> + +export const signalEmptyState = (h: HtmlBuilder, options: SignalEmptyStateOptions): Html => { + const copy = COPY.get(options.signal) ?? { noun: options.signal, icon: "PulseIcon" } + const subject = options.noun ?? copy.noun + const canWiden = options.onWidenRange !== undefined && options.range !== WIDEST_RANGE + const detail = options.detail === undefined ? [] : [options.detail] + const widen = (variant: "outline" | "ghost") => + options.onWidenRange === undefined || !canWiden + ? [] + : [button(h, { variant, size: "sm", attributes: [h.OnClick(options.onWidenRange)] }, ["Widen to 30 days"])] + + if (options.isFiltered === true) + return empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "FilterIcon")]), + emptyTitle(h, ["No ", subject, " match these filters"]), + emptyDescription(h, [ + "Nothing in the ", + resolveRange(options.range).label, + " window matched. Clear the filters", + ...(canWiden ? [" or widen the range"] : []), + " to see more.", + ]), + ]), + emptyContent(h, [ + ...detail, + h.div( + [h.Class("flex flex-wrap items-center justify-center gap-2")], + [ + ...(options.onClearFilters === undefined + ? [] + : [button(h, { variant: "outline", size: "sm", attributes: [h.OnClick(options.onClearFilters)] }, ["Clear filters"])]), + ...widen("ghost"), + ], + ), + ]), + ], + "h-full", + ) + + return Option.match(options.maybePresence, { + // Presence unknown (loading or unreadable): state the fact, give no advice. + onNone: () => + empty( + h, + [emptyHeader(h, [emptyTitle(h, ["No ", subject, " found"])]), ...(detail.length > 0 ? [emptyContent(h, detail)] : [])], + "h-full", + ), + onSome: (presence) => + presence.status === "absent" + ? empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, copy.icon)]), + emptyTitle(h, ["No ", subject, " yet"]), + emptyDescription(h, sourceOf(h, options.signal)), + ]), + emptyContent(h, [...detail, connectGuide(h, options.guide)], "w-full max-w-md"), + ], + "h-full", + ) + : empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "ClockIcon")]), + emptyTitle(h, ["No ", subject, " in this time range"]), + emptyDescription(h, [ + presence.lastSeenMs === null + ? `Maple has ${copy.noun}, just none in the selected window.` + : `Maple last received ${copy.noun} ${formatRelativeFrom(presence.lastSeenMs, options.nowMs)}.`, + ]), + ]), + ...(detail.length > 0 || canWiden ? [emptyContent(h, [...detail, ...widen("outline")])] : []), + ], + "h-full", + ), + }) +} diff --git a/apps/local-ui-foldkit/src/page/kit/toolbar.ts b/apps/local-ui-foldkit/src/page/kit/toolbar.ts new file mode 100644 index 0000000000..fbc71013be --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/toolbar.ts @@ -0,0 +1,134 @@ +import { TIME_RANGES } from "@maple/local-ui/lib/time" +import { formatNumber } from "@maple/ui/lib/format" +import { TONE_TEXT, type Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { button } from "@maple/web-foldkit/ui/button" +import { icon } from "@maple/web-foldkit/ui/icon" +import { nativeOption, nativeSelect } from "@maple/web-foldkit/ui/native-select" +import { searchInput } from "@maple/web-foldkit/ui/search-input" +import { statusDot } from "@maple/web-foldkit/ui/status-dot" +import type { Html, HtmlBuilder } from "foldkit/html" + +// packages/ui/src/components/toolbar.tsx and apps/local-ui/src/components/toolbar.tsx. + +type Children = ReadonlyArray +const present = (children: Children): ReadonlyArray => children.filter((child) => child !== null) + +export const toolbar = (h: HtmlBuilder, children: Children, className?: string): Html => + h.div( + [h.Class(cn("flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3", className))], + present(children), + ) + +/** Right-hand cluster of `toolbarStat`s. */ +export const toolbarStats = (h: HtmlBuilder, children: Children, className?: string): Html => + h.div([h.Class(cn("flex items-center gap-4", className))], present(children)) + +export const toolbarStat = ( + h: HtmlBuilder, + options: Readonly<{ value: number | string; label: string; hasDot?: boolean; isDanger?: boolean; tone?: Tone }>, +): Html => + h.span( + [h.Class("flex items-center gap-1.5 whitespace-nowrap text-sm")], + [ + ...(options.hasDot === true ? [statusDot(h, { tone: "ok" })] : []), + h.span( + [ + h.Class( + cn( + "font-medium tabular-nums", + options.tone && TONE_TEXT[options.tone], + options.isDanger === true && + typeof options.value === "number" && + options.value > 0 && + TONE_TEXT.crit, + ), + ), + ], + [typeof options.value === "number" ? formatNumber(options.value) : options.value], + ), + h.span([h.Class("text-muted-foreground")], [options.label]), + ], + ) + +/** + * apps/local-ui's `RefreshButton`: the "New data" pill once the server took telemetry after the + * list loaded, then the shared reload button, disabled and spinning while the refresh runs. + */ +export const refreshButton = ( + h: HtmlBuilder, + options: Readonly<{ isSpinning: boolean; hasNewData: boolean; onRefresh: M; className?: string }>, +): Html => + h.span( + [h.Class(cn("flex items-center gap-1", options.className))], + [ + ...(options.hasNewData + ? [ + h.button( + [ + h.Type("button"), + h.OnClick(options.onRefresh), + h.Class( + "inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-xs font-medium whitespace-nowrap text-primary transition-colors hover:bg-primary/15", + ), + ], + [h.span([h.Class("size-1.5 rounded-full bg-primary")], []), "New data"], + ), + ] + : []), + button( + h, + { + variant: "ghost", + size: "icon-sm", + isDisabled: options.isSpinning, + attributes: [h.AriaLabel("Reload"), h.Title("Reload"), h.OnClick(options.onRefresh)], + }, + [icon(h, "ArrowRotateClockwiseIcon", options.isSpinning ? { className: "animate-spin" } : {})], + ), + ], + ) + +const RANGE_LABELS: ReadonlyMap = new Map([ + ["5m", "Last 5 minutes"], + ["15m", "Last 15 minutes"], + ["30m", "Last 30 minutes"], + ["1h", "Last 1 hour"], + ["6h", "Last 6 hours"], + ["12h", "Last 12 hours"], + ["24h", "Last 24 hours"], + ["7d", "Last 7 days"], + ["30d", "Last 30 days"], +]) + +/** `TimeRangeSelect` bound to local mode's presets. */ +export const timeRangeSelect = (h: HtmlBuilder, value: string, onChange: (next: string) => M): Html => + h.div( + [h.Class("flex items-center gap-1.5")], + [ + icon(h, "ClockIcon", { + className: "size-3.5 text-muted-foreground", + attributes: [h.Attribute("stroke-width", "2")], + }), + nativeSelect( + h, + { size: "sm", defaultValue: value, attributes: [h.AriaLabel("Time range"), h.OnChange(onChange)] }, + TIME_RANGES.map((range) => nativeOption(range.key, RANGE_LABELS.get(range.key) ?? range.label)), + ), + ], + ) + +/** `ToolbarSearch`: the draft lives in the page Model and commits after the page's debounce. */ +export const toolbarSearch = ( + h: HtmlBuilder, + options: Readonly<{ id: string; value: string; placeholder: string; onInput: (value: string) => M; onClear: M; className?: string }>, +): Html => + searchInput(h, { + id: options.id, + size: "default", + value: options.value, + placeholder: options.placeholder, + onInput: options.onInput, + onClear: options.onClear, + className: cn("max-w-sm", options.className), + }) diff --git a/apps/local-ui-foldkit/src/page/kit/view-states.ts b/apps/local-ui-foldkit/src/page/kit/view-states.ts new file mode 100644 index 0000000000..a6dc636198 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/kit/view-states.ts @@ -0,0 +1,72 @@ +import { button } from "@maple/web-foldkit/ui/button" +import { empty, emptyContent, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/web-foldkit/ui/empty" +import { icon } from "@maple/web-foldkit/ui/icon" +import { skeletonList } from "@maple/web-foldkit/ui/skeleton" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { LoadFailure } from "../../data/local-query" + +// apps/local-ui/src/components/view-states.tsx: shared loading, empty and error placeholders. + +export const emptyState = ( + h: HtmlBuilder, + options: Readonly<{ icon?: Html; title: string; hint?: ReadonlyArray; action?: Html }>, +): Html => + empty( + h, + [ + ...(options.icon === undefined ? [] : [emptyMedia(h, { variant: "icon" }, [options.icon])]), + emptyHeader(h, [ + emptyTitle(h, [options.title]), + ...(options.hint === undefined ? [] : [emptyDescription(h, options.hint)]), + ]), + ...(options.action === undefined ? [] : [emptyContent(h, [options.action])]), + ], + "h-full", + ) + +/** `ErrorState`: "Couldn't load (data: A) => data + +export const FetchEntry = Command.define("FetchLocalMetricEntry", { + args: { ...EntryRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchEntry, Message.FailedFetchEntry, Message.FoundCachedEntry], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "metrics", "entry", request.metricName, windowKey(request)], + readsCache, + fromCached: cachedAs(Schema.NullOr(MetricEntry)), + toCached: same, + fetch: fetchMetricEntry(request.metricName, request, request.apiBase), + found: (cached) => Message.FoundCachedEntry({ request, cached }), + succeeded: (entry, fetchedAtMs) => Message.SucceededFetchEntry({ key: request.key, entry, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchEntry({ key: request.key, failure })))), +}) + +export const FetchTimeseries = Command.define("FetchLocalMetricTimeseries", { + args: { ...TimeseriesRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTimeseries, Message.FailedFetchTimeseries, Message.FoundCachedTimeseries], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: [ + "local", + "metrics", + "timeseries", + request.entry.metricName, + request.entry.metricType, + request.entry.isMonotonic, + request.bucketSeconds, + windowKey(request), + ], + readsCache, + fromCached: cachedAs(Schema.Array(SeriesPoint)), + toCached: (points: ReadonlyArray) => points, + fetch: Effect.map( + fetchMetricTimeseries(request.entry, request, request.bucketSeconds, request.apiBase), + (points): ReadonlyArray => points, + ), + found: (cached) => Message.FoundCachedTimeseries({ request, cached }), + succeeded: (points, fetchedAtMs) => Message.SucceededFetchTimeseries({ key: request.key, points, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTimeseries({ key: request.key, failure })))), +}) + +export const FetchBreakdown = Command.define("FetchLocalMetricBreakdown", { + args: { ...BreakdownRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchBreakdown, Message.FailedFetchBreakdown, Message.FoundCachedBreakdown], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "metrics", "breakdown", request.entry.metricName, request.entry.metricType, windowKey(request)], + readsCache, + fromCached: cachedAs(Schema.Array(BreakdownRow)), + toCached: same, + fetch: fetchMetricBreakdown(request.entry, request, request.apiBase), + found: (cached) => Message.FoundCachedBreakdown({ request, cached }), + succeeded: (rows, fetchedAtMs) => Message.SucceededFetchBreakdown({ key: request.key, rows, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchBreakdown({ key: request.key, failure })))), +}) diff --git a/apps/local-ui-foldkit/src/page/metric-detail/fetch.ts b/apps/local-ui-foldkit/src/page/metric-detail/fetch.ts new file mode 100644 index 0000000000..2a23f66041 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/fetch.ts @@ -0,0 +1,66 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { isCounter, isMetricType } from "@maple/local-ui/lib/units" +import { DateTime, Effect, Schema } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import { foldCatalogRows, type MetricEntry } from "../metrics/catalog" + +// The queries of apps/local-ui/src/hooks/use-local-metric-detail.ts. + +/** `HARD_SERIES_LIMIT` (packages/ui/src/components/plot/timeseries.tsx, a React module). */ +const HARD_SERIES_LIMIT = 60 + +/** The chart draws at most `HARD_SERIES_LIMIT` lines, so the query caps the series too. */ +const SERIES_CAP = { groupBy: ["service"], seriesLimit: HARD_SERIES_LIMIT } + +export const SeriesPoint = Schema.Struct({ bucket: Schema.String, series: Schema.String, value: Schema.Number }) +export type SeriesPoint = typeof SeriesPoint.Type + +export const BreakdownRow = Schema.Struct({ + name: Schema.String, + avgValue: Schema.Number, + sumValue: Schema.Number, + count: Schema.Number, +}) +export type BreakdownRow = typeof BreakdownRow.Type + +/** `useLocalMetricEntry`: the catalog rows for one metric, folded across services. */ +export const fetchMetricEntry = (metricName: string, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows(CH.compile(CH.listMetricsQuery({ search: metricName, limit: 50 }), localParams(bounds)), apiBase), + (rows): MetricEntry | null => foldCatalogRows(rows.filter((r) => r.metricName === metricName)).entries[0] ?? null, + ) + +/** `useLocalMetricTimeseries`: counters plot their per-second rate, the rest their average. */ +export const fetchMetricTimeseries = (entry: MetricEntry, bounds: TimeBounds, bucketSeconds: number, apiBase: string) => + Effect.gen(function* () { + const params = { ...localParams(bounds), bucketSeconds, metricName: entry.metricName } + if (isCounter(entry)) { + const rows = yield* queryRows( + CH.compile( + CH.metricsTimeseriesRateQuery({ metricName: entry.metricName, bucketSeconds, ...SERIES_CAP }), + params, + ), + apiBase, + ) + return rows.map((r): SeriesPoint => ({ bucket: DateTime.formatIso(r.bucket), series: r.groupName || "value", value: r.rateValue })) + } + const metricType = entry.metricType + if (!isMetricType(metricType)) return [] + const rows = yield* queryRows(CH.compile(CH.metricsTimeseriesQuery({ metricType, ...SERIES_CAP }), params), apiBase) + return rows.map((r): SeriesPoint => ({ bucket: DateTime.formatIso(r.bucket), series: r.groupName || "value", value: r.avgValue })) + }) + +/** `useLocalMetricBreakdown`: the per-service table. */ +export const fetchMetricBreakdown = (entry: MetricEntry, bounds: TimeBounds, apiBase: string) => { + const metricType = entry.metricType + if (!isMetricType(metricType)) return Effect.succeed>([]) + return Effect.map( + queryRows( + CH.compile(CH.metricsBreakdownQuery({ metricType, limit: 20 }), { ...localParams(bounds), metricName: entry.metricName }), + apiBase, + ), + (rows): ReadonlyArray => + rows.map((r) => ({ name: r.name, avgValue: r.avgValue, sumValue: r.sumValue, count: r.count })), + ) +} diff --git a/apps/local-ui-foldkit/src/page/metric-detail/index.ts b/apps/local-ui-foldkit/src/page/metric-detail/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/metric-detail/lanes.ts b/apps/local-ui-foldkit/src/page/metric-detail/lanes.ts new file mode 100644 index 0000000000..1ab6165bdc --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/lanes.ts @@ -0,0 +1,10 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchBreakdown, FetchEntry, FetchTimeseries } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchEntry.name, model.entry, (key) => FetchEntry.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchTimeseries.name, model.timeseries, (key) => FetchTimeseries.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchBreakdown.name, model.breakdown, (key) => FetchBreakdown.Interrupt({ key }, outcomeOf)), +] diff --git a/apps/local-ui-foldkit/src/page/metric-detail/load.ts b/apps/local-ui-foldkit/src/page/metric-detail/load.ts new file mode 100644 index 0000000000..17e6b1a27b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/load.ts @@ -0,0 +1,76 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, type ChartWindow, chartWindow, parseClickHouseDateTime, type TimeBounds } from "@maple/local-ui/lib/time" +import { isMetricType } from "@maple/local-ui/lib/units" +import { Array, Option } from "effect" +import { AsyncData, type Command } from "foldkit" +import { keyOf, request } from "../../data/slot" +import { type PageContext, rangeOf } from "../contract" +import type { MetricEntry } from "../metrics/catalog" +import { FetchBreakdown, FetchEntry, FetchTimeseries } from "./command" +import type { Message } from "./message" +import type { Model } from "./model" + +type Step = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +const boundsKey = (bounds: TimeBounds) => `${bounds.startTime}/${bounds.endTime}` + +/** The entry on screen (a previous window's while the new one loads), or null. */ +export const entryOf = (model: Model): MetricEntry | null => + Option.getOrNull(Option.flatMap(AsyncData.getData(model.entry.data), Option.fromNullishOr)) + +/** `chartWindow(bounds, firstSeenMs)`: the range clipped to when the metric first reported. */ +export const chartOf = (model: Model, bounds: TimeBounds): ChartWindow => + chartWindow(bounds, parseClickHouseDateTime(entryOf(model)?.firstSeen), model.nowMs) + +/** The timeseries and the breakdown read the entry: nothing to ask for until one exists. */ +export const loadDependents = (model: Model, context: PageContext, force = false): Step => { + const entry = entryOf(model) + const maybeBounds = boundsOf(model, context) + if (entry === null || Option.isNone(maybeBounds)) return { model } + const bounds = maybeBounds.value + const apiBase = context.env.apiBase + const { bucketSeconds } = chartOf(model, bounds) + const identity = { metricName: entry.metricName, metricType: entry.metricType, bounds: boundsKey(bounds) } + const timeseriesKey = keyOf("metric-timeseries", { ...identity, isMonotonic: entry.isMonotonic, bucketSeconds }) + const breakdownKey = keyOf("metric-breakdown", identity) + const timeseries = request(model.timeseries, timeseriesKey, force) + const breakdown = isMetricType(entry.metricType) ? request(model.breakdown, breakdownKey, force) : Option.none() + return { + model: { + ...model, + timeseries: Option.getOrElse(timeseries, () => model.timeseries), + breakdown: Option.getOrElse(breakdown, () => model.breakdown), + }, + commands: Array.getSomes([ + Option.map(timeseries, () => + FetchTimeseries({ key: timeseriesKey, entry, bucketSeconds, ...bounds, apiBase, readsCache: !force && model.timeseries.key !== timeseriesKey }), + ), + Option.map(breakdown, () => FetchBreakdown({ key: breakdownKey, entry, ...bounds, apiBase, readsCache: !force && model.breakdown.key !== breakdownKey })), + ]), + } +} + +export const load = (model: Model, context: PageContext, force = false): Step => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const key = keyOf("metric-entry", { metricName: model.metricName, bounds: boundsKey(bounds) }) + const entry = request(model.entry, key, force) + const next = { ...model, entry: Option.getOrElse(entry, () => model.entry) } + const dependents = loadDependents(next, context, force) + return { + model: dependents.model, + commands: [ + ...Array.getSomes([ + Option.map(entry, () => + FetchEntry({ key, metricName: model.metricName, ...bounds, apiBase: context.env.apiBase, readsCache: !force && model.entry.key !== key }), + ), + ]), + ...(dependents.commands ?? []), + ], + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/metric-detail/message.ts b/apps/local-ui-foldkit/src/page/metric-detail/message.ts new file mode 100644 index 0000000000..94911a7d7b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/message.ts @@ -0,0 +1,33 @@ +import * as Chart from "@maple/web-foldkit/charts/chart" +import { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { BreakdownRequest, EntryRequest, TimeseriesRequest } from "./requests" +import { MetricEntry } from "../metrics/catalog" +import { BreakdownRow, SeriesPoint } from "./fetch" + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchEntry: { key: Schema.String, entry: Schema.NullOr(MetricEntry), fetchedAtMs: Schema.Number }, + FailedFetchEntry: { key: Schema.String, failure: LoadFailure }, + /** The result cache answered first; `request` is the read the fetch then repeats. */ + FoundCachedEntry: { request: EntryRequest, cached: CachedOf(Schema.NullOr(MetricEntry)) }, + SucceededFetchTimeseries: { key: Schema.String, points: Schema.Array(SeriesPoint), fetchedAtMs: Schema.Number }, + FailedFetchTimeseries: { key: Schema.String, failure: LoadFailure }, + FoundCachedTimeseries: { request: TimeseriesRequest, cached: CachedOf(Schema.Array(SeriesPoint)) }, + SucceededFetchBreakdown: { key: Schema.String, rows: Schema.Array(BreakdownRow), fetchedAtMs: Schema.Number }, + FailedFetchBreakdown: { key: Schema.String, failure: LoadFailure }, + FoundCachedBreakdown: { request: BreakdownRequest, cached: CachedOf(Schema.Array(BreakdownRow)) }, + ClickedBack: {}, + ClickedRefresh: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + ResolvedThemeColors: { colors: ThemeColors }, + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, + GotChartMessage: { message: Chart.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/metric-detail/metric-detail.scene.test.ts b/apps/local-ui-foldkit/src/page/metric-detail/metric-detail.scene.test.ts new file mode 100644 index 0000000000..9976695747 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/metric-detail.scene.test.ts @@ -0,0 +1,83 @@ +import { DateTime, Option } from "effect" +import { ResolveThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../contract" +import { PageOutMessage } from "../contract" +import type { MetricEntry } from "../metrics/catalog" +import { Message } from "./message" +import type { Model } from "./model" +import type { PageViewInputs } from "../kit/inputs" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T12:00:00Z")) +const env = { apiBase: "", port: "4318", otlpEndpoint: "http://localhost:4318", isHosted: false } +const contextAt = (search: string, canGoBack = false): PageContext => ({ + env, + location: { path: "/metrics/http.server.request.duration", search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack, +}) +const context = contextAt("range=1h&spanId=abc") +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +const entry: MetricEntry = { + metricName: "http.server.request.duration", + metricType: "histogram", + metricUnit: "s", + metricDescription: "", + serviceNames: ["checkout-api"], + dataPointCount: 183, + firstSeen: "2026-10-01T11:00:00.000Z", + lastSeen: "2026-10-01T12:00:00.000Z", + isMonotonic: false, +} + +const entered = () => + pageUpdate(init(context, "http.server.request.duration").model, Message.CompletedReadClock({ nowMs: NOW })) + +describe("local metric detail", () => { + test("entry asks for the metric's catalog entry, then its timeseries and breakdown", () => { + const step = entered() + expect(names(step.commands)).toEqual(["FetchLocalMetricEntry"]) + const found = pageUpdate( + step.model, + Message.SucceededFetchEntry({ key: step.model.entry.key ?? "", entry, fetchedAtMs: NOW }), + ) + expect(names(found.commands)).toEqual(["FetchLocalMetricTimeseries", "FetchLocalMetricBreakdown"]) + }) + + test("an entry answer for a superseded window is dropped", () => { + const first = entered().model + const later = pageUpdate(first, Message.CompletedReadRefreshClock({ nowMs: NOW + 120_000 })).model + const dropped = pageUpdate(later, Message.SucceededFetchEntry({ key: first.entry.key ?? "", entry, fetchedAtMs: NOW })) + expect(dropped.model.entry).toEqual(later.entry) + expect(names(dropped.commands)).toEqual([]) + }) + + test("a missing metric asks for nothing more", () => { + const step = entered() + const missing = pageUpdate(step.model, Message.SucceededFetchEntry({ key: step.model.entry.key ?? "", entry: null, fetchedAtMs: NOW })) + expect(names(missing.commands)).toEqual([]) + }) + + test("back without in-app history opens the list, filters kept, detail params dropped", () => { + const back = pageUpdate(entered().model, Message.ClickedBack()) + expect(back.outMessage).toEqual(PageOutMessage.RequestedBack({ fallbackHash: "#/metrics?range=1h" })) + }) + + test("last seen reads the root's newer clock, as legacy reads the time at each render", () => { + const step = entered() + const found = pageUpdate(step.model, Message.SucceededFetchEntry({ key: step.model.entry.key ?? "", entry, fetchedAtMs: NOW })).model + const inputs: PageViewInputs = { context, presenceOf: () => Option.none(), presenceNowMs: NOW, clockMs: NOW + 20 * 60_000, hasNewDataSince: () => false } + scene( + { update: pageUpdate, view: withViewInputs(pageView, inputs)() }, + given(found), + Mount.resolve(ResolveThemeColors, Message.ResolvedThemeColors({ colors: {} })), + expectView(text(/20m ago/)).toExist(), + ) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/metric-detail/model.ts b/apps/local-ui-foldkit/src/page/metric-detail/model.ts new file mode 100644 index 0000000000..375189b1db --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/model.ts @@ -0,0 +1,24 @@ +import * as Chart from "@maple/web-foldkit/charts/chart" +import { ChartRuntime } from "@maple/web-foldkit/charts/chart-runtime" +import { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { MetricEntry } from "../metrics/catalog" +import { BreakdownRow, SeriesPoint } from "./fetch" + +export const Model = Schema.TaggedStruct("MetricDetailPage", { + metricName: Schema.String, + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + /** The clock at the last read: `chartWindow`'s "now" and relative times. */ + nowMs: Schema.Number, + entry: SlotSchema(Schema.NullOr(MetricEntry)), + entryUpdatedAtMs: Schema.Number, + timeseries: SlotSchema(Schema.Array(SeriesPoint)), + breakdown: SlotSchema(Schema.Array(BreakdownRow)), + isRefreshing: Schema.Boolean, + chart: Chart.Model, + themeColors: Schema.NullOr(ThemeColors), + chartRuntime: ChartRuntime, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/metric-detail/requests.ts b/apps/local-ui-foldkit/src/page/metric-detail/requests.ts new file mode 100644 index 0000000000..57564e5241 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/requests.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect" +import { MetricEntry } from "../metrics/catalog" + +// What each metric detail read asks for; a cached answer carries it back so the fetch can follow. + +const Window = { key: Schema.String, startTime: Schema.String, endTime: Schema.String, apiBase: Schema.String } + +export const EntryRequest = Schema.Struct({ ...Window, metricName: Schema.String }) +export const TimeseriesRequest = Schema.Struct({ ...Window, entry: MetricEntry, bucketSeconds: Schema.Number }) +export const BreakdownRequest = Schema.Struct({ ...Window, entry: MetricEntry }) diff --git a/apps/local-ui-foldkit/src/page/metric-detail/update.ts b/apps/local-ui-foldkit/src/page/metric-detail/update.ts new file mode 100644 index 0000000000..0088a7b10f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/update.ts @@ -0,0 +1,144 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as Chart from "@maple/web-foldkit/charts/chart" +import { LoadChartRuntime } from "@maple/web-foldkit/charts/chart-runtime" +import { Option, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { accept, acceptCached, idle } from "../../data/slot" +import { hrefFor, queryOf } from "../../route" +import { type PageContext, PageOutMessage, withParams } from "../contract" +import { FetchBreakdown, FetchEntry, FetchTimeseries, ReadClock, ReadRefreshClock } from "./command" +import { load, loadDependents } from "./load" +import { Message } from "./message" +import type { Model } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +export const init = ( + _context: PageContext, + metricName: string, +): Readonly<{ model: Model; commands: ReadonlyArray> }> => { + return { + model: { + _tag: "MetricDetailPage", + metricName, + maybeAnchorMs: Option.none(), + nowMs: 0, + entry: idle(), + entryUpdatedAtMs: 0, + timeseries: idle(), + breakdown: idle(), + isRefreshing: false, + chart: Chart.init(), + themeColors: null, + chartRuntime: { _tag: "Loading" }, + }, + commands: [ + ReadClock(), + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], + } +} + +/** A search change (range) keeps the instance and re-resolves the window's keys. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => load(model, context) + +/** Detail-page params that must not leak back into the list's filters. */ +const DETAIL_ONLY_PARAMS = ["spanId", "view"] + +/** `back("/metrics")`: history back within the app, else the list with the filters intact. */ +export const backFallbackHash = (context: PageContext): string => { + const fallback = queryOf(context.location) + DETAIL_ONLY_PARAMS.forEach((key) => fallback.delete(key)) + return hrefFor("/metrics", fallback) +} + +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + return model.isRefreshing && + !isLoading(model.entry.data) && + !isLoading(model.timeseries.data) && + !isLoading(model.breakdown.data) + ? { ...model, isRefreshing: false } + : model +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadClock: ({ nowMs }) => + load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + SucceededFetchEntry: ({ key, entry, fetchedAtMs }) => { + const isCurrent = model.entry.key === key + const next = { + ...model, + entry: accept(model.entry, key, Result.succeed(entry), fetchedAtMs), + entryUpdatedAtMs: isCurrent ? fetchedAtMs : model.entryUpdatedAtMs, + } + const dependents = loadDependents(next, context) + return { model: settleRefresh(dependents.model), commands: dependents.commands } + }, + FailedFetchEntry: ({ key, failure }) => + ({ model: settleRefresh({ ...model, entry: accept(model.entry, key, Result.fail(failure)) }) }), + SucceededFetchTimeseries: ({ key, points, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, timeseries: accept(model.timeseries, key, Result.succeed(points), fetchedAtMs) }) }), + FailedFetchTimeseries: ({ key, failure }) => + ({ model: settleRefresh({ ...model, timeseries: accept(model.timeseries, key, Result.fail(failure)) }) }), + SucceededFetchBreakdown: ({ key, rows, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, breakdown: accept(model.breakdown, key, Result.succeed(rows), fetchedAtMs) }) }), + FoundCachedEntry: ({ request, cached }) => { + if (model.entry.key !== request.key) return { model } + const next = { ...model, entry: acceptCached(model.entry, request.key, cached, cached.isFresh), entryUpdatedAtMs: cached.updatedAtMs } + const dependents = loadDependents(next, context) + const fetch = cached.isFresh ? [] : [FetchEntry({ ...request, readsCache: false })] + return { model: settleRefresh(dependents.model), commands: [...fetch, ...(dependents.commands ?? [])] } + }, + FoundCachedTimeseries: ({ request, cached }) => + model.timeseries.key === request.key + ? { + model: settleRefresh({ ...model, timeseries: acceptCached(model.timeseries, request.key, cached, cached.isFresh) }), + commands: cached.isFresh ? [] : [FetchTimeseries({ ...request, readsCache: false })], + } + : { model }, + FoundCachedBreakdown: ({ request, cached }) => + model.breakdown.key === request.key + ? { + model: settleRefresh({ ...model, breakdown: acceptCached(model.breakdown, request.key, cached, cached.isFresh) }), + commands: cached.isFresh ? [] : [FetchBreakdown({ ...request, readsCache: false })], + } + : { model }, + FailedFetchBreakdown: ({ key, failure }) => + ({ model: settleRefresh({ ...model, breakdown: accept(model.breakdown, key, Result.fail(failure)) }) }), + ClickedBack: () => ({ model, outMessage: PageOutMessage.RequestedBack({ fallbackHash: backFallbackHash(context) }) }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), + FailedLoadChartRuntime: ({ reason }) => ({ model: { ...model, chartRuntime: { _tag: "Failed", reason } } }), + GotChartMessage: ({ message: child }) => { + const next = Chart.update(model.chart, child) + return { + model: { ...model, chart: next.model }, + commands: Command.mapMessages(next.commands ?? [], (chart) => Message.GotChartMessage({ message: chart })), + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/metric-detail/view-body.ts b/apps/local-ui-foldkit/src/page/metric-detail/view-body.ts new file mode 100644 index 0000000000..84e75de6d2 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/view-body.ts @@ -0,0 +1,138 @@ +import { parseClickHouseDateTime, type TimeBounds } from "@maple/local-ui/lib/time" +import { chartUnitFromOtel, humanizeUnit, isCounter } from "@maple/local-ui/lib/units" +import { EMPTY_VALUE, formatNumber, formatValueByUnit } from "@maple/ui/lib/format" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { isChartRuntimeReady } from "@maple/web-foldkit/charts/chart-runtime" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/web-foldkit/ui/table" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { seriesChart } from "../kit/series-chart" +import { errorState } from "../kit/view-states" +import type { MetricEntry } from "../metrics/catalog" +import type { BreakdownRow } from "./fetch" +import { chartOf } from "./load" +import { Message } from "./message" +import type { Model } from "./model" + +// The body of `MetricDetailView`: the chart section and the per-service breakdown. + +type H = HtmlBuilder + +const relative = (value: string, nowMs: number): string => { + const ms = parseClickHouseDateTime(value) + return ms === null ? "-" : formatRelativeFrom(ms, nowMs) +} + +const chartSection = (h: H, model: Model, entry: MetricEntry, bounds: TimeBounds, clockMs: number): Html => { + const counter = isCounter(entry) + const unitLabel = humanizeUnit(entry.metricUnit, entry.metricName) + const chartUnit = counter ? undefined : chartUnitFromOtel(entry.metricUnit, entry.metricName) + const title = counter + ? `Rate (${unitLabel ? `${unitLabel} ` : ""}per second)` + : `Average value${unitLabel ? ` (${unitLabel})` : ""}` + const failure = failureWithoutData(model.timeseries) + return h.section( + [h.Class("space-y-2")], + [ + h.div( + [h.Class("flex flex-wrap items-baseline justify-between gap-2")], + [ + h.h3([h.Class("text-sm font-medium")], [title, " ", "by service"]), + h.span( + [h.Class("text-xs text-muted-foreground")], + [ + entry.serviceNames.length.toLocaleString(), + " services ·", + " ", + formatNumber(entry.dataPointCount), + " datapoints · last seen", + " ", + relative(entry.lastSeen, Math.max(model.nowMs, clockMs)), + ], + ), + ], + ), + Option.isSome(failure) + ? errorState(h, { label: "timeseries", failure: failure.value, onRetry: Message.ClickedRefresh() }) + : seriesChart(h, { + chartId: "local-metric-detail", + points: Option.getOrUndefined(AsyncData.getData(model.timeseries.data)), + window: chartOf(model, bounds), + fill: counter ? "zero" : "sparse", + isPending: isPending(model.timeseries), + heightClassName: "h-64", + unit: chartUnit, + fitYAxisToData: !counter, + chart: model.chart, + toChartMessage: (message) => Message.GotChartMessage({ message }), + theme: isChartRuntimeReady(model.chartRuntime) ? model.themeColors : null, + nowMs: model.nowMs, + }), + ], + ) +} + +const breakdownRow = (h: H, row: BreakdownRow, chartUnit: string | undefined, showSum: boolean): Html => + tableRow(h, [ + tableCell( + h, + [ + row.name + ? h.span([h.Class("flex items-center gap-1.5")], [serviceDot(h, { serviceName: row.name, size: "sm" }), row.name]) + : EMPTY_VALUE, + ], + "font-mono text-xs", + ), + tableCell(h, [formatValueByUnit(row.avgValue, chartUnit)], "text-right tabular-nums"), + ...(showSum ? [tableCell(h, [formatValueByUnit(row.sumValue, chartUnit)], "text-right tabular-nums")] : []), + tableCell(h, [row.count.toLocaleString()], "text-right tabular-nums"), + ]) + +const breakdownSection = (h: H, model: Model, entry: MetricEntry): Html => { + const counter = isCounter(entry) + const chartUnit = counter ? undefined : chartUnitFromOtel(entry.metricUnit, entry.metricName) + // A total of cumulative counter readings means nothing; histograms' sums do. + const showSum = entry.metricType === "histogram" || entry.metricType === "exponential_histogram" + const failure = failureWithoutData(model.breakdown) + const body = (): Html => { + if (isPending(model.breakdown)) + return h.div([h.Class("flex h-24 items-center justify-center rounded-md border")], [spinner(h)]) + if (Option.isSome(failure)) + return errorState(h, { label: "breakdown", failure: failure.value, onRetry: Message.ClickedRefresh() }) + const rows = Option.getOrElse(AsyncData.getData(model.breakdown.data), () => []) + return h.div( + [h.Class("rounded-md border")], + [ + table(h, {}, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Service"]), + tableHead(h, [counter ? "Avg reading" : "Avg"], "text-right"), + ...(showSum ? [tableHead(h, ["Sum"], "text-right")] : []), + tableHead(h, ["Datapoints"], "text-right"), + ]), + ]), + tableBody(h, rows.map((row) => breakdownRow(h, row, chartUnit, showSum))), + ]), + ], + ) + } + return h.section([h.Class("space-y-2")], [h.h3([h.Class("text-sm font-medium")], ["Breakdown by service"]), body()]) +} + +/** The loaded metric: description, chart and breakdown. */ +export const detailBody = (h: H, model: Model, entry: MetricEntry, bounds: TimeBounds, clockMs: number): Html => + h.div( + [h.Class("space-y-6 p-4")], + [ + ...(entry.metricDescription + ? [h.p([h.Class("text-sm text-muted-foreground")], [entry.metricDescription])] + : []), + chartSection(h, model, entry, bounds, clockMs), + breakdownSection(h, model, entry), + ], + ) diff --git a/apps/local-ui-foldkit/src/page/metric-detail/view-header.ts b/apps/local-ui-foldkit/src/page/metric-detail/view-header.ts new file mode 100644 index 0000000000..9c506b8eb8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/view-header.ts @@ -0,0 +1,49 @@ +import { humanizeUnit } from "@maple/local-ui/lib/units" +import { metricTypeBadge } from "@maple/web-foldkit/page/metrics/shared" +import { button } from "@maple/web-foldkit/ui/button" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { truncatedText } from "@maple/web-foldkit/ui/truncated-text" +import type { Html, HtmlBuilder } from "foldkit/html" +import { refreshButton, timeRangeSelect } from "../kit/toolbar" +import type { MetricEntry } from "../metrics/catalog" +import { Message } from "./message" +import type { Model } from "./model" + +// The header row of `MetricDetailView` (apps/local-ui/src/views/metric-detail-view.tsx). + +export const detailHeader = ( + h: HtmlBuilder, + model: Model, + options: Readonly<{ entry: MetricEntry | null; backLabel: string; range: string; hasNewData: boolean }>, +): Html => { + const { entry } = options + const unitLabel = entry === null ? "" : humanizeUnit(entry.metricUnit, entry.metricName) + return h.div( + [h.Class("flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-4 py-2")], + [ + button(h, { variant: "ghost", size: "sm", className: "gap-1.5", attributes: [h.OnClick(Message.ClickedBack())] }, [ + icon(h, "ArrowLeftIcon"), + options.backLabel, + ]), + truncatedText(h, { text: model.metricName, mono: true, className: "text-xs" }), + ...(entry === null ? [] : [metricTypeBadge(h, entry.metricType)]), + ...(unitLabel + ? [ + badge( + h, + { variant: "outline", size: "xs", mono: true, attributes: entry === null ? [] : [h.Title(entry.metricUnit)] }, + [unitLabel], + ), + ] + : []), + h.div( + [h.Class("ml-auto flex items-center gap-2")], + [ + refreshButton(h, { isSpinning: model.isRefreshing, hasNewData: options.hasNewData, onRefresh: Message.ClickedRefresh() }), + timeRangeSelect(h, options.range, (range) => Message.ChangedRange({ range })), + ], + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/metric-detail/view.ts b/apps/local-ui-foldkit/src/page/metric-detail/view.ts new file mode 100644 index 0000000000..a76793b47a --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metric-detail/view.ts @@ -0,0 +1,64 @@ +import { WIDEST_RANGE } from "@maple/local-ui/lib/time" +import { ResolveThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { button } from "@maple/web-foldkit/ui/button" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { SERIES_CHART_TOKENS } from "../kit/series-chart" +import { emptyState, errorState } from "../kit/view-states" +import { boundsOf, entryOf } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { detailBody } from "./view-body" +import { detailHeader } from "./view-header" + +// `MetricDetailView` (apps/local-ui/src/views/metric-detail-view.tsx). + +export const view = Submodel.defineView((model, inputs, h) => { + const { context } = inputs + const range = rangeOf(context) + const entry = entryOf(model) + const content = (): Html => { + if (isPending(model.entry)) return h.div([h.Class("flex h-full items-center justify-center")], [spinner(h)]) + const failure = failureWithoutData(model.entry) + if (Option.isSome(failure)) + return errorState(h, { label: "metric", failure: failure.value, onRetry: Message.ClickedRefresh() }) + const maybeBounds = boundsOf(model, context) + if (entry === null || Option.isNone(maybeBounds)) + return h.div( + [h.Class("flex h-full flex-col items-center justify-center gap-3")], + [ + emptyState(h, { + title: "Metric not found", + hint: ["Nothing reported this metric in the selected time range."], + }), + ...(range !== WIDEST_RANGE + ? [ + button( + h, + { variant: "outline", size: "sm", attributes: [h.OnClick(Message.ClickedWidenRange())] }, + ["Widen to 30 days"], + ), + ] + : []), + ], + ) + return detailBody(h, model, entry, maybeBounds.value, inputs.clockMs) + } + return h.div( + [h.Class("flex h-full flex-col"), h.OnMount(ResolveThemeColors({ tokens: SERIES_CHART_TOKENS }))], + [ + detailHeader(h, model, { + entry, + backLabel: context.canGoBack ? "Back" : "Metrics", + range, + hasNewData: inputs.hasNewDataSince(model.entryUpdatedAtMs), + }), + h.div([h.Class("min-h-0 flex-1 overflow-auto")], [content()]), + ], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/metrics/cache.ts b/apps/local-ui-foldkit/src/page/metrics/cache.ts new file mode 100644 index 0000000000..6515b7d4ef --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/cache.ts @@ -0,0 +1,24 @@ +import { Array, Option, Schema } from "effect" +import type { CacheValue } from "../../data/cache-store" +import { cachedAs } from "../../data/cached" +import { MetricsListData } from "./catalog" +import { type MetricSparkline, SparklinePoint, SummaryRow } from "./fetch" + +// The metrics hooks' data as the result cache keeps them (keys are built in the Commands). + +export const listFromCached = cachedAs(MetricsListData) +export const summaryFromCached = cachedAs(Schema.Array(SummaryRow)) + +const isPoints = Schema.is(Schema.toType(Schema.Array(SparklinePoint))) + +/** Legacy caches the previews as `ReadonlyMap`. */ +export const sparklinesToCached = (sparklines: ReadonlyArray) => + new Map(sparklines.map((sparkline) => [sparkline.metricName, sparkline.points])) + +export const sparklinesFromCached = (value: CacheValue): Option.Option> => { + if (!(value instanceof Map)) return Option.none() + const entries = Array.fromIterable(value.entries()).map(([metricName, points]) => + typeof metricName === "string" && isPoints(points) ? Option.some({ metricName, points }) : Option.none(), + ) + return Option.all(entries) +} diff --git a/apps/local-ui-foldkit/src/page/metrics/catalog.ts b/apps/local-ui-foldkit/src/page/metrics/catalog.ts new file mode 100644 index 0000000000..557cb7724a --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/catalog.ts @@ -0,0 +1,67 @@ +import type { CH } from "@maple/query-engine" +import { DateTime, Schema } from "effect" + +// `MetricEntry` and `foldCatalogRows` (apps/local-ui/src/hooks/use-local-metrics.ts, a React module). + +/** One metric across all reporting services, aggregated from the catalog rows. */ +export const MetricEntry = Schema.Struct({ + metricName: Schema.String, + metricType: Schema.String, + metricUnit: Schema.String, + metricDescription: Schema.String, + serviceNames: Schema.Array(Schema.String), + dataPointCount: Schema.Number, + firstSeen: Schema.String, + lastSeen: Schema.String, + isMonotonic: Schema.Boolean, +}) +export type MetricEntry = typeof MetricEntry.Type + +export const FacetOption = Schema.Struct({ name: Schema.String, count: Schema.Number }) +export type FacetOption = typeof FacetOption.Type + +export const MetricsListData = Schema.Struct({ + entries: Schema.Array(MetricEntry), + serviceFacets: Schema.Array(FacetOption), +}) +export type MetricsListData = typeof MetricsListData.Type + +type Mutable = { -readonly [K in keyof MetricEntry]: MetricEntry[K] } & { serviceNames: Array } + +/** Fold per-service catalog rows into one entry per (metric, type). */ +export const foldCatalogRows = (rows: ReadonlyArray): MetricsListData => { + const byMetric = new Map() + const byService = new Map>() + rows.forEach((row) => { + const key = `${row.metricName}\x00${row.metricType}` + const existing = byMetric.get(key) + if (existing) { + if (!existing.serviceNames.includes(row.serviceName)) existing.serviceNames.push(row.serviceName) + existing.dataPointCount += row.dataPointCount + // ISO strings of one format compare chronologically. + const lastSeen = DateTime.formatIso(row.lastSeen) + const firstSeen = DateTime.formatIso(row.firstSeen) + if (lastSeen > existing.lastSeen) existing.lastSeen = lastSeen + if (firstSeen < existing.firstSeen) existing.firstSeen = firstSeen + } else { + byMetric.set(key, { + metricName: row.metricName, + metricType: row.metricType, + metricUnit: row.metricUnit, + metricDescription: row.metricDescription, + serviceNames: [row.serviceName], + dataPointCount: row.dataPointCount, + firstSeen: DateTime.formatIso(row.firstSeen), + lastSeen: DateTime.formatIso(row.lastSeen), + isMonotonic: Number(row.isMonotonic) === 1, + }) + } + const metricsOfService = byService.get(row.serviceName) ?? new Set() + metricsOfService.add(key) + byService.set(row.serviceName, metricsOfService) + }) + const serviceFacets = [...byService.entries()] + .map(([name, metrics]) => ({ name, count: metrics.size })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + return { entries: [...byMetric.values()], serviceFacets } +} diff --git a/apps/local-ui-foldkit/src/page/metrics/command.ts b/apps/local-ui-foldkit/src/page/metrics/command.ts new file mode 100644 index 0000000000..c9afdc4478 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/command.ts @@ -0,0 +1,91 @@ +import { listFromCached, sparklinesFromCached, sparklinesToCached, summaryFromCached } from "./cache" +import { withCache } from "../../data/cached" +import { Clock, Duration, Effect, Queue, Schema, Stream } from "effect" +import { Command, Mount } from "foldkit" +import { fetchMetricsList, fetchMetricsSummary, fetchSparklines } from "./fetch" +import { Message } from "./message" +import { ListRequest, SparklinesRequest, SummaryRequest } from "./requests" + + +export const ReadClock = Command.define("ReadMetricsClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadMetricsRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + + +export const FetchList = Command.define("FetchLocalMetricsList", { + args: { ...ListRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchList, Message.FailedFetchList, Message.FoundCachedList], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "metrics", "list", request.type ?? null, request.search ?? null, `${request.startTime}/${request.endTime}`], + readsCache, + fromCached: listFromCached, + toCached: (data) => data, + fetch: fetchMetricsList({ type: request.type, search: request.search }, request, request.apiBase), + found: (cached) => Message.FoundCachedList({ request, cached }), + succeeded: (data, fetchedAtMs) => Message.SucceededFetchList({ key: request.key, data, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchList({ key: request.key, failure })))), +}) + +export const FetchSummary = Command.define("FetchLocalMetricsSummary", { + args: { ...SummaryRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchSummary, Message.FailedFetchSummary, Message.FoundCachedSummary], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "metrics", "summary", request.service ?? null, `${request.startTime}/${request.endTime}`], + readsCache, + fromCached: summaryFromCached, + toCached: (rows) => rows, + fetch: fetchMetricsSummary(request.service, request, request.apiBase), + found: (cached) => Message.FoundCachedSummary({ request, cached }), + succeeded: (rows, fetchedAtMs) => Message.SucceededFetchSummary({ key: request.key, rows, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchSummary({ key: request.key, failure })))), +}) + +export const FetchSparklines = Command.define("FetchLocalMetricsSparklines", { + args: { ...SparklinesRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchSparklines, Message.FailedFetchSparklines, Message.FoundCachedSparklines], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "metrics", "sparklines", request.groups, request.bucketSeconds, `${request.startTime}/${request.endTime}`], + readsCache, + fromCached: sparklinesFromCached, + toCached: sparklinesToCached, + fetch: fetchSparklines(request.groups, request, request.bucketSeconds, request.apiBase), + found: (cached) => Message.FoundCachedSparklines({ request, cached }), + succeeded: (sparklines, fetchedAtMs) => Message.SucceededFetchSparklines({ key: request.key, sparklines, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchSparklines({ key: request.key, failure })))), +}) + +export const WaitSearchDebounce = Command.define("WaitMetricsSearchDebounce", { + args: { version: Schema.Number }, + messages: [Message.ElapsedSearchDebounce], + execute: ({ version }) => Effect.as(Effect.sleep(Duration.millis(300)), Message.ElapsedSearchDebounce({ version })), +}) + +/** The grid's `ResizeObserver` (a callback ref in React): its width picks the column count. */ +export const MeasureGrid = Mount.defineStream("MeasureLocalMetricsGrid", { + messages: [Message.MeasuredGrid], + execute: ({ element }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const observer = new ResizeObserver((entries) => + Queue.offerUnsafe(queue, Message.MeasuredGrid({ width: entries[0]?.contentRect.width ?? 0 })), + ) + observer.observe(element) + return observer + }), + (observer) => Effect.sync(() => observer.disconnect()), + ).pipe(Effect.flatMap(() => Effect.never)), + ), +}) diff --git a/apps/local-ui-foldkit/src/page/metrics/derive.ts b/apps/local-ui-foldkit/src/page/metrics/derive.ts new file mode 100644 index 0000000000..6e11881128 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/derive.ts @@ -0,0 +1,52 @@ +import { type ChartWindow, chartWindow, parseClickHouseDateTime, type TimeBounds } from "@maple/local-ui/lib/time" +import { isCounter } from "@maple/local-ui/lib/units" +import type { MetricEntry } from "./catalog" +import type { SparklineGroup, SparklinePoint } from "./fetch" + +// Pure parts of apps/local-ui/src/hooks/use-local-metrics.ts and metrics-list-view.tsx. + +/** `sparklineWindow`: the range clipped to when the oldest listed metric started. */ +export const sparklineWindow = (entries: ReadonlyArray, bounds: TimeBounds, nowMs: number): ChartWindow => { + const firstSeen = entries.reduce((earliest, entry) => { + const ms = parseClickHouseDateTime(entry.firstSeen) + return ms !== null && (earliest === null || ms < earliest) ? ms : earliest + }, null) + return chartWindow(bounds, firstSeen, nowMs) +} + +/** The sparkline groups: metric names by type, types sorted. */ +export const sparklineGroups = (entries: ReadonlyArray): ReadonlyArray => { + const byType = new Map>() + entries.forEach((entry) => { + const names = byType.get(entry.metricType) + if (names) names.push(entry.metricName) + else byType.set(entry.metricType, [entry.metricName]) + }) + return [...byType.entries()].sort(([a], [b]) => a.localeCompare(b)) +} + +/** + * `previewValues`: counters preview their rise per bucket (clamped at resets), the rate's shape; + * everything else its average. + */ +export const previewValues = ( + entry: Pick, + points: ReadonlyArray, +): ReadonlyArray => { + if (!isCounter(entry)) return points.map((point) => point.avgValue) + return points.slice(1).map((point, index) => Math.max(0, point.avgValue - (points[index]?.avgValue ?? 0))) +} + +/** `StatSparkline`'s values: non-finite readings draw as 0. */ +export const sparklineValues = (values: ReadonlyArray): ReadonlyArray => + values.map((value) => (Number.isFinite(value) ? value : 0)) + +/** `columnsFor`: the grid's columns for the scroll container's measured width. */ +export const columnsFor = (width: number): number => { + if (width >= 1000) return 3 + if (width >= 560) return 2 + return 1 +} + +/** Card height plus the grid gap, the virtualizer's row estimate. */ +export const CARD_ROW_HEIGHT = 124 + 12 diff --git a/apps/local-ui-foldkit/src/page/metrics/fetch.ts b/apps/local-ui-foldkit/src/page/metrics/fetch.ts new file mode 100644 index 0000000000..19d892a7d4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/fetch.ts @@ -0,0 +1,86 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { isMetricType } from "@maple/local-ui/lib/units" +import { DateTime, Effect, Schema } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import { foldCatalogRows } from "./catalog" + +// The queries of apps/local-ui/src/hooks/use-local-metrics.ts. + +export const SummaryRow = Schema.Struct({ + metricType: Schema.String, + metricCount: Schema.Number, + dataPointCount: Schema.Number, +}) +export type SummaryRow = typeof SummaryRow.Type + +export const SparklinePoint = Schema.Struct({ + bucket: Schema.String, + avgValue: Schema.Number, + sumValue: Schema.Number, + dataPointCount: Schema.Number, +}) +export type SparklinePoint = typeof SparklinePoint.Type + +/** The batched previews, by metric name in arrival order (`ReadonlyMap` as data). */ +export const MetricSparkline = Schema.Struct({ metricName: Schema.String, points: Schema.Array(SparklinePoint) }) +export type MetricSparkline = typeof MetricSparkline.Type + +/** One sparkline query per metric type: `[metricType, metricNames]`, sorted by type. */ +export const SparklineGroup = Schema.Tuple([Schema.String, Schema.Array(Schema.String)]) +export type SparklineGroup = typeof SparklineGroup.Type + +/** `useLocalMetricsList`: the catalog over the hourly rollup (the service filter is client-side). */ +export const fetchMetricsList = ( + filters: Readonly<{ type?: string; search?: string }>, + bounds: TimeBounds, + apiBase: string, +) => + Effect.map( + queryRows( + CH.compile( + CH.listMetricsQuery({ metricType: filters.type, search: filters.search, limit: 500 }), + localParams(bounds), + ), + apiBase, + ), + foldCatalogRows, + ) + +/** `useLocalMetricsSummary`: per-type metric and datapoint counts. */ +export const fetchMetricsSummary = (service: string | undefined, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows(CH.compile(CH.metricsSummaryQuery({ serviceName: service }), localParams(bounds)), apiBase), + (rows): ReadonlyArray => + rows.map((r) => ({ metricType: r.metricType, metricCount: r.metricCount, dataPointCount: r.dataPointCount })), + ) + +/** `useLocalMetricsSparklines`: one query per metric type, folded into points by metric. */ +export const fetchSparklines = ( + groups: ReadonlyArray, + bounds: TimeBounds, + bucketSeconds: number, + apiBase: string, +) => + Effect.gen(function* () { + const params = { ...localParams(bounds), bucketSeconds } + const results = yield* Effect.forEach( + groups.flatMap(([metricType, metricNames]) => (isMetricType(metricType) ? [{ metricType, metricNames }] : [])), + ({ metricType, metricNames }) => + queryRows(CH.compile(CH.metricsSparklinesQuery({ metricType, metricNames: [...metricNames] }), params), apiBase), + { concurrency: "unbounded" }, + ) + const points = new Map>() + results.flat().forEach((row) => { + const point = { + bucket: DateTime.formatIso(row.bucket), + avgValue: row.avgValue, + sumValue: row.sumValue, + dataPointCount: row.dataPointCount, + } + const list = points.get(row.metricName) + if (list) list.push(point) + else points.set(row.metricName, [point]) + }) + return [...points.entries()].map(([metricName, list]): MetricSparkline => ({ metricName, points: list })) + }) diff --git a/apps/local-ui-foldkit/src/page/metrics/index.ts b/apps/local-ui-foldkit/src/page/metrics/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/metrics/lanes.ts b/apps/local-ui-foldkit/src/page/metrics/lanes.ts new file mode 100644 index 0000000000..503ced1ae5 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/lanes.ts @@ -0,0 +1,10 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchList, FetchSparklines, FetchSummary } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchList.name, model.list, (key) => FetchList.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchSummary.name, model.summary, (key) => FetchSummary.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchSparklines.name, model.sparklines, (key) => FetchSparklines.Interrupt({ key }, outcomeOf)), +] diff --git a/apps/local-ui-foldkit/src/page/metrics/load.ts b/apps/local-ui-foldkit/src/page/metrics/load.ts new file mode 100644 index 0000000000..3f5bb22427 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/load.ts @@ -0,0 +1,80 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, type TimeBounds } from "@maple/local-ui/lib/time" +import { Array, Option } from "effect" +import { AsyncData, type Command } from "foldkit" +import { keyOf, request } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, rangeOf } from "../contract" +import { FetchList, FetchSparklines, FetchSummary } from "./command" +import { sparklineGroups, sparklineWindow } from "./derive" +import type { Message } from "./message" +import type { Model } from "./model" + +type Step = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +const boundsKey = (bounds: TimeBounds) => `${bounds.startTime}/${bounds.endTime}` + +/** The view's filters: service (client-side), type and the search (server-side). */ +export const metricsFilters = (context: PageContext) => { + const query = queryOf(context.location) + return { + service: query.get("service") || undefined, + type: query.get("type") || undefined, + search: query.get("q") || undefined, + } +} + +/** + * `useLocalMetricsSparklines`: keyed by the listed metrics (the placeholder's while a new list + * loads), the chart bucket and the window; nothing to ask for an empty list. + */ +export const loadSparklines = (model: Model, context: PageContext, force = false): Step => + Option.match(Option.zipWith(boundsOf(model, context), AsyncData.getData(model.list.data), (bounds, data) => ({ bounds, data })), { + onNone: () => ({ model }), + onSome: ({ bounds, data }) => { + const groups = sparklineGroups(data.entries) + if (groups.length === 0) return { model } + const { bucketSeconds } = sparklineWindow(data.entries, bounds, model.nowMs) + const key = keyOf("metrics-sparklines", { groups, bucketSeconds, bounds: boundsKey(bounds) }) + return Option.match(request(model.sparklines, key, force), { + onNone: () => ({ model }), + onSome: (sparklines) => ({ + model: { ...model, sparklines }, + commands: [FetchSparklines({ key, groups, bucketSeconds, ...bounds, apiBase: context.env.apiBase, readsCache: model.sparklines.key !== key })], + }), + }) + }, + }) + +/** The list and the summary for the current filters and window, then the sparklines. */ +export const load = (model: Model, context: PageContext, force = false): Step => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const { service, type, search } = metricsFilters(context) + const apiBase = context.env.apiBase + const listKey = keyOf("metrics-list", { type: type ?? null, search: search ?? null, bounds: boundsKey(bounds) }) + const summaryKey = keyOf("metrics-summary", { service: service ?? null, bounds: boundsKey(bounds) }) + const list = request(model.list, listKey, force) + const summary = request(model.summary, summaryKey, force) + const next = { + ...model, + list: Option.getOrElse(list, () => model.list), + summary: Option.getOrElse(summary, () => model.summary), + } + const sparklines = loadSparklines(next, context, force) + return { + model: sparklines.model, + commands: [ + ...Array.getSomes([ + Option.map(list, () => FetchList({ key: listKey, type, search, ...bounds, apiBase, readsCache: model.list.key !== listKey })), + Option.map(summary, () => FetchSummary({ key: summaryKey, service, ...bounds, apiBase, readsCache: model.summary.key !== summaryKey })), + ]), + ...(sparklines.commands ?? []), + ], + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/metrics/message.ts b/apps/local-ui-foldkit/src/page/metrics/message.ts new file mode 100644 index 0000000000..acf9a08c0c --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/message.ts @@ -0,0 +1,47 @@ +import * as Chart from "@maple/web-foldkit/charts/chart" +import { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { ListRequest, SparklinesRequest, SummaryRequest } from "./requests" +import { MetricsListData } from "./catalog" +import { MetricSparkline, SummaryRow } from "./fetch" + +export const FacetParam = Schema.Literals(["type", "service"]) +export type FacetParam = typeof FacetParam.Type + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchList: { key: Schema.String, data: MetricsListData, fetchedAtMs: Schema.Number }, + FailedFetchList: { key: Schema.String, failure: LoadFailure }, + /** The result cache answered first; `request` is the read the fetch then repeats. */ + FoundCachedList: { request: ListRequest, cached: CachedOf(MetricsListData) }, + SucceededFetchSummary: { key: Schema.String, rows: Schema.Array(SummaryRow), fetchedAtMs: Schema.Number }, + FailedFetchSummary: { key: Schema.String, failure: LoadFailure }, + FoundCachedSummary: { request: SummaryRequest, cached: CachedOf(Schema.Array(SummaryRow)) }, + SucceededFetchSparklines: { key: Schema.String, sparklines: Schema.Array(MetricSparkline), fetchedAtMs: Schema.Number }, + FailedFetchSparklines: { key: Schema.String, failure: LoadFailure }, + FoundCachedSparklines: { request: SparklinesRequest, cached: CachedOf(Schema.Array(MetricSparkline)) }, + ClickedRefresh: {}, + ClickedOpenFilters: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ChangedSearch: { text: Schema.String }, + ClickedClearSearch: {}, + ElapsedSearchDebounce: { version: Schema.Number }, + ToggledFacetOption: { param: FacetParam, name: Schema.String }, + ClickedClearSidebarFilters: {}, + ClickedClearFilters: {}, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + MeasuredGrid: { width: Schema.Number }, + ResolvedThemeColors: { colors: ThemeColors }, + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, + GotSparklineMessage: { message: Chart.Message }, + GotFilterSectionsMessage: { message: FilterSections.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/metrics/metrics.scene.test.ts b/apps/local-ui-foldkit/src/page/metrics/metrics.scene.test.ts new file mode 100644 index 0000000000..7a1939f837 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/metrics.scene.test.ts @@ -0,0 +1,79 @@ +import { DateTime } from "effect" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../contract" +import { PageOutMessage } from "../contract" +import type { MetricEntry } from "./catalog" +import { Message } from "./message" +import type { Model } from "./model" +import { init, locationChanged, update } from "./update" + +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T12:00:00Z")) +const env = { apiBase: "", port: "4318", otlpEndpoint: "http://localhost:4318", isHosted: false } +const contextAt = (search: string): PageContext => ({ + env, + location: { path: "/metrics", search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +}) +const context = contextAt("") +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +const entry = (metricName: string, metricType: string, serviceNames: ReadonlyArray): MetricEntry => ({ + metricName, + metricType, + metricUnit: "", + metricDescription: "", + serviceNames, + dataPointCount: 10, + firstSeen: "2026-10-01T11:20:00.000Z", + lastSeen: "2026-10-01T12:00:00.000Z", + isMonotonic: false, +}) + +const entered = () => pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + +const withList = (model: Model, entries: ReadonlyArray, ctx = context) => + update( + model, + Message.SucceededFetchList({ key: model.list.key ?? "", data: { entries, serviceFacets: [] }, fetchedAtMs: NOW }), + ctx, + ) + +describe("local metrics list", () => { + test("entry reads the clock, then asks for the catalog and the type summary", () => { + expect(names(init(context).commands)).toContain("ReadMetricsClock") + expect(names(entered().commands)).toEqual(["FetchLocalMetricsList", "FetchLocalMetricsSummary"]) + }) + + test("the listed metrics ask for one sparkline batch", () => { + const listed = withList(entered().model, [entry("a", "gauge", ["x"]), entry("b", "sum", ["x"])]) + expect(names(listed.commands)).toEqual(["FetchLocalMetricsSparklines"]) + expect(listed.model.sparklines.key).toContain('"gauge",["a"]') + }) + + test("a catalog answer for a superseded search is dropped", () => { + const first = entered().model + const searched = update(first, Message.CompletedReadClock({ nowMs: NOW }), contextAt("q=http")).model + const dropped = update( + searched, + Message.SucceededFetchList({ key: first.list.key ?? "", data: { entries: [], serviceFacets: [] }, fetchedAtMs: NOW }), + contextAt("q=http"), + ) + expect(dropped.model.list).toEqual(searched.list) + }) + + test("an empty catalog renders the signal empty state, which reads the presence", () => { + const listed = withList(entered().model, []) + expect(listed.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + expect(names(listed.commands)).toEqual([]) + }) + + test("a service filter that matches nothing asks for the presence without new SQL", () => { + const listed = withList(entered().model, [entry("a", "gauge", ["x"])]).model + const filtered = locationChanged(listed, contextAt("service=y")) + expect(names(filtered.commands)).toEqual(["FetchLocalMetricsSummary"]) + expect(filtered.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/metrics/model.ts b/apps/local-ui-foldkit/src/page/metrics/model.ts new file mode 100644 index 0000000000..29e842c8da --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/model.ts @@ -0,0 +1,33 @@ +import { ChartRuntime } from "@maple/web-foldkit/charts/chart-runtime" +import { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" +import { MetricsListData } from "./catalog" +import { MetricSparkline, SummaryRow } from "./fetch" + +/** A debounced text input: what is typed, a version that retires timers, and the last sent value. */ +export const Draft = Schema.Struct({ text: Schema.String, version: Schema.Number, sent: Schema.String }) +export type Draft = typeof Draft.Type + +export const Model = Schema.TaggedStruct("MetricsPage", { + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + /** The clock at the last read: `chartWindow`'s "now". */ + nowMs: Schema.Number, + list: SlotSchema(MetricsListData), + /** `dataUpdatedAt` of the list, for the "New data" pill. */ + listUpdatedAtMs: Schema.Number, + summary: SlotSchema(Schema.Array(SummaryRow)), + sparklines: SlotSchema(Schema.Array(MetricSparkline)), + search: Draft, + sections: FilterSections.Model, + isRefreshing: Schema.Boolean, + /** The grid's scroll container width (`ResizeObserver`); 0 before measured. */ + gridWidth: Schema.Number, + themeColors: Schema.NullOr(ThemeColors), + chartRuntime: ChartRuntime, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/metrics/requests.ts b/apps/local-ui-foldkit/src/page/metrics/requests.ts new file mode 100644 index 0000000000..61167e7be9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/requests.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect" +import { SparklineGroup } from "./fetch" + +// What each metrics read asks for; a cached answer carries it back so the fetch can follow. + +const Window = { startTime: Schema.String, endTime: Schema.String, apiBase: Schema.String } + +export const ListRequest = Schema.Struct({ + key: Schema.String, + type: Schema.optional(Schema.String), + search: Schema.optional(Schema.String), + ...Window, +}) +export const SummaryRequest = Schema.Struct({ key: Schema.String, service: Schema.optional(Schema.String), ...Window }) +export const SparklinesRequest = Schema.Struct({ + key: Schema.String, + groups: Schema.Array(SparklineGroup), + bucketSeconds: Schema.Number, + ...Window, +}) diff --git a/apps/local-ui-foldkit/src/page/metrics/update.ts b/apps/local-ui-foldkit/src/page/metrics/update.ts new file mode 100644 index 0000000000..f0520578a2 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/update.ts @@ -0,0 +1,175 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import { LoadChartRuntime } from "@maple/web-foldkit/charts/chart-runtime" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Option, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { accept, idle, acceptCached } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage, replaceParams, withParams } from "../contract" +import { updateCopy } from "../kit/copies" +import { FetchList, FetchSparklines, FetchSummary, ReadClock, ReadRefreshClock, WaitSearchDebounce } from "./command" +import { load, loadSparklines } from "./load" +import { Message } from "./message" +import type { Model } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +export const init = ( + context: PageContext, +): Readonly<{ model: Model; commands: ReadonlyArray> }> => { + const q = queryOf(context.location).get("q") ?? "" + return { + model: { + _tag: "MetricsPage", + maybeAnchorMs: Option.none(), + nowMs: 0, + list: idle(), + listUpdatedAtMs: 0, + summary: idle(), + sparklines: idle(), + search: { text: q, version: 0, sent: q }, + sections: FilterSections.init(), + isRefreshing: false, + gridWidth: 0, + themeColors: null, + chartRuntime: { _tag: "Loading" }, + copies: {}, + }, + commands: [ + ReadClock(), + Command.mapMessage(FilterSections.ReadRemembered(), toSections), + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], + } +} + +/** The hash changed: keep the search input in sync unless it is the page's own echo, then load. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => { + const q = queryOf(context.location).get("q") ?? "" + const search = q === model.search.sent ? model.search : { ...model.search, text: q, sent: q } + return withPresence(load({ ...model, search }, context), context) +} + +const navigate = (model: Model, context: PageContext, updates: Record): UpdateReturn => ({ + model, + outMessage: replaceParams(context.location, updates), +}) + +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +/** The refresh spinner stops once nothing the refresh started is still loading. */ +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + return model.isRefreshing && !isLoading(model.list.data) && !isLoading(model.summary.data) && !isLoading(model.sparklines.data) + ? { ...model, isRefreshing: false } + : model +} + +const editSearch = (model: Model, text: string): UpdateReturn => { + const version = model.search.version + 1 + return { model: { ...model, search: { ...model.search, text, version } }, commands: [WaitSearchDebounce({ version })] } +} + +/** The service-filtered entries the grid shows (the service filter never re-runs SQL). */ +export const visibleEntries = (model: Model, service: string | undefined) => + Option.match(AsyncData.getData(model.list.data), { + onNone: () => [], + onSome: (data) => (service ? data.entries.filter((entry) => entry.serviceNames.includes(service)) : data.entries), + }) + +/** A settled empty grid renders `SignalEmptyState`, which reads the shared signal presence. */ +const withPresence = (step: UpdateReturn, context: PageContext): UpdateReturn => { + const service = queryOf(context.location).get("service") || undefined + const isEmpty = + AsyncData.isSuccess(step.model.list.data) && visibleEntries(step.model, service).length === 0 + return isEmpty && step.outMessage === undefined ? { ...step, outMessage: PageOutMessage.RequestedSignalPresence() } : step +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadClock: ({ nowMs }) => + load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + SucceededFetchList: ({ key, data, fetchedAtMs }) => { + const isCurrent = model.list.key === key + const next = { ...model, list: accept(model.list, key, Result.succeed(data), fetchedAtMs), listUpdatedAtMs: isCurrent ? fetchedAtMs : model.listUpdatedAtMs } + const sparklines = loadSparklines(next, context) + return withPresence({ model: settleRefresh(sparklines.model), commands: sparklines.commands }, context) + }, + FailedFetchList: ({ key, failure }) => ({ model: settleRefresh({ ...model, list: accept(model.list, key, Result.fail(failure)) }) }), + SucceededFetchSummary: ({ key, rows, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, summary: accept(model.summary, key, Result.succeed(rows), fetchedAtMs) }) }), + FoundCachedList: ({ request, cached }) => { + if (model.list.key !== request.key) return { model } + const next = { ...model, list: acceptCached(model.list, request.key, cached, cached.isFresh), listUpdatedAtMs: cached.updatedAtMs } + const sparklines = loadSparklines(next, context) + const fetch = cached.isFresh ? [] : [FetchList({ ...request, readsCache: false })] + return withPresence({ model: settleRefresh(sparklines.model), commands: [...fetch, ...(sparklines.commands ?? [])] }, context) + }, + FoundCachedSummary: ({ request, cached }) => + model.summary.key === request.key + ? { + model: settleRefresh({ ...model, summary: acceptCached(model.summary, request.key, cached, cached.isFresh) }), + commands: cached.isFresh ? [] : [FetchSummary({ ...request, readsCache: false })], + } + : { model }, + FoundCachedSparklines: ({ request, cached }) => + model.sparklines.key === request.key + ? { + model: settleRefresh({ ...model, sparklines: acceptCached(model.sparklines, request.key, cached, cached.isFresh) }), + commands: cached.isFresh ? [] : [FetchSparklines({ ...request, readsCache: false })], + } + : { model }, + FailedFetchSummary: ({ key, failure }) => ({ model: settleRefresh({ ...model, summary: accept(model.summary, key, Result.fail(failure)) }) }), + SucceededFetchSparklines: ({ key, sparklines, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, sparklines: accept(model.sparklines, key, Result.succeed(sparklines), fetchedAtMs) }) }), + FailedFetchSparklines: ({ key, failure }) => + ({ model: settleRefresh({ ...model, sparklines: accept(model.sparklines, key, Result.fail(failure)) }) }), + ClickedOpenFilters: () => ({ model, outMessage: PageOutMessage.RequestedFilterDrawer() }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ChangedSearch: ({ text }) => editSearch(model, text), + ClickedClearSearch: () => editSearch(model, ""), + ElapsedSearchDebounce: ({ version }) => { + if (version !== model.search.version) return { model } + const trimmed = model.search.text.trim() + return navigate({ ...model, search: { ...model.search, sent: trimmed } }, context, { q: trimmed === "" ? null : trimmed }) + }, + ToggledFacetOption: ({ param, name }) => + navigate(model, context, { [param]: queryOf(context.location).get(param) === name ? null : name }), + ClickedClearSidebarFilters: () => navigate(model, context, { service: null, type: null }), + ClickedClearFilters: () => navigate(model, context, { service: null, type: null, q: null }), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + MeasuredGrid: ({ width }) => ({ model: { ...model, gridWidth: width } }), + ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), + FailedLoadChartRuntime: ({ reason }) => ({ model: { ...model, chartRuntime: { _tag: "Failed", reason } } }), + GotSparklineMessage: () => ({ model }), + GotFilterSectionsMessage: ({ message: child }) => { + const next = FilterSections.update(model.sections, child) + return { model: { ...model, sections: next.model }, commands: Command.mapMessages(next.commands ?? [], toSections) } + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/metrics/view-grid.ts b/apps/local-ui-foldkit/src/page/metrics/view-grid.ts new file mode 100644 index 0000000000..87eea94155 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/view-grid.ts @@ -0,0 +1,148 @@ +import { humanizeUnit, isCounter } from "@maple/local-ui/lib/units" +import { validateCssColor } from "@maple/ui/lib/sanitizers" +import { cn } from "@maple/ui/lib/utils" +import * as Chart from "@maple/web-foldkit/charts/chart" +import * as Sparkline from "@maple/web-foldkit/charts/sparkline" +import { resolveColor, type ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { METRIC_TYPE_COLORS, metricTypeBadge } from "@maple/web-foldkit/page/metrics/shared" +import { badge } from "@maple/web-foldkit/ui/display" +import { truncatedText } from "@maple/web-foldkit/ui/truncated-text" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../route" +import type { MetricEntry } from "./catalog" +import { CARD_ROW_HEIGHT, columnsFor, previewValues, sparklineValues } from "./derive" +import type { SparklinePoint } from "./fetch" +import { MeasureGrid } from "./command" +import { Message } from "./message" + +// `MetricGrid` and `MetricPreviewCard` (apps/local-ui/src/views/metrics-list-view.tsx). + +type H = HtmlBuilder + +/** Sparklines keep no interaction state; one constant Model satisfies the frame. */ +const CHART_MODEL = Chart.init() +const toSparklineMessage = (message: Chart.Message) => Message.GotSparklineMessage({ message }) + +export type GridInputs = Readonly<{ + entries: ReadonlyArray + points: ReadonlyMap> | undefined + isLoading: boolean + isFailed: boolean + query: URLSearchParams + isDimmed: boolean + width: number + theme: ThemeColors +}> + +const sparkline = (h: H, theme: ThemeColors, entry: MetricEntry, values: ReadonlyArray): Html => { + const token = validateCssColor(METRIC_TYPE_COLORS.get(entry.metricType) ?? "var(--chart-1)") ?? "var(--chart-1)" + const slotId = `local-metric-spark-${entry.metricName} ${entry.metricType}` + return h.div( + [h.Class("h-full w-full")], + [ + h.submodel({ + slotId, + model: CHART_MODEL, + view: Sparkline.view, + toParentMessage: toSparklineMessage, + viewInputs: { + chartId: slotId, + values, + color: resolveColor(theme, token), + className: "h-full w-full", + revision: 0, + curve: "monotone", + fillOpacity: [0.35, 0], + }, + }), + ], + ) +} + +const footer = (h: H, entry: MetricEntry, services: string): Html => { + const unit = humanizeUnit(entry.metricUnit, entry.metricName) + const [only] = entry.serviceNames + return h.div( + [h.Class("flex w-full items-center justify-between gap-2 text-3xs text-muted-foreground")], + [ + h.span( + [h.Class("flex min-w-0 items-center gap-1.5")], + [ + ...(entry.serviceNames.length === 1 && only !== undefined ? [serviceDot(h, { serviceName: only, size: "sm" })] : []), + h.span([h.Class("truncate")], [services]), + ], + ), + h.span( + [h.Class("flex shrink-0 items-center gap-1.5")], + [ + ...(unit ? [badge(h, { variant: "outline", size: "xs", mono: true, className: "text-4xs" }, [unit])] : []), + isCounter(entry) ? "rate" : "avg", + ], + ), + ], + ) +} + +const previewCard = (h: H, inputs: GridInputs, entry: MetricEntry): Html => { + const values = previewValues(entry, inputs.points?.get(entry.metricName) ?? []) + const services = entry.serviceNames.length === 1 ? (entry.serviceNames[0] ?? "") : `${entry.serviceNames.length} services` + const status = inputs.isLoading ? "Loading…" : inputs.isFailed ? "Preview unavailable" : "One datapoint so far" + return h.a( + [ + h.Href(hrefFor(`/metrics/${encodeURIComponent(entry.metricName)}`, inputs.query)), + h.AriaLabel(`${entry.metricName}, ${entry.metricType} metric from ${services}`), + h.Class( + "group flex h-[124px] flex-col gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40 focus-visible:outline-2 focus-visible:outline-ring", + ), + ], + [ + h.div( + [h.Class("flex w-full items-start justify-between gap-2")], + [truncatedText(h, { text: entry.metricName, mono: true, className: "text-xs font-medium" }), metricTypeBadge(h, entry.metricType)], + ), + h.div( + [h.AriaHidden(true), h.Class("h-12 w-full")], + [ + values.length >= 2 + ? sparkline(h, inputs.theme, entry, sparklineValues(values)) + : h.div([h.Class("flex h-full items-center text-3xs text-muted-foreground")], [status]), + ], + ), + footer(h, entry, services), + ], + ) +} + +/** + * Up to 500 cards in rows of the measured column count, each row placed where the virtualizer + * would put it. Every row is drawn: the capture grows the viewport until nothing scrolls. + * TODO(virtualize): rows outside the viewport are not windowed. + */ +export const metricGrid = (h: H, inputs: GridInputs): Html => { + const columns = columnsFor(inputs.width) + const rowCount = Math.ceil(inputs.entries.length / columns) + return h.div( + [h.Class(cn("h-full overflow-auto", inputs.isDimmed && "opacity-60 transition-opacity")), h.OnMount(MeasureGrid())], + [ + h.div( + [h.Class("relative m-4"), h.Style({ height: `${rowCount * CARD_ROW_HEIGHT}px` })], + Array.from({ length: rowCount }, (_, index) => + h.div( + [ + h.Key(`row-${index}`), + h.Class("absolute inset-x-0 grid gap-3"), + h.Style({ + transform: `translateY(${index * CARD_ROW_HEIGHT}px)`, + "grid-template-columns": `repeat(${columns}, minmax(0, 1fr))`, + }), + ], + inputs.entries + .slice(index * columns, index * columns + columns) + .map((entry) => previewCard(h, inputs, entry)), + ), + ), + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/metrics/view-sidebar.ts b/apps/local-ui-foldkit/src/page/metrics/view-sidebar.ts new file mode 100644 index 0000000000..1091132a8b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/view-sidebar.ts @@ -0,0 +1,71 @@ +import { getServiceColor } from "@maple/ui/lib/colors" +import { METRIC_TYPE_COLORS } from "@maple/web-foldkit/page/metrics/shared" +import { filterSection } from "@maple/web-foldkit/ui/filters/filter-section" +import type * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { filterSidebarBody, filterSidebarFrame, filterSidebarHeader } from "@maple/web-foldkit/ui/filters/filter-sidebar" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "./message" +import type { Model } from "./model" + +// The filter rail of `MetricsListView` (apps/local-ui/src/views/metrics-list-view.tsx). + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +/** copied: `metricTypeLabel` (packages/ui/src/components/metrics/metric-type-badge.tsx, a React module). */ +const TYPE_LABELS = new Map([ + ["sum", "Sum"], + ["gauge", "Gauge"], + ["histogram", "Histogram"], +]) +export const metricTypeLabel = (type: string): string => + type === "exponential_histogram" ? "Exp histogram" : (TYPE_LABELS.get(type) ?? type) + +export const sidebar = ( + h: HtmlBuilder, + model: Model, + filters: Readonly<{ service?: string; type?: string }>, + activeFilterCount: number, +): Html => { + const summary = Option.getOrElse(AsyncData.getData(model.summary.data), () => []) + // A stable sort: equal counts keep the summary's order. + const typeFacets = summary + .map((row) => ({ name: row.metricType, count: row.metricCount })) + .toSorted((a, b) => b.count - a.count) + const serviceFacets = Option.match(AsyncData.getData(model.list.data), { + onNone: () => [], + onSome: (data) => data.serviceFacets, + }) + const isFetching = AsyncData.isLoading(model.list.data) || AsyncData.isRefreshing(model.list.data) + const sections = [ + filterSection(h, { + title: "Type", + options: typeFacets, + selected: filters.type ? [filters.type] : [], + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param: "type", name }), + getOptionLabel: metricTypeLabel, + colorOf: (name) => METRIC_TYPE_COLORS.get(name), + }), + filterSection(h, { + title: "Service", + options: serviceFacets, + selected: filters.service ? [filters.service] : [], + isSearchable: true, + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param: "service", name }), + colorOf: getServiceColor, + }), + ] + return filterSidebarFrame(h, { className: "w-56 shrink-0 px-4", isWaiting: isFetching }, [ + filterSidebarHeader(h, activeFilterCount > 0 ? { onClear: Message.ClickedClearSidebarFilters() } : {}), + ...filterSidebarBody( + h, + "metrics-filters", + sections.filter((section) => section !== null), + ), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/metrics/view.ts b/apps/local-ui-foldkit/src/page/metrics/view.ts new file mode 100644 index 0000000000..2ecc8fe7bf --- /dev/null +++ b/apps/local-ui-foldkit/src/page/metrics/view.ts @@ -0,0 +1,110 @@ +import { isChartRuntimeReady } from "@maple/web-foldkit/charts/chart-runtime" +import { ResolveThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { METRIC_TYPE_COLORS } from "@maple/web-foldkit/page/metrics/shared" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { queryOf } from "../../route" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { pageShell } from "../kit/page-shell" +import { signalEmptyState } from "../kit/signal-empty-state" +import { refreshButton, timeRangeSelect, toolbar, toolbarSearch, toolbarStat, toolbarStats } from "../kit/toolbar" +import { errorState, listSkeleton } from "../kit/view-states" +import { metricsFilters } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { visibleEntries } from "./update" +import { metricGrid } from "./view-grid" +import { sidebar } from "./view-sidebar" + +// `MetricsListView` (apps/local-ui/src/views/metrics-list-view.tsx). + +/** The colours the sparklines resolve: the type palette and the fallback. */ +const THEME_TOKENS = [...METRIC_TYPE_COLORS.values(), "var(--chart-1)"] + +const toCopy = (id: string, message: Parameters[0]["message"]) => + Message.GotCopyMessage({ id, message }) + +export const view = Submodel.defineView((model, inputs, h) => { + const { context } = inputs + const { service, type, search } = metricsFilters(context) + const range = rangeOf(context) + const entries = visibleEntries(model, service) + const activeFilterCount = [service, type].filter(Boolean).length + const totalDataPoints = Option.getOrElse(AsyncData.getData(model.summary.data), () => []).reduce( + (sum, row) => sum + row.dataPointCount, + 0, + ) + const content = (): Html => { + if (isPending(model.list)) return listSkeleton(h, { variant: "grid", rows: 9 }) + const failure = failureWithoutData(model.list) + if (Option.isSome(failure)) + return errorState(h, { label: "metrics", failure: failure.value, onRetry: Message.ClickedRefresh() }) + if (entries.length === 0) + return signalEmptyState(h, { + signal: "metrics", + isFiltered: activeFilterCount > 0 || search !== undefined, + onClearFilters: Message.ClickedClearFilters(), + range, + onWidenRange: Message.ClickedWidenRange(), + maybePresence: inputs.presenceOf("metrics"), + nowMs: inputs.presenceNowMs, + guide: { id: "metrics-empty", endpoint: context.env.otlpEndpoint, copies: model.copies, toCopyMessage: toCopy }, + }) + const theme = model.themeColors + if (theme === null || !isChartRuntimeReady(model.chartRuntime)) return listSkeleton(h, { variant: "grid", rows: 9 }) + const sparklines = AsyncData.getData(model.sparklines.data) + return metricGrid(h, { + entries, + points: Option.getOrUndefined( + Option.map(sparklines, (list) => new Map(list.map((entry) => [entry.metricName, entry.points]))), + ), + isLoading: isPending(model.sparklines), + isFailed: Option.isSome(failureWithoutData(model.sparklines)), + query: queryOf(context.location), + isDimmed: model.list.isPlaceholder, + width: model.gridWidth, + theme, + }) + } + return h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: THEME_TOKENS }))], + [ + pageShell( + h, + { + onOpenFilters: Message.ClickedOpenFilters(), + sidebar: sidebar(h, model, { service, type }, activeFilterCount), + toolbar: toolbar(h, [ + toolbarSearch(h, { + id: "metrics-search", + value: model.search.text, + placeholder: "Filter by metric name…", + onInput: (text) => Message.ChangedSearch({ text }), + onClear: Message.ClickedClearSearch(), + className: "min-w-48 flex-1", + }), + toolbarStats( + h, + [ + toolbarStat(h, { value: entries.length, label: "metrics" }), + toolbarStat(h, { value: totalDataPoints, label: "datapoints" }), + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.listUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + }), + timeRangeSelect(h, range, (next) => Message.ChangedRange({ range: next })), + ], + "shrink-0", + ), + ]), + activeFilterCount, + }, + [content()], + ), + ], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/query/command.ts b/apps/local-ui-foldkit/src/page/query/command.ts new file mode 100644 index 0000000000..74c65d5b0b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/command.ts @@ -0,0 +1,67 @@ +import { describeQueryError, parseTables, TABLES_SQL } from "@maple/local-ui/lib/query-console" +import { executeLocalQuery } from "@maple/query-engine/local" +import { Duration, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { cachedAs, withCache } from "../../data/cached" +import { Message } from "./message" +import { LocalTable } from "./model" + +// The console's side effects: the platform read, the tables list, a run and a download. + +export const ReadPlatform = Command.define("ReadQueryConsolePlatform", { + messages: [Message.CompletedReadPlatform], + execute: Effect.sync(() => Message.CompletedReadPlatform({ isMac: /Mac|iPhone|iPad/.test(navigator.platform) })), +}) + +/** `QuerySidebar`'s `system.tables` read (`retry: false`), opened on its cached list first. */ +export const FetchTables = Command.define("FetchQueryConsoleTables", { + args: { key: Schema.String, apiBase: Schema.String, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTables, Message.FailedFetchTables, Message.FoundCachedTables], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, apiBase, readsCache }) => + withCache({ + key: ["local", "query-console", "tables"], + readsCache, + fromCached: cachedAs(Schema.Array(LocalTable)), + toCached: (tables: ReadonlyArray) => tables, + fetch: Effect.map(executeLocalQuery(TABLES_SQL, apiBase), (rows): ReadonlyArray => parseTables(rows)), + found: (cached) => Message.FoundCachedTables({ key, cached }), + succeeded: (tables, fetchedAtMs) => Message.SucceededFetchTables({ key, tables, fetchedAtMs }), + }).pipe( + Effect.catch((error) => + Effect.succeed(Message.FailedFetchTables({ key, failure: { message: describeQueryError(error).detail } })), + ), + ), +}) + +/** One submission, timed like `performance.now()` around the request (`retry: false`); Cancel interrupts it. */ +export const RunQuery = Command.define("RunQueryConsoleQuery", { + args: { id: Schema.Number, sql: Schema.String, apiBase: Schema.String }, + messages: [Message.SucceededRunQuery, Message.FailedRunQuery], + interrupt: { keyFields: ["id"], toKey: ({ id }) => String(id) }, + execute: ({ id, sql, apiBase }) => + executeLocalQuery(sql, apiBase).pipe( + Effect.timed, + Effect.map(([elapsed, rows]) => Message.SucceededRunQuery({ id, rows, elapsedMs: Duration.toMillis(elapsed) })), + Effect.catch((error) => { + const { title, detail } = describeQueryError(error) + return Effect.succeed(Message.FailedRunQuery({ id, title, detail })) + }), + ), +}) + +/** `downloadText`: save text as a file; the object URL is released right after. */ +export const DownloadText = Command.define("DownloadQueryConsoleText", { + args: { filename: Schema.String, text: Schema.String, type: Schema.String }, + messages: [Message.CompletedDownload], + execute: ({ filename, text, type }) => + Effect.sync(() => { + const url = URL.createObjectURL(new Blob([text], { type })) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) + return Message.CompletedDownload() + }), +}) diff --git a/apps/local-ui-foldkit/src/page/query/index.ts b/apps/local-ui-foldkit/src/page/query/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/query/lanes.ts b/apps/local-ui-foldkit/src/page/query/lanes.ts new file mode 100644 index 0000000000..185d005300 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/lanes.ts @@ -0,0 +1,13 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchTables, RunQuery } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`): the tables and the current run. */ +export const lanes = (model: Model): ReadonlyArray => { + const { run } = model + const running: ReadonlyArray = + run._tag === "Running" + ? [{ scope: RunQuery.name, key: String(run.id), isInFlight: true, stop: () => RunQuery.Interrupt({ id: run.id }, outcomeOf) }] + : [] + return [...slotLane(FetchTables.name, model.tables, (key) => FetchTables.Interrupt({ key }, outcomeOf)), ...running] +} diff --git a/apps/local-ui-foldkit/src/page/query/message.ts b/apps/local-ui-foldkit/src/page/query/message.ts new file mode 100644 index 0000000000..55c6a42168 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/message.ts @@ -0,0 +1,26 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { LocalTable, ResultRow } from "./model" + +export const DownloadFormat = Schema.Literals(["Csv", "Json"]) +export type DownloadFormat = typeof DownloadFormat.Type + +export const Message = defineMessageUnion({ + CompletedReadPlatform: { isMac: Schema.Boolean }, + SucceededFetchTables: { key: Schema.String, tables: Schema.Array(LocalTable), fetchedAtMs: Schema.Number }, + /** The result cache held the tables list before the fetch ran. */ + FoundCachedTables: { key: Schema.String, cached: CachedOf(Schema.Array(LocalTable)) }, + FailedFetchTables: { key: Schema.String, failure: LoadFailure }, + ChangedSql: { text: Schema.String }, + ClickedPickSql: { sql: Schema.String }, + ClickedRun: {}, + ClickedCancel: {}, + SucceededRunQuery: { id: Schema.Number, rows: Schema.Array(ResultRow), elapsedMs: Schema.Number }, + FailedRunQuery: { id: Schema.Number, title: Schema.String, detail: Schema.String }, + ClickedDownload: { format: DownloadFormat }, + CompletedDownload: {}, + ClickedOpenFilters: {}, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/query/model.ts b/apps/local-ui-foldkit/src/page/query/model.ts new file mode 100644 index 0000000000..404aa223c5 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/model.ts @@ -0,0 +1,31 @@ +import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import { SlotSchema } from "../../data/slot" + +// `QueryView` (apps/local-ui/src/views/query-view.tsx) as data. + +export const LocalTable = Schema.Struct({ name: Schema.String, totalRows: Schema.NullOr(Schema.Number) }) +export type LocalTable = typeof LocalTable.Type + +/** One `/local/query` row as the server sent it. */ +export const ResultRow = Schema.Record(Schema.String, Schema.Unknown) +export type ResultRow = typeof ResultRow.Type + +/** The console's submission: none yet, running, or its answer. `id` retires a cancelled run's answer. */ +export const RunState = defineTaggedUnion({ + Idle: {}, + Running: { id: Schema.Number, sql: Schema.String }, + Succeeded: { id: Schema.Number, sql: Schema.String, rows: Schema.Array(ResultRow), elapsedMs: Schema.Number }, + Failed: { id: Schema.Number, sql: Schema.String, title: Schema.String, detail: Schema.String }, +}) +export type RunState = typeof RunState.Type + +export const Model = Schema.TaggedStruct("QueryPage", { + /** `navigator.platform` reads Mac: the shortcut hint shows ⌘, else Ctrl. */ + isMac: Schema.Boolean, + /** The sidebar's `system.tables` read; a failure keeps `describeQueryError`'s detail as its message. */ + tables: SlotSchema(Schema.Array(LocalTable)), + run: RunState, + nextRunId: Schema.Number, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/query/query.scene.test.ts b/apps/local-ui-foldkit/src/page/query/query.scene.test.ts new file mode 100644 index 0000000000..1f8f587319 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/query.scene.test.ts @@ -0,0 +1,73 @@ +import { Option } from "effect" +import { click, Command, expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { outcomeOf, stopSuperseded } from "../../data/lanes" +import { FetchTables, ReadPlatform, RunQuery } from "./command" +import { lanes } from "./lanes" +import { Message } from "./message" +import { type Model, RunState } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const env = { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false } +const contextFor = (search: string): PageContext => ({ + env, + location: { path: "/query", search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +}) +const context = contextFor("sql=SELECT%201") +const inputs: PageViewInputs = { context, presenceOf: () => Option.none(), presenceNowMs: 0, clockMs: 0, hasNewDataSince: () => false } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, inputs)() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +const loaded = (): Model => { + const model = init(context).model + return pageUpdate(model, Message.SucceededFetchTables({ key: model.tables.key ?? "", tables: [{ name: "traces", totalRows: 12 }], fetchedAtMs: 0 })).model +} + +describe("local query console", () => { + test("entry reads the platform and the tables list", () => { + expect(names(init(context).commands)).toEqual([ReadPlatform.name, FetchTables.name]) + }) + + test("Run posts the hash's SQL and shows the rows", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(text("Run a query")).toExist(), + click(role("button", { name: "Run" })), + Command.resolve(RunQuery, Message.SucceededRunQuery({ id: 0, rows: [{ answer: 1 }], elapsedMs: 4 })), + expectView(role("columnheader", { name: "answer" })).toExist(), + expectView(text("4 ms")).toExist(), + ) + }) + + test("a failed run shows the server's refusal; a cancelled run's late answer is dropped", () => { + const running = pageUpdate(loaded(), Message.ClickedRun()).model + const failed = pageUpdate(running, Message.FailedRunQuery({ id: 0, title: "Query not allowed", detail: "only SELECT" })) + expect(failed.model.run).toEqual(RunState.Failed({ id: 0, sql: "SELECT 1", title: "Query not allowed", detail: "only SELECT" })) + scene({ update: pageUpdate, view }, given(failed.model), expectView(text("only SELECT")).toExist()) + const cancelled = pageUpdate(running, Message.ClickedCancel()).model + const late = pageUpdate(cancelled, Message.SucceededRunQuery({ id: 0, rows: [], elapsedMs: 1 })) + expect(late.model.run).toEqual(RunState.Idle()) + }) + + test("Cancel or a second run stops the running request, and only while one runs", () => { + const running = pageUpdate(loaded(), Message.ClickedRun()).model + const stopsAfter = (before: Model, message: Message) => + names(stopSuperseded(lanes(before), lanes(pageUpdate(before, message).model), outcomeOf)) + expect(stopsAfter(running, Message.ClickedCancel())).toEqual(["RunQueryConsoleQuery.Interrupt"]) + expect(stopsAfter(running, Message.ClickedRun())).toEqual(["RunQueryConsoleQuery.Interrupt"]) + expect(stopsAfter(pageUpdate(running, Message.ClickedCancel()).model, Message.ClickedCancel())).toEqual([]) + }) + + test("an empty editor does not run", () => { + const empty = update(loaded(), Message.ClickedRun(), contextFor("")) + expect(empty.commands).toBeUndefined() + }) +}) diff --git a/apps/local-ui-foldkit/src/page/query/update.ts b/apps/local-ui-foldkit/src/page/query/update.ts new file mode 100644 index 0000000000..74aa47eb36 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/update.ts @@ -0,0 +1,83 @@ +import type { Resources } from "../../platform/resources" +import { toCsv, toJson } from "@maple/local-ui/lib/query-console" +import { Option, Result } from "effect" +import type { Command, Update } from "foldkit" +import { accept, acceptCached, idle, keyOf, request } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage, replaceParams } from "../contract" +import { DownloadText, FetchTables, ReadPlatform, RunQuery } from "./command" +import { Message } from "./message" +import { type LocalTable, type Model, RunState } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage +type Commands = ReadonlyArray> + +const TABLES_KEY = keyOf("query-console", "tables") + +export const init = (context: PageContext): Readonly<{ model: Model; commands: Commands }> => { + const empty = idle>() + const tables = Option.getOrElse(request(empty, TABLES_KEY), () => empty) + return { + model: { _tag: "QueryPage", isMac: false, tables, run: RunState.Idle(), nextRunId: 0 }, + commands: [ReadPlatform(), FetchTables({ key: TABLES_KEY, apiBase: context.env.apiBase, readsCache: true })], + } +} + +/** The console reads its SQL straight from the fragment, so a hash change needs nothing here. */ +export const locationChanged = (model: Model, _context: PageContext): UpdateReturn => ({ model }) + +const sqlOf = (context: PageContext): string => queryOf(context.location).get("sql") ?? "" + +/** `run`: a fresh id per submission, so a remount is never served an older answer. */ +const run = (model: Model, context: PageContext): UpdateReturn => { + const text = sqlOf(context).trim() + if (text === "") return { model } + const id = model.nextRunId + return { + model: { ...model, run: RunState.Running({ id, sql: text }), nextRunId: id + 1 }, + commands: [RunQuery({ id, sql: text, apiBase: context.env.apiBase })], + } +} + +const isCurrentRun = (model: Model, id: number): boolean => model.run._tag === "Running" && model.run.id === id + +const rowsOf = (model: Model) => (model.run._tag === "Succeeded" ? model.run.rows : []) + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadPlatform: ({ isMac }) => ({ model: { ...model, isMac } }), + SucceededFetchTables: ({ key, tables, fetchedAtMs }) => + ({ model: { ...model, tables: accept(model.tables, key, Result.succeed(tables), fetchedAtMs) } }), + FoundCachedTables: ({ key, cached }) => ({ + model: { ...model, tables: acceptCached(model.tables, key, cached, cached.isFresh) }, + commands: cached.isFresh || model.tables.key !== key ? [] : [FetchTables({ key, apiBase: context.env.apiBase, readsCache: false })], + }), + FailedFetchTables: ({ key, failure }) => ({ model: { ...model, tables: accept(model.tables, key, Result.fail(failure)) } }), + ChangedSql: ({ text }) => ({ model, outMessage: replaceParams(context.location, { sql: text }) }), + ClickedPickSql: ({ sql }) => ({ model, outMessage: replaceParams(context.location, { sql }) }), + ClickedRun: () => run(model, context), + // `cancelQueries`: the console goes idle; the root stops the run's request (`lanes.ts`). + ClickedCancel: () => (model.run._tag !== "Running" ? { model } : { model: { ...model, run: RunState.Idle() } }), + SucceededRunQuery: ({ id, rows, elapsedMs }) => + model.run._tag === "Running" && isCurrentRun(model, id) + ? { model: { ...model, run: RunState.Succeeded({ id, sql: model.run.sql, rows, elapsedMs }) } } + : { model }, + FailedRunQuery: ({ id, title, detail }) => + model.run._tag === "Running" && isCurrentRun(model, id) + ? { model: { ...model, run: RunState.Failed({ id, sql: model.run.sql, title, detail }) } } + : { model }, + ClickedDownload: ({ format }) => { + const rows = rowsOf(model) + if (rows.length === 0) return { model } + return { + model, + commands: [ + format === "Csv" + ? DownloadText({ filename: "maple-query.csv", text: toCsv(rows), type: "text/csv" }) + : DownloadText({ filename: "maple-query.json", text: toJson(rows), type: "application/json" }), + ], + } + }, + CompletedDownload: () => ({ model }), + ClickedOpenFilters: () => ({ model, outMessage: PageOutMessage.RequestedFilterDrawer() }), + }) diff --git a/apps/local-ui-foldkit/src/page/query/view-result.ts b/apps/local-ui-foldkit/src/page/query/view-result.ts new file mode 100644 index 0000000000..2f3e12c50f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/view-result.ts @@ -0,0 +1,102 @@ +import { DISPLAY_ROW_LIMIT, formatCell, inferColumns, isNumericColumn, truncateCell } from "@maple/local-ui/lib/query-console" +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { icon } from "@maple/web-foldkit/ui/icon" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { ResultRow } from "./model" + +// `ResultTable` and `QueryError` (apps/local-ui/src/views/query-view.tsx). + +export const queryError = (h: HtmlBuilder, title: string, detail: string): Html => + h.div( + [h.Class("p-4")], + [ + h.div( + [h.Class("rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3")], + [ + h.div( + [h.Class("flex items-center gap-2 text-sm font-medium text-destructive")], + [icon(h, "CircleWarningIcon", { className: "size-4 shrink-0" }), title], + ), + h.pre([h.Class("mt-2 font-mono text-xs whitespace-pre-wrap break-all text-foreground")], [detail]), + ], + ), + ], + ) + +export const resultTable = (h: HtmlBuilder, rows: ReadonlyArray): Html => { + const columns = inferColumns(rows) + const numeric = new Set(columns.filter((column) => isNumericColumn(rows, column))) + const shown = rows.length > DISPLAY_ROW_LIMIT ? rows.slice(0, DISPLAY_ROW_LIMIT) : rows + return h.div( + [], + [ + h.table( + [h.Class("w-max min-w-full border-separate border-spacing-0 font-mono text-xs")], + [ + h.thead( + [], + [ + h.tr( + [], + columns.map((column) => + h.th( + [ + h.Key(column), + h.Class( + cn( + "sticky top-0 z-10 border-b bg-background px-3 py-2 font-medium whitespace-nowrap text-muted-foreground", + numeric.has(column) ? "text-right" : "text-left", + ), + ), + ], + [column], + ), + ), + ), + ], + ), + h.tbody( + [], + shown.map((row, index) => + h.tr( + [h.Key(String(index)), h.Class("hover:bg-accent/40")], + columns.map((column) => { + const text = formatCell(row[column]) + return h.td( + [ + h.Key(column), + h.Title(text), + h.Class( + cn( + "border-b border-border/50 px-3 py-1.5 whitespace-nowrap", + numeric.has(column) && "text-right tabular-nums", + (row[column] === null || row[column] === undefined) && "text-muted-foreground/60", + ), + ), + ], + [truncateCell(text)], + ) + }), + ), + ), + ), + ], + ), + ...(rows.length > DISPLAY_ROW_LIMIT + ? [ + h.p( + [h.Class("px-3 py-2 text-xs text-muted-foreground")], + [ + "Showing the first ", + formatNumber(DISPLAY_ROW_LIMIT), + " of ", + formatNumber(rows.length), + " rows. Download CSV or JSON for the full result.", + ], + ), + ] + : []), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/query/view-sidebar.ts b/apps/local-ui-foldkit/src/page/query/view-sidebar.ts new file mode 100644 index 0000000000..8c8f4b8e78 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/view-sidebar.ts @@ -0,0 +1,74 @@ +import { EXAMPLE_QUERIES, tablePreviewSql } from "@maple/local-ui/lib/query-console" +import { formatNumber } from "@maple/ui/lib/format" +import { eyebrow } from "@maple/web-foldkit/ui/eyebrow" +import { filterSidebarBody, filterSidebarFrame, filterSidebarHeader } from "@maple/web-foldkit/ui/filters/filter-sidebar" +import { icon } from "@maple/web-foldkit/ui/icon" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { Message } from "./message" +import type { Model } from "./model" + +// `QuerySidebar` (apps/local-ui/src/views/query-view.tsx): the tables and the starter queries. + +const sidebarItem = (h: HtmlBuilder, title: string, sql: string, children: ReadonlyArray): Html => + h.button( + [ + h.Type("button"), + h.Title(title), + h.OnClick(Message.ClickedPickSql({ sql })), + h.Class("flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-accent/60"), + ], + children, + ) + +const tablesList = (h: HtmlBuilder, model: Model): Html => { + if (isPending(model.tables)) return h.div([h.Class("flex h-12 items-center justify-center")], [spinner(h, "size-4")]) + const failure = failureWithoutData(model.tables) + if (Option.isSome(failure)) return h.p([h.Class("text-xs break-all text-destructive")], [failure.value.message]) + const tables = Option.getOrElse(AsyncData.getData(model.tables.data), () => []) + return h.ul( + [h.Class("space-y-0.5")], + tables.map((table) => + h.li( + [h.Key(table.name)], + [ + sidebarItem(h, `Insert SELECT * FROM ${table.name}`, tablePreviewSql(table.name), [ + icon(h, "DatabaseIcon", { className: "size-3.5 shrink-0 text-muted-foreground" }), + h.span([h.Class("min-w-0 flex-1 truncate font-mono")], [table.name]), + ...(table.totalRows !== null + ? [h.span([h.Class("shrink-0 tabular-nums text-muted-foreground")], [formatNumber(table.totalRows)])] + : []), + ]), + ], + ), + ), + ) +} + +export const querySidebar = (h: HtmlBuilder, model: Model): Html => { + const isFetching = AsyncData.isLoading(model.tables.data) || AsyncData.isRefreshing(model.tables.data) + return filterSidebarFrame(h, { className: "w-56 shrink-0 px-4", isWaiting: isFetching }, [ + filterSidebarHeader(h, { title: "Tables" }), + ...filterSidebarBody(h, "query-tables", [ + tablesList(h, model), + eyebrow(h, { as: "h3", variant: "label", className: "pt-4 pb-1" }, ["Examples"]), + h.ul( + [h.Class("space-y-0.5")], + EXAMPLE_QUERIES.map((example) => + h.li( + [h.Key(example.label)], + [ + sidebarItem(h, example.sql, example.sql, [ + icon(h, "CodeIcon", { className: "size-3.5 shrink-0 text-muted-foreground" }), + h.span([h.Class("min-w-0 flex-1 truncate")], [example.label]), + ]), + ], + ), + ), + ), + ]), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/query/view.ts b/apps/local-ui-foldkit/src/page/query/view.ts new file mode 100644 index 0000000000..d402851550 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/query/view.ts @@ -0,0 +1,110 @@ +import { formatElapsed } from "@maple/local-ui/lib/query-console" +import { button } from "@maple/web-foldkit/ui/button" +import { kbd } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { textarea } from "@maple/web-foldkit/ui/textarea" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { queryOf } from "../../route" +import type { PageViewInputs } from "../kit/inputs" +import { pageShell } from "../kit/page-shell" +import { toolbar, toolbarStat, toolbarStats } from "../kit/toolbar" +import { emptyState } from "../kit/view-states" +import { Message } from "./message" +import { type Model, RunState } from "./model" +import { queryError, resultTable } from "./view-result" +import { querySidebar } from "./view-sidebar" + +// `QueryView` (apps/local-ui/src/views/query-view.tsx). + +const rowsOf = (model: Model) => (model.run._tag === "Succeeded" ? model.run.rows : []) + +const consoleToolbar = (h: HtmlBuilder, model: Model): Html => { + const rows = rowsOf(model) + const download = (format: "Csv" | "Json", label: string) => + button( + h, + { variant: "outline", size: "sm", isDisabled: rows.length === 0, attributes: [h.OnClick(Message.ClickedDownload({ format }))] }, + [label], + ) + return toolbar(h, [ + toolbarStats( + h, + model.run._tag === "Succeeded" + ? [ + toolbarStat(h, { value: rows.length, label: rows.length === 1 ? "row" : "rows" }), + h.span( + [h.Class("text-sm whitespace-nowrap text-muted-foreground")], + [h.span([h.Class("font-medium tabular-nums text-foreground")], [formatElapsed(model.run.elapsedMs)]), " ", "elapsed"], + ), + ] + : [h.span([h.Class("text-sm text-muted-foreground")], ["Read-only SQL against the local chDB tables"])], + "flex-wrap", + ), + toolbarStats(h, [download("Csv", "CSV"), download("Json", "JSON")], "shrink-0 gap-2"), + ]) +} + +const editor = (h: HtmlBuilder, model: Model, sql: string): Html => + h.div( + [h.Class("space-y-2 border-b p-4")], + [ + textarea(h, { + id: "query-console-sql", + controlClassName: "max-h-72 min-h-28 font-mono text-xs leading-relaxed", + defaultValue: sql, + attributes: [ + h.AriaLabel("SQL query"), + h.Placeholder("SELECT * FROM traces WHERE OrgId = 'local' LIMIT 100"), + h.Spellcheck(false), + h.OnInput((text) => Message.ChangedSql({ text })), + h.OnKeyDownPreventDefault((key, modifiers) => + key === "Enter" && (modifiers.metaKey || modifiers.ctrlKey) ? Option.some(Message.ClickedRun()) : Option.none(), + ), + ], + }), + h.div( + [h.Class("flex items-center justify-between gap-2")], + [ + h.span( + [h.Class("flex items-center gap-1 text-xs text-muted-foreground")], + [kbd(h, undefined, [model.isMac ? "⌘" : "Ctrl"]), kbd(h, undefined, ["Enter"]), h.span([h.Class("ml-1")], ["to run"])], + ), + model.run._tag === "Running" + ? button(h, { variant: "outline", size: "sm", className: "gap-1.5", attributes: [h.OnClick(Message.ClickedCancel())] }, [ + spinner(h, "size-3.5"), + "Cancel", + ]) + : button(h, { size: "sm", isDisabled: sql.trim() === "", attributes: [h.OnClick(Message.ClickedRun())] }, ["Run"]), + ], + ), + ], + ) + +const result = (h: HtmlBuilder, model: Model): Html => + RunState.match(model.run, { + Idle: () => + emptyState(h, { + icon: icon(h, "CodeIcon"), + title: "Run a query", + hint: ["Pick a table or an example from the sidebar, or write your own SELECT. Every table is scoped by OrgId = 'local'."], + }), + Running: () => h.div([h.Class("flex h-24 items-center justify-center")], [spinner(h, "size-4")]), + Failed: ({ title, detail }) => queryError(h, title, detail), + Succeeded: ({ rows }) => + rows.length === 0 + ? emptyState(h, { title: "No rows", hint: ["The query ran and returned an empty result."] }) + : resultTable(h, rows), + }) + +export const view = Submodel.defineView((model, inputs, h) => { + const sql = queryOf(inputs.context.location).get("sql") ?? "" + return pageShell(h, { sidebar: querySidebar(h, model), toolbar: consoleToolbar(h, model), onOpenFilters: Message.ClickedOpenFilters() }, [ + h.div( + [h.Class("flex h-full min-h-0 flex-col")], + [editor(h, model, sql), h.div([h.Class("min-h-0 flex-1 overflow-auto")], [result(h, model)])], + ), + ]) +}) diff --git a/apps/local-ui-foldkit/src/page/registry.ts b/apps/local-ui-foldkit/src/page/registry.ts new file mode 100644 index 0000000000..0bd60c1d5b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/registry.ts @@ -0,0 +1,225 @@ +import type { Resources } from "../platform/resources" +import { Schema } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { AppRoute } from "../route" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Lane } from "../data/lanes" +import type { PageContext, PageOutMessage } from "./contract" +import type { PageViewInputs } from "./kit/inputs" +import * as Errors from "./errors" +import * as Logs from "./logs" +import * as MetricDetail from "./metric-detail" +import * as Metrics from "./metrics" +import * as Query from "./query" +import * as ServiceDetail from "./service-detail" +import * as ServiceMap from "./service-map" +import * as Services from "./services" +import * as SessionDetail from "./session-detail" +import * as Sessions from "./sessions" +import * as TraceDetail from "./trace-detail" +import * as Traces from "./traces" + +// Every page: its Model in the root, its Messages lifted, one entry per route. + +/** Before the first route resolves, the page slot holds this. */ +export const PlaceholderPage = Schema.TaggedStruct("PlaceholderPage", { route: Schema.String }) +export type PlaceholderPage = typeof PlaceholderPage.Type + +export const placeholder = (route: string): PlaceholderPage => ({ _tag: "PlaceholderPage", route }) + +export const PageModel = Schema.Union([ + Traces.Model, + TraceDetail.Model, + Logs.Model, + Metrics.Model, + MetricDetail.Model, + Services.Model, + ServiceDetail.Model, + ServiceMap.Model, + Errors.Model, + Sessions.Model, + SessionDetail.Model, + Query.Model, + PlaceholderPage, +]) +export type PageModel = typeof PageModel.Type + +export const PageMessage = defineMessageUnion({ + GotTracesMessage: { message: Traces.Message }, + GotTraceDetailMessage: { message: TraceDetail.Message }, + GotLogsMessage: { message: Logs.Message }, + GotMetricsMessage: { message: Metrics.Message }, + GotMetricDetailMessage: { message: MetricDetail.Message }, + GotServicesMessage: { message: Services.Message }, + GotServiceDetailMessage: { message: ServiceDetail.Message }, + GotServiceMapMessage: { message: ServiceMap.Message }, + GotErrorsMessage: { message: Errors.Message }, + GotSessionsMessage: { message: Sessions.Message }, + GotSessionDetailMessage: { message: SessionDetail.Message }, + GotQueryMessage: { message: Query.Message }, +}) +export type PageMessage = typeof PageMessage.Type + +export type PageReturn = Update.ReturnWithOutMessage + +/** The page instance survives while this is unchanged; a search change arrives as `locationChanged`. */ +export const pageKeyOf = (route: AppRoute): string => JSON.stringify(route) + +const lift = ( + result: Readonly<{ + model: PageModel + commands?: ReadonlyArray> + outMessage?: PageOutMessage + }>, + toMessage: (message: M) => PageMessage, +): PageReturn => ({ + model: result.model, + commands: Command.mapMessages(result.commands ?? [], toMessage), + outMessage: result.outMessage, +}) + +const toTraces = (message: Traces.Message) => PageMessage.GotTracesMessage({ message }) +const toTraceDetail = (message: TraceDetail.Message) => PageMessage.GotTraceDetailMessage({ message }) +const toLogs = (message: Logs.Message) => PageMessage.GotLogsMessage({ message }) +const toMetrics = (message: Metrics.Message) => PageMessage.GotMetricsMessage({ message }) +const toMetricDetail = (message: MetricDetail.Message) => PageMessage.GotMetricDetailMessage({ message }) +const toServices = (message: Services.Message) => PageMessage.GotServicesMessage({ message }) +const toServiceDetail = (message: ServiceDetail.Message) => PageMessage.GotServiceDetailMessage({ message }) +const toServiceMap = (message: ServiceMap.Message) => PageMessage.GotServiceMapMessage({ message }) +const toErrors = (message: Errors.Message) => PageMessage.GotErrorsMessage({ message }) +const toSessions = (message: Sessions.Message) => PageMessage.GotSessionsMessage({ message }) +const toSessionDetail = (message: SessionDetail.Message) => PageMessage.GotSessionDetailMessage({ message }) +const toQuery = (message: Query.Message) => PageMessage.GotQueryMessage({ message }) + +export const initPage = (route: AppRoute, context: PageContext): PageReturn => + AppRoute.match(route, { + Traces: () => lift(Traces.init(context), toTraces), + TraceDetail: ({ traceId }) => lift(TraceDetail.init(context, traceId), toTraceDetail), + Logs: () => lift(Logs.init(context), toLogs), + Metrics: () => lift(Metrics.init(context), toMetrics), + MetricDetail: ({ metricName }) => lift(MetricDetail.init(context, metricName), toMetricDetail), + Services: () => lift(Services.init(context), toServices), + ServiceDetail: ({ serviceName }) => lift(ServiceDetail.init(context, serviceName), toServiceDetail), + ServiceMap: () => lift(ServiceMap.init(context), toServiceMap), + Errors: () => lift(Errors.init(context), toErrors), + Sessions: () => lift(Sessions.init(context), toSessions), + SessionDetail: ({ sessionId }) => lift(SessionDetail.init(context, sessionId), toSessionDetail), + Query: () => lift(Query.init(context), toQuery), + }) + +/** A page's Message reaches it only while it is the open page (a late answer for another is dropped). */ +export const updatePage = (page: PageModel, message: PageMessage, context: PageContext): PageReturn => { + const unchanged: PageReturn = { model: page } + return PageMessage.match(message, { + GotTracesMessage: ({ message: m }) => + page._tag === "TracesPage" ? lift(Traces.update(page, m, context), toTraces) : unchanged, + GotTraceDetailMessage: ({ message: m }) => + page._tag === "TraceDetailPage" ? lift(TraceDetail.update(page, m, context), toTraceDetail) : unchanged, + GotLogsMessage: ({ message: m }) => (page._tag === "LogsPage" ? lift(Logs.update(page, m, context), toLogs) : unchanged), + GotMetricsMessage: ({ message: m }) => + page._tag === "MetricsPage" ? lift(Metrics.update(page, m, context), toMetrics) : unchanged, + GotMetricDetailMessage: ({ message: m }) => + page._tag === "MetricDetailPage" ? lift(MetricDetail.update(page, m, context), toMetricDetail) : unchanged, + GotServicesMessage: ({ message: m }) => + page._tag === "ServicesPage" ? lift(Services.update(page, m, context), toServices) : unchanged, + GotServiceDetailMessage: ({ message: m }) => + page._tag === "ServiceDetailPage" ? lift(ServiceDetail.update(page, m, context), toServiceDetail) : unchanged, + GotServiceMapMessage: ({ message: m }) => + page._tag === "ServiceMapPage" ? lift(ServiceMap.update(page, m, context), toServiceMap) : unchanged, + GotErrorsMessage: ({ message: m }) => + page._tag === "ErrorsPage" ? lift(Errors.update(page, m, context), toErrors) : unchanged, + GotSessionsMessage: ({ message: m }) => + page._tag === "SessionsPage" ? lift(Sessions.update(page, m, context), toSessions) : unchanged, + GotSessionDetailMessage: ({ message: m }) => + page._tag === "SessionDetailPage" ? lift(SessionDetail.update(page, m, context), toSessionDetail) : unchanged, + GotQueryMessage: ({ message: m }) => (page._tag === "QueryPage" ? lift(Query.update(page, m, context), toQuery) : unchanged), + }) +} + +/** The notice's Try again: the open page's own refresh, where it has one. */ +export const refreshPage = (page: PageModel, context: PageContext): PageReturn => { + if (page._tag === "TracesPage") return updatePage(page, PageMessage.GotTracesMessage({ message: Traces.Message.ClickedRefresh() }), context) + if (page._tag === "TraceDetailPage") + return updatePage(page, PageMessage.GotTraceDetailMessage({ message: TraceDetail.Message.ClickedRefresh() }), context) + if (page._tag === "LogsPage") return updatePage(page, PageMessage.GotLogsMessage({ message: Logs.Message.ClickedRefresh() }), context) + if (page._tag === "MetricsPage") return updatePage(page, PageMessage.GotMetricsMessage({ message: Metrics.Message.ClickedRefresh() }), context) + if (page._tag === "MetricDetailPage") + return updatePage(page, PageMessage.GotMetricDetailMessage({ message: MetricDetail.Message.ClickedRefresh() }), context) + if (page._tag === "ServicesPage") return updatePage(page, PageMessage.GotServicesMessage({ message: Services.Message.ClickedRefresh() }), context) + if (page._tag === "ServiceDetailPage") + return updatePage(page, PageMessage.GotServiceDetailMessage({ message: ServiceDetail.Message.ClickedRefresh() }), context) + if (page._tag === "ServiceMapPage") + return updatePage(page, PageMessage.GotServiceMapMessage({ message: ServiceMap.Message.ClickedRefresh() }), context) + if (page._tag === "ErrorsPage") return updatePage(page, PageMessage.GotErrorsMessage({ message: Errors.Message.ClickedRefresh() }), context) + if (page._tag === "SessionsPage") return updatePage(page, PageMessage.GotSessionsMessage({ message: Sessions.Message.ClickedRefresh() }), context) + if (page._tag === "SessionDetailPage") + return updatePage(page, PageMessage.GotSessionDetailMessage({ message: SessionDetail.Message.ClickedRefresh() }), context) + return { model: page } +} + +/** The open page sees a new fragment (filters, range, selection) without being rebuilt. */ +export const locationChanged = (page: PageModel, context: PageContext): PageReturn => { + if (page._tag === "TracesPage") return lift(Traces.locationChanged(page, context), toTraces) + if (page._tag === "TraceDetailPage") return lift(TraceDetail.locationChanged(page, context), toTraceDetail) + if (page._tag === "LogsPage") return lift(Logs.locationChanged(page, context), toLogs) + if (page._tag === "MetricsPage") return lift(Metrics.locationChanged(page, context), toMetrics) + if (page._tag === "MetricDetailPage") return lift(MetricDetail.locationChanged(page, context), toMetricDetail) + if (page._tag === "ServicesPage") return lift(Services.locationChanged(page, context), toServices) + if (page._tag === "ServiceDetailPage") return lift(ServiceDetail.locationChanged(page, context), toServiceDetail) + if (page._tag === "ServiceMapPage") return lift(ServiceMap.locationChanged(page, context), toServiceMap) + if (page._tag === "ErrorsPage") return lift(Errors.locationChanged(page, context), toErrors) + if (page._tag === "SessionsPage") return lift(Sessions.locationChanged(page, context), toSessions) + if (page._tag === "SessionDetailPage") return lift(SessionDetail.locationChanged(page, context), toSessionDetail) + if (page._tag === "QueryPage") return lift(Query.locationChanged(page, context), toQuery) + return { model: page } +} + +/** The requests the page may have in flight; the root stops those a step supersedes or abandons. */ +export const lanesOf = (page: PageModel): ReadonlyArray => { + if (page._tag === "TracesPage") return Traces.lanes(page) + if (page._tag === "TraceDetailPage") return TraceDetail.lanes(page) + if (page._tag === "LogsPage") return Logs.lanes(page) + if (page._tag === "MetricsPage") return Metrics.lanes(page) + if (page._tag === "MetricDetailPage") return MetricDetail.lanes(page) + if (page._tag === "ServicesPage") return Services.lanes(page) + if (page._tag === "ServiceDetailPage") return ServiceDetail.lanes(page) + if (page._tag === "ServiceMapPage") return ServiceMap.lanes(page) + if (page._tag === "ErrorsPage") return Errors.lanes(page) + if (page._tag === "SessionsPage") return Sessions.lanes(page) + if (page._tag === "SessionDetailPage") return SessionDetail.lanes(page) + if (page._tag === "QueryPage") return Query.lanes(page) + return [] +} + +/** The open page as a Submodel view; its Messages arrive lifted into `PageMessage`. */ +export const viewPage =

( + h: HtmlBuilder

, + page: PageModel, + viewInputs: PageViewInputs, + toParent: (message: PageMessage) => P, +): Html => { + const slot = (slotId: string, toPage: (message: M) => PageMessage) => ({ + slotId, + viewInputs, + toParentMessage: (message: M) => toParent(toPage(message)), + }) + if (page._tag === "TracesPage") return h.submodel({ ...slot("page:traces", toTraces), model: page, view: Traces.view }) + if (page._tag === "TraceDetailPage") + return h.submodel({ ...slot("page:trace-detail", toTraceDetail), model: page, view: TraceDetail.view }) + if (page._tag === "LogsPage") return h.submodel({ ...slot("page:logs", toLogs), model: page, view: Logs.view }) + if (page._tag === "MetricsPage") return h.submodel({ ...slot("page:metrics", toMetrics), model: page, view: Metrics.view }) + if (page._tag === "MetricDetailPage") + return h.submodel({ ...slot("page:metric-detail", toMetricDetail), model: page, view: MetricDetail.view }) + if (page._tag === "ServicesPage") return h.submodel({ ...slot("page:services", toServices), model: page, view: Services.view }) + if (page._tag === "ServiceDetailPage") + return h.submodel({ ...slot("page:service-detail", toServiceDetail), model: page, view: ServiceDetail.view }) + if (page._tag === "ServiceMapPage") + return h.submodel({ ...slot("page:service-map", toServiceMap), model: page, view: ServiceMap.view }) + if (page._tag === "ErrorsPage") return h.submodel({ ...slot("page:errors", toErrors), model: page, view: Errors.view }) + if (page._tag === "SessionsPage") return h.submodel({ ...slot("page:sessions", toSessions), model: page, view: Sessions.view }) + if (page._tag === "SessionDetailPage") + return h.submodel({ ...slot("page:session-detail", toSessionDetail), model: page, view: SessionDetail.view }) + if (page._tag === "QueryPage") return h.submodel({ ...slot("page:query", toQuery), model: page, view: Query.view }) + return h.div([], []) +} diff --git a/apps/local-ui-foldkit/src/page/service-detail/command.ts b/apps/local-ui-foldkit/src/page/service-detail/command.ts new file mode 100644 index 0000000000..3f92966404 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/command.ts @@ -0,0 +1,85 @@ +import { GoldenSignalRow, OperationRow, Overview, SeriesPoint } from "./model" +import { cachedAs, withCache } from "../../data/cached" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { fetchGoldenSignals, fetchOperations, fetchOperationsTimeseries, fetchOverview } from "./fetch" +import { Message } from "./message" + +const Window = { key: Schema.String, serviceName: Schema.String, startTime: Schema.String, endTime: Schema.String, apiBase: Schema.String } + +export const ReadClock = Command.define("ReadServiceDetailClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadServiceDetailRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + +const windowKey = (startTime: string, endTime: string) => `${startTime}/${endTime}` +const same = (data: A) => data + +export const FetchOverview = Command.define("FetchLocalServiceOverview", { + args: { ...Window, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchOverview, Message.FailedFetchOverview, Message.FoundCachedOverview], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, serviceName, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: ["local", "services", "overview", serviceName, windowKey(startTime, endTime)], + readsCache, + fromCached: cachedAs(Overview), + toCached: same, + fetch: fetchOverview(serviceName, { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedOverview({ key, cached }), + succeeded: (overview, fetchedAtMs) => Message.SucceededFetchOverview({ key, overview, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchOverview({ key, failure })))), +}) + +export const FetchOperations = Command.define("FetchLocalServiceOperations", { + args: { ...Window, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchOperations, Message.FailedFetchOperations, Message.FoundCachedOperations], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, serviceName, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: ["local", "services", "operations", serviceName, windowKey(startTime, endTime)], + readsCache, + fromCached: cachedAs(Schema.Array(OperationRow)), + toCached: same, + fetch: fetchOperations(serviceName, { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedOperations({ key, cached }), + succeeded: (operations, fetchedAtMs) => Message.SucceededFetchOperations({ key, operations, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchOperations({ key, failure })))), +}) + +export const FetchTimeseries = Command.define("FetchLocalServiceOperationsTimeseries", { + args: { ...Window, spanNames: Schema.Array(Schema.String), bucketSeconds: Schema.Number, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTimeseries, Message.FailedFetchTimeseries, Message.FoundCachedTimeseries], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, serviceName, startTime, endTime, apiBase, spanNames, bucketSeconds, readsCache }) => + withCache({ + key: ["local", "services", "operations-ts", serviceName, spanNames, bucketSeconds, windowKey(startTime, endTime)], + readsCache, + fromCached: cachedAs(Schema.Array(SeriesPoint)), + toCached: same, + fetch: fetchOperationsTimeseries(serviceName, spanNames, bucketSeconds, { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedTimeseries({ key, cached }), + succeeded: (points, fetchedAtMs) => Message.SucceededFetchTimeseries({ key, points, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTimeseries({ key, failure })))), +}) + +export const FetchGoldenSignals = Command.define("FetchLocalServiceGoldenSignals", { + args: { ...Window, bucketSeconds: Schema.Number, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchGoldenSignals, Message.FailedFetchGoldenSignals, Message.FoundCachedGoldenSignals], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, serviceName, startTime, endTime, apiBase, bucketSeconds, readsCache }) => + withCache({ + key: ["local", "services", "golden-signals", serviceName, bucketSeconds, windowKey(startTime, endTime)], + readsCache, + fromCached: cachedAs(Schema.Array(GoldenSignalRow)), + toCached: same, + fetch: fetchGoldenSignals(serviceName, bucketSeconds, { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedGoldenSignals({ key, cached }), + succeeded: (rows, fetchedAtMs) => Message.SucceededFetchGoldenSignals({ key, rows, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchGoldenSignals({ key, failure })))), +}) diff --git a/apps/local-ui-foldkit/src/page/service-detail/fetch.ts b/apps/local-ui-foldkit/src/page/service-detail/fetch.ts new file mode 100644 index 0000000000..fe97fd341b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/fetch.ts @@ -0,0 +1,90 @@ +import { CH } from "@maple/query-engine" +import { parseClickHouseDateTime, type TimeBounds } from "@maple/local-ui/lib/time" +import { DateTime, Effect } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import { toCatalogEntry } from "../services/fetch" +import type { GoldenSignalRow, OperationRow, Overview, SeriesPoint } from "./model" + +// The query functions of apps/local-ui/src/hooks/use-local-service-detail.ts. + +/** Header stats from the catalog (merged t-digests) and the earliest entry span in the window. */ +export const fetchOverview = (serviceName: string, bounds: TimeBounds, apiBase: string) => + Effect.gen(function* () { + const params = localParams(bounds) + const [catalogRows, overviewRows] = yield* Effect.all( + [ + queryRows(CH.compile(CH.serviceCatalogQuery({ serviceName, limit: 1 }), params), apiBase), + queryRows(CH.compile(CH.serviceOverviewQuery({ serviceName }), params), apiBase), + ], + { concurrency: "unbounded" }, + ) + const row = catalogRows[0] + if (!row) return null + const firstSeenMs = overviewRows.reduce((earliest, overview) => { + const ms = parseClickHouseDateTime(DateTime.formatIso(overview.firstSeen)) + return ms !== null && (earliest === null || ms < earliest) ? ms : earliest + }, null) + const overview: Overview = { stats: toCatalogEntry(row, 0), firstSeenMs } + return overview + }) + +export const fetchOperations = (serviceName: string, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows(CH.compile(CH.serviceOperationsSummaryQuery({ serviceName, limit: 25 }), localParams(bounds)), apiBase), + // A null average renders as legacy's null does: the empty value (`formatLatency`, `formatErrorRate`). + (rows): ReadonlyArray => + rows.map((row) => ({ + ...row, + errorRate: row.errorRate ?? Number.NaN, + avgDurationMs: row.avgDurationMs ?? Number.NaN, + })), + ) + +/** Per-bucket span counts for the top operations: the chart's `requests_per_sec` makes them a rate. */ +export const fetchOperationsTimeseries = ( + serviceName: string, + spanNames: ReadonlyArray, + bucketSeconds: number, + bounds: TimeBounds, + apiBase: string, +) => + Effect.map( + queryRows( + CH.compile(CH.serviceOperationsTimeseriesQuery({ serviceName, spanNames, bucketSeconds }), { + ...localParams(bounds), + bucketSeconds, + }), + apiBase, + ), + (rows): ReadonlyArray => + rows.map((row) => ({ bucket: DateTime.formatIso(row.bucket), series: row.spanName, value: row.count })), + ) + +/** Latency, throughput, error rate and Apdex from the service's entry spans (`rootOnly`). */ +export const fetchGoldenSignals = (serviceName: string, bucketSeconds: number, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows( + CH.compile( + CH.tracesTimeseriesQuery({ + metric: "count", + needsSampling: false, + allMetrics: true, + serviceName, + rootOnly: true, + bucketSeconds, + }), + { ...localParams(bounds), bucketSeconds }, + ), + apiBase, + ), + (rows): ReadonlyArray => + rows.map((row) => ({ + bucketMs: DateTime.toEpochMillis(row.bucket), + count: row.count, + errorRate: row.errorRate, + p50Ms: row.p50Duration, + p95Ms: row.p95Duration, + p99Ms: row.p99Duration, + apdexScore: row.apdexScore, + })), + ) diff --git a/apps/local-ui-foldkit/src/page/service-detail/index.ts b/apps/local-ui-foldkit/src/page/service-detail/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/service-detail/lanes.ts b/apps/local-ui-foldkit/src/page/service-detail/lanes.ts new file mode 100644 index 0000000000..2d24a235b0 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/lanes.ts @@ -0,0 +1,11 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchGoldenSignals, FetchOperations, FetchOverview, FetchTimeseries } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchOverview.name, model.overview, (key) => FetchOverview.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchOperations.name, model.operations, (key) => FetchOperations.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchTimeseries.name, model.timeseries, (key) => FetchTimeseries.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchGoldenSignals.name, model.golden, (key) => FetchGoldenSignals.Interrupt({ key }, outcomeOf)), +] diff --git a/apps/local-ui-foldkit/src/page/service-detail/load.ts b/apps/local-ui-foldkit/src/page/service-detail/load.ts new file mode 100644 index 0000000000..a6dac13172 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/load.ts @@ -0,0 +1,82 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, chartWindow, type ChartWindow, type TimeBounds } from "@maple/local-ui/lib/time" +import { Array, Option } from "effect" +import { AsyncData, type Command } from "foldkit" +import { keyOf, request } from "../../data/slot" +import { type PageContext, rangeOf } from "../contract" +import { FetchGoldenSignals, FetchOperations, FetchOverview, FetchTimeseries } from "./command" +import type { Message } from "./message" +import type { Model } from "./model" + +const CHART_SERIES_LIMIT = 8 + +export type SlotName = "overview" | "operations" | "timeseries" | "golden" + +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +/** `chartWindow(bounds, firstSeenMs)`: clipped to when the service first showed up, ending now. */ +export const chartOf = (model: Model, bounds: TimeBounds): ChartWindow => { + const firstSeenMs = Option.getOrNull( + Option.flatMap(AsyncData.getData(model.overview.data), (overview) => Option.fromNullishOr(overview?.firstSeenMs)), + ) + return chartWindow(bounds, firstSeenMs, model.nowMs) +} + +/** The chart's series: the first eight operations by volume. */ +export const topSpanNames = (model: Model): ReadonlyArray => + Option.match(AsyncData.getData(model.operations.data), { + onNone: () => [], + onSome: (operations) => operations.slice(0, CHART_SERIES_LIMIT).map((operation) => operation.spanName), + }) + +/** + * Requests every query whose key the window, the overview's first-seen time or the top operations + * changed. `forced` refetches those slots in place, as `invalidateQueries` or a Retry does. + */ +export const load = ( + model: Model, + context: PageContext, + forced: ReadonlyArray = [], +): Readonly<{ model: Model; commands?: ReadonlyArray> }> => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const { serviceName } = model + const window = { serviceName, ...bounds, apiBase: context.env.apiBase } + const boundsKey = `${bounds.startTime}/${bounds.endTime}` + const { bucketSeconds } = chartOf(model, bounds) + const spanNames = topSpanNames(model) + const keys = { + overview: keyOf("service-overview", { serviceName, boundsKey }), + operations: keyOf("service-operations", { serviceName, boundsKey }), + timeseries: keyOf("service-operations-ts", { serviceName, spanNames, bucketSeconds, boundsKey }), + golden: keyOf("service-golden-signals", { serviceName, bucketSeconds, boundsKey }), + } + // A key the slot has no rows for opens on its cached result; a forced refetch does not. + const readsCache = (slot: SlotName) => !forced.includes(slot) && model[slot].key !== keys[slot] + const overview = request(model.overview, keys.overview, forced.includes("overview")) + const operations = request(model.operations, keys.operations, forced.includes("operations")) + // `skipToken` while no operation is known: nothing to chart. + const timeseries = + spanNames.length === 0 ? Option.none() : request(model.timeseries, keys.timeseries, forced.includes("timeseries")) + const golden = request(model.golden, keys.golden, forced.includes("golden")) + return { + model: { + ...model, + overview: Option.getOrElse(overview, () => model.overview), + operations: Option.getOrElse(operations, () => model.operations), + timeseries: Option.getOrElse(timeseries, () => model.timeseries), + golden: Option.getOrElse(golden, () => model.golden), + }, + commands: Array.getSomes([ + Option.map(overview, () => FetchOverview({ key: keys.overview, ...window, readsCache: readsCache("overview") })), + Option.map(operations, () => FetchOperations({ key: keys.operations, ...window, readsCache: readsCache("operations") })), + Option.map(timeseries, () => + FetchTimeseries({ key: keys.timeseries, ...window, spanNames, bucketSeconds, readsCache: readsCache("timeseries") }), + ), + Option.map(golden, () => FetchGoldenSignals({ key: keys.golden, ...window, bucketSeconds, readsCache: readsCache("golden") })), + ]), + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/service-detail/message.ts b/apps/local-ui-foldkit/src/page/service-detail/message.ts new file mode 100644 index 0000000000..ab98f7f26e --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/message.ts @@ -0,0 +1,37 @@ +import * as Chart from "@maple/web-foldkit/charts/chart" +import { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { GoldenSignalRow, OperationRow, Overview, SeriesPoint } from "./model" + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchOverview: { key: Schema.String, overview: Overview, fetchedAtMs: Schema.Number }, + FailedFetchOverview: { key: Schema.String, failure: LoadFailure }, + /** The result cache answered a slot (or an older window of it) before the fetch ran. */ + FoundCachedOverview: { key: Schema.String, cached: CachedOf(Overview) }, + SucceededFetchOperations: { key: Schema.String, operations: Schema.Array(OperationRow), fetchedAtMs: Schema.Number }, + FailedFetchOperations: { key: Schema.String, failure: LoadFailure }, + FoundCachedOperations: { key: Schema.String, cached: CachedOf(Schema.Array(OperationRow)) }, + SucceededFetchTimeseries: { key: Schema.String, points: Schema.Array(SeriesPoint), fetchedAtMs: Schema.Number }, + FailedFetchTimeseries: { key: Schema.String, failure: LoadFailure }, + FoundCachedTimeseries: { key: Schema.String, cached: CachedOf(Schema.Array(SeriesPoint)) }, + SucceededFetchGoldenSignals: { key: Schema.String, rows: Schema.Array(GoldenSignalRow), fetchedAtMs: Schema.Number }, + FailedFetchGoldenSignals: { key: Schema.String, failure: LoadFailure }, + FoundCachedGoldenSignals: { key: Schema.String, cached: CachedOf(Schema.Array(GoldenSignalRow)) }, + ClickedRetryOverview: {}, + ClickedRetryOperations: {}, + ClickedRetryGoldenSignals: {}, + ClickedRefresh: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ClickedBack: {}, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, + ResolvedThemeColors: { colors: ThemeColors }, + GotChartMessage: { chartId: Schema.String, message: Chart.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/service-detail/model.ts b/apps/local-ui-foldkit/src/page/service-detail/model.ts new file mode 100644 index 0000000000..6adc89e12f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/model.ts @@ -0,0 +1,62 @@ +import * as Chart from "@maple/web-foldkit/charts/chart" +import { ChartRuntime } from "@maple/web-foldkit/charts/chart-runtime" +import { ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { ServiceCatalogEntry } from "../services/model" + +// apps/local-ui/src/hooks/use-local-service-detail.ts, as Schemas. + +/** `ServiceOverview`: the header stats and where the chart starts; null when the service is quiet. */ +export const Overview = Schema.NullOr( + Schema.Struct({ stats: ServiceCatalogEntry, firstSeenMs: Schema.NullOr(Schema.Number) }), +) +export type Overview = typeof Overview.Type + +export const OperationRow = Schema.Struct({ + spanName: Schema.String, + spanCount: Schema.Number, + estimatedSpanCount: Schema.Number, + errorCount: Schema.Number, + estimatedErrorCount: Schema.Number, + errorRate: Schema.Number, + avgDurationMs: Schema.Number, + p50DurationMs: Schema.Number, + p95DurationMs: Schema.Number, + p99DurationMs: Schema.Number, +}) +export type OperationRow = typeof OperationRow.Type + +/** `SeriesPoint` (lib/chart-series.ts): one long-form (bucket, series, value) row. */ +export const SeriesPoint = Schema.Struct({ bucket: Schema.String, series: Schema.String, value: Schema.Number }) +export type SeriesPoint = typeof SeriesPoint.Type + +/** `GoldenSignalRow` (lib/golden-signals.ts): one warehouse bucket of entry-span signals. */ +export const GoldenSignalRow = Schema.Struct({ + bucketMs: Schema.Number, + count: Schema.Number, + errorRate: Schema.Number, + p50Ms: Schema.Number, + p95Ms: Schema.Number, + p99Ms: Schema.Number, + apdexScore: Schema.Number, +}) +export type GoldenSignalRow = typeof GoldenSignalRow.Type + +export const Model = Schema.TaggedStruct("ServiceDetailPage", { + serviceName: Schema.String, + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + /** The clock at the last read: relative times and `chartWindow`'s "now". */ + nowMs: Schema.Number, + overview: SlotSchema(Overview), + operations: SlotSchema(Schema.Array(OperationRow)), + timeseries: SlotSchema(Schema.Array(SeriesPoint)), + golden: SlotSchema(Schema.Array(GoldenSignalRow)), + overviewUpdatedAtMs: Schema.Number, + isRefreshing: Schema.Boolean, + chartRuntime: ChartRuntime, + themeColors: Schema.NullOr(ThemeColors), + charts: Schema.Record(Schema.String, Chart.Model), +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/service-detail/service-detail.scene.test.ts b/apps/local-ui-foldkit/src/page/service-detail/service-detail.scene.test.ts new file mode 100644 index 0000000000..3165cfa4a9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/service-detail.scene.test.ts @@ -0,0 +1,71 @@ +import { ResolveThemeColors, ThemeMessage } from "@maple/web-foldkit/charts/theme-colors" +import { Option } from "effect" +import { click, expect as expectView, expectOutMessage, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { FetchGoldenSignals, FetchOperations, FetchOverview, FetchTimeseries } from "./command" +import { Message } from "./message" +import type { Model, OperationRow } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const NOW = Date.parse("2026-10-01T12:00:30Z") +const context: PageContext = { + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + location: { path: "/services/checkout-api", search: "" }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +} +const inputs: PageViewInputs = { context, presenceOf: () => Option.none(), presenceNowMs: NOW, clockMs: 0, hasNewDataSince: () => false } +const view = withViewInputs(pageView, inputs)() +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) +const themeResolved = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) + +const operation = (spanName: string): OperationRow => ({ + spanName, + spanCount: 30, + estimatedSpanCount: 0, + errorCount: 0, + estimatedErrorCount: 0, + errorRate: 0, + avgDurationMs: 5, + p50DurationMs: 4, + p95DurationMs: 6, + p99DurationMs: 7, +}) + +const anchored = () => pageUpdate(init(context, "checkout-api").model, Message.CompletedReadClock({ nowMs: NOW })) + +describe("local service detail", () => { + test("entry asks the overview, operations and golden signals; the operations chart waits for operations", () => { + expect(names(anchored().commands)).toEqual([FetchOverview.name, FetchOperations.name, FetchGoldenSignals.name]) + const base = anchored().model + const withOps = pageUpdate( + base, + Message.SucceededFetchOperations({ key: base.operations.key ?? "", operations: [operation("POST /checkout")], fetchedAtMs: 0 }), + ) + expect(names(withOps.commands)).toEqual([FetchTimeseries.name]) + }) + + test("an answer for a superseded key is dropped", () => { + const base = anchored().model + const stale = pageUpdate(base, Message.SucceededFetchOperations({ key: "stale", operations: [operation("x")], fetchedAtMs: 0 })) + expect(stale.model.operations).toEqual(base.operations) + }) + + test("a quiet service offers to widen the range", () => { + const base = anchored().model + const quiet = pageUpdate(base, Message.SucceededFetchOverview({ key: base.overview.key ?? "", overview: null, fetchedAtMs: NOW })) + scene( + { update: pageUpdate, view }, + given(quiet.model), + themeResolved(), + expectView(text("No spans from this service in this range")).toExist(), + click(role("button", { name: "Widen to 30 days" })), + expectOutMessage(PageOutMessage.RememberedRange({ range: "30d", hash: "#/services/checkout-api?range=30d" })), + ) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/service-detail/update.ts b/apps/local-ui-foldkit/src/page/service-detail/update.ts new file mode 100644 index 0000000000..f4a70b270c --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/update.ts @@ -0,0 +1,163 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as Chart from "@maple/web-foldkit/charts/chart" +import { LoadChartRuntime } from "@maple/web-foldkit/charts/chart-runtime" +import { Option, Record, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { accept, idle, acceptCached } from "../../data/slot" +import { hrefFor, queryOf } from "../../route" +import { type PageContext, PageOutMessage, withParams } from "../contract" +import { ReadClock, ReadRefreshClock } from "./command" +import { load, type SlotName } from "./load" +import { Message } from "./message" +import type { Model } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +/** Detail-page params that must not leak back into a list's filters. */ +const DETAIL_ONLY_PARAMS = ["spanId", "view"] + +export const init = ( + _context: PageContext, + serviceName: string, +): Readonly<{ model: Model; commands: ReadonlyArray> }> => ({ + model: { + _tag: "ServiceDetailPage", + serviceName, + maybeAnchorMs: Option.none(), + nowMs: 0, + overview: idle(), + operations: idle(), + timeseries: idle(), + golden: idle(), + overviewUpdatedAtMs: 0, + isRefreshing: false, + chartRuntime: { _tag: "Loading" }, + themeColors: null, + charts: {}, + }, + commands: [ + ReadClock(), + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], +}) + +/** A search change (the range) re-keys every query on the new window. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => load(model, context) + +/** The refresh spinner stops once nothing the refresh started is still loading. */ +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + const busy = [model.overview, model.operations, model.timeseries, model.golden].some((slot) => isLoading(slot.data)) + return model.isRefreshing && !busy ? { ...model, isRefreshing: false } : model +} + +/** A settled answer can change the chart window or the top operations, which re-key the charts. */ +const settle = (model: Model, context: PageContext): UpdateReturn => { + const next = load(model, context) + return { model: settleRefresh(next.model), commands: next.commands } +} + +/** A cached slot is on screen: re-key the charts it decides, and fetch it unless it is fresh. */ +const settleCached = (model: Model, context: PageContext, slot: SlotName, isFresh: boolean): UpdateReturn => { + const next = load(model, context, isFresh ? [] : [slot]) + return { model: settleRefresh(next.model), commands: next.commands } +} + +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +/** `back("/services")`: history back, else the list with the detail-only params dropped. */ +const back = (model: Model, context: PageContext): UpdateReturn => { + const fallback = queryOf(context.location) + DETAIL_ONLY_PARAMS.forEach((key) => fallback.delete(key)) + return { model, outMessage: PageOutMessage.RequestedBack({ fallbackHash: hrefFor("/services", fallback) }) } +} + +const retry = (model: Model, context: PageContext, slot: SlotName): UpdateReturn => load(model, context, [slot]) + +const ALL_SLOTS: ReadonlyArray = ["overview", "operations", "timeseries", "golden"] + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadClock: ({ nowMs }) => + load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + SucceededFetchOverview: ({ key, overview, fetchedAtMs }) => + settle( + { + ...model, + overview: accept(model.overview, key, Result.succeed(overview), fetchedAtMs), + overviewUpdatedAtMs: model.overview.key === key ? fetchedAtMs : model.overviewUpdatedAtMs, + }, + context, + ), + FailedFetchOverview: ({ key, failure }) => + settle({ ...model, overview: accept(model.overview, key, Result.fail(failure)) }, context), + SucceededFetchOperations: ({ key, operations, fetchedAtMs }) => + settle({ ...model, operations: accept(model.operations, key, Result.succeed(operations), fetchedAtMs) }, context), + FailedFetchOperations: ({ key, failure }) => + settle({ ...model, operations: accept(model.operations, key, Result.fail(failure)) }, context), + SucceededFetchTimeseries: ({ key, points, fetchedAtMs }) => + settle({ ...model, timeseries: accept(model.timeseries, key, Result.succeed(points), fetchedAtMs) }, context), + FailedFetchTimeseries: ({ key, failure }) => + settle({ ...model, timeseries: accept(model.timeseries, key, Result.fail(failure)) }, context), + SucceededFetchGoldenSignals: ({ key, rows, fetchedAtMs }) => + settle({ ...model, golden: accept(model.golden, key, Result.succeed(rows), fetchedAtMs) }, context), + FoundCachedOverview: ({ key, cached }) => + model.overview.key === key + ? settleCached( + { ...model, overview: acceptCached(model.overview, key, cached, cached.isFresh), overviewUpdatedAtMs: cached.updatedAtMs }, + context, + "overview", + cached.isFresh, + ) + : { model }, + FoundCachedOperations: ({ key, cached }) => + model.operations.key === key + ? settleCached({ ...model, operations: acceptCached(model.operations, key, cached, cached.isFresh) }, context, "operations", cached.isFresh) + : { model }, + FoundCachedTimeseries: ({ key, cached }) => + model.timeseries.key === key + ? settleCached({ ...model, timeseries: acceptCached(model.timeseries, key, cached, cached.isFresh) }, context, "timeseries", cached.isFresh) + : { model }, + FoundCachedGoldenSignals: ({ key, cached }) => + model.golden.key === key + ? settleCached({ ...model, golden: acceptCached(model.golden, key, cached, cached.isFresh) }, context, "golden", cached.isFresh) + : { model }, + FailedFetchGoldenSignals: ({ key, failure }) => + settle({ ...model, golden: accept(model.golden, key, Result.fail(failure)) }, context), + ClickedRetryOverview: () => retry(model, context, "overview"), + ClickedRetryOperations: () => retry(model, context, "operations"), + ClickedRetryGoldenSignals: () => retry(model, context, "golden"), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, ALL_SLOTS) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ClickedBack: () => back(model, context), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), + FailedLoadChartRuntime: ({ reason }) => ({ model: { ...model, chartRuntime: { _tag: "Failed", reason } } }), + ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + GotChartMessage: ({ chartId, message: child }) => { + const current = Option.getOrElse(Record.get(model.charts, chartId), Chart.init) + const next = Chart.update(current, child) + return { + model: { ...model, charts: Record.set(model.charts, chartId, next.model) }, + commands: Command.mapMessages(next.commands ?? [], (chart) => Message.GotChartMessage({ chartId, message: chart })), + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/service-detail/view-golden.ts b/apps/local-ui-foldkit/src/page/service-detail/view-golden.ts new file mode 100644 index 0000000000..e47b271985 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/view-golden.ts @@ -0,0 +1,97 @@ +import type { ChartWindow } from "@maple/local-ui/lib/time" +import { fillGoldenSignals } from "@maple/local-ui/lib/golden-signals" +import * as Chart from "@maple/web-foldkit/charts/chart" +import { ChartSpec } from "@maple/web-foldkit/charts/chart-spec" +import * as FixedMetricChart from "@maple/web-foldkit/charts/fixed-metric-chart" +import { fixedMetricLegend, latencySeries } from "@maple/web-foldkit/charts/fixed-metric-legend" +import { LATENCY_SERIES } from "@maple/web-foldkit/charts/fixed-metric-series" +import { resolveColor, type ThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { chartSkeleton } from "@maple/web-foldkit/ui/chart-skeleton" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "./message" +import type { GoldenSignalRow, Model } from "./model" + +// `GoldenSignals` (apps/local-ui/src/views/service-detail-view.tsx): the four charts web's service +// page opens with, `LatencyLineChart`, `ThroughputAreaChart`, `ApdexAreaChart`, `ErrorRateAreaChart`. + +const GOLDEN_CHART_CLASS = "h-44 w-full" + +type Kind = "Latency" | "Throughput" | "Apdex" | "ErrorRate" + +const TILES: ReadonlyArray> = [ + { id: "latency", kind: "Latency", title: "Latency", ariaLabel: "Latency percentiles", gradientPrefix: "latency" }, + { id: "throughput", kind: "Throughput", title: "Throughput", ariaLabel: "Throughput", gradientPrefix: "throughput" }, + { id: "apdex", kind: "Apdex", title: "Apdex", ariaLabel: "Apdex score", gradientPrefix: "apdex" }, + { id: "error-rate", kind: "ErrorRate", title: "Error rate", ariaLabel: "Error rate", gradientPrefix: "errorRate" }, +] + +/** The tokens the golden charts and the operations chart read off the theme. */ +export const GOLDEN_TOKENS = ["--chart-throughput", "--chart-error", "--chart-apdex", "--chart-p99", "--chart-p95", "--chart-p50"] + +const colorsOf = (kind: Kind, theme: ThemeColors): Readonly> => { + const chrome = { border: resolveColor(theme, "--border"), background: resolveColor(theme, "--background") } + if (kind === "Latency") + return { ...chrome, ...Object.fromEntries(LATENCY_SERIES.map((entry) => [entry.key, resolveColor(theme, entry.token)])) } + if (kind === "Throughput") + return { ...chrome, throughput: resolveColor(theme, "--chart-throughput"), error: resolveColor(theme, "--chart-error") } + if (kind === "Apdex") return { ...chrome, apdexScore: resolveColor(theme, "--chart-apdex") } + return { ...chrome, errorRate: resolveColor(theme, "--chart-error") } +} + +export type ChartContext = Readonly<{ theme: ThemeColors; nowMs: number }> + +const tileChart = ( + h: HtmlBuilder, + model: Model, + tile: (typeof TILES)[number], + rows: ReadonlyArray>>, + context: ChartContext, +): Html => + h.submodel({ + slotId: `golden-${tile.id}`, + model: model.charts[tile.id] ?? Chart.init(), + view: FixedMetricChart.view, + viewInputs: { + chartId: tile.id, + ariaLabel: tile.ariaLabel, + className: GOLDEN_CHART_CLASS, + spec: ChartSpec.FixedMetric({ + chart: tile.kind, + rows, + colors: colorsOf(tile.kind, context.theme), + perSecond: tile.kind === "Throughput", + timeZone: null, + nowMs: context.nowMs, + gradientPrefix: tile.gradientPrefix, + showTooltip: true, + }), + legend: tile.kind === "Latency" ? fixedMetricLegend(h, latencySeries(context.theme)) : undefined, + }, + toParentMessage: (message: Chart.Message) => Message.GotChartMessage({ chartId: tile.id, message }), + }) + +/** Pending (or the chart runtime and theme not ready yet) draws the area skeleton in each tile. */ +export const goldenSignals = ( + h: HtmlBuilder, + model: Model, + rows: ReadonlyArray, + window: ChartWindow, + isPending: boolean, + context: ChartContext | null, +): Html => { + const points = fillGoldenSignals(rows, window).map((point) => ({ ...point })) + return h.section( + [h.Class("grid gap-3 md:grid-cols-2")], + TILES.map((tile) => + h.div( + [h.Key(tile.title), h.Class("space-y-2 rounded-md border p-3")], + [ + h.h3([h.Class("text-sm font-medium")], [tile.title]), + isPending || context === null + ? chartSkeleton(h, "area", GOLDEN_CHART_CLASS) + : tileChart(h, model, tile, points, context), + ], + ), + ), + ) +} diff --git a/apps/local-ui-foldkit/src/page/service-detail/view-operations.ts b/apps/local-ui-foldkit/src/page/service-detail/view-operations.ts new file mode 100644 index 0000000000..7593414182 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/view-operations.ts @@ -0,0 +1,66 @@ +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/web-foldkit/ui/table" +import { errorRateValue, latencyValue } from "@maple/web-foldkit/ui/values" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { linkRow, rowLink } from "../kit/row-link" +import { errorState } from "../kit/view-states" +import { Message } from "./message" +import type { Model, OperationRow } from "./model" +import { tracesHref } from "./view-parts" + +// The "Top operations" section of `ServiceDetailView`. + +const HEADERS = ["Spans", "Errors", "Error rate", "Avg", "p50", "p95"] + +const operationRow = (h: HtmlBuilder, serviceName: string, range: string, op: OperationRow): Html => + linkRow(h, undefined, [ + tableCell( + h, + [rowLink(h, { href: tracesHref(serviceName, range, op.spanName), label: `Traces with ${op.spanName}` }, [op.spanName])], + "max-w-96 truncate font-mono text-xs", + ), + tableCell(h, [formatNumber(op.estimatedSpanCount || op.spanCount)], "text-right tabular-nums"), + tableCell( + h, + [formatNumber(op.estimatedErrorCount || op.errorCount)], + cn("text-right tabular-nums", op.errorCount > 0 && "text-severity-error"), + ), + tableCell(h, [errorRateValue(h, { rate: op.errorRate })], "text-right tabular-nums"), + tableCell(h, [latencyValue(h, { ms: op.avgDurationMs, scale: "avg" })], "text-right"), + tableCell(h, [latencyValue(h, { ms: op.p50DurationMs, scale: "p50" })], "text-right"), + tableCell(h, [latencyValue(h, { ms: op.p95DurationMs, scale: "p95" })], "text-right"), + ]) + +const operationsBody = (h: HtmlBuilder, model: Model, range: string): Html => { + if (isPending(model.operations)) + return h.div([h.Class("flex h-24 items-center justify-center rounded-md border")], [spinner(h)]) + const failure = failureWithoutData(model.operations) + if (Option.isSome(failure)) + return errorState(h, { label: "operations", failure: failure.value, onRetry: Message.ClickedRetryOperations() }) + const operations = Option.getOrElse(AsyncData.getData(model.operations.data), () => []) + if (operations.length === 0) + return h.div( + [h.Class("flex h-24 items-center justify-center rounded-md border text-sm text-muted-foreground")], + ["No operations recorded in this range."], + ) + return h.div( + [h.Class("rounded-md border")], + [ + table(h, {}, [ + tableHeader(h, [tableRow(h, [tableHead(h, ["Operation"]), ...HEADERS.map((header) => tableHead(h, [header], "text-right"))])]), + tableBody(h, operations.map((op) => operationRow(h, model.serviceName, range, op))), + ]), + ], + ) +} + +export const topOperations = (h: HtmlBuilder, model: Model, range: string): Html => + h.section( + [h.Class("space-y-2")], + [h.h3([h.Class("text-sm font-medium")], ["Top operations"]), operationsBody(h, model, range)], + ) diff --git a/apps/local-ui-foldkit/src/page/service-detail/view-parts.ts b/apps/local-ui-foldkit/src/page/service-detail/view-parts.ts new file mode 100644 index 0000000000..caf3fdf904 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/view-parts.ts @@ -0,0 +1,84 @@ +import { errorRateClass } from "@maple/ui/lib/error-rate" +import { formatDuration, formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" +import { cn } from "@maple/ui/lib/utils" +import { button, linkButton } from "@maple/web-foldkit/ui/button" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../route" +import { refreshButton, timeRangeSelect } from "../kit/toolbar" +import { Message } from "./message" +import type { ServiceCatalogEntry } from "../services/model" + +// The header and stat cards of `ServiceDetailView` (apps/local-ui/src/views/service-detail-view.tsx). + +/** Traces touching this service, in span scope: most services never own a trace's root span. */ +export const tracesHref = (serviceName: string, range: string, spanName?: string): string => { + const params = new URLSearchParams({ scope: "spans", service: serviceName, range }) + if (spanName) params.set("span", spanName) + return hrefFor("/traces", params) +} + +export const detailHeader = ( + h: HtmlBuilder, + options: Readonly<{ + serviceName: string + backLabel: string + environments: ReadonlyArray + range: string + isRefreshing: boolean + hasNewData: boolean + }>, +): Html => + h.div( + [h.Class("flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-4 py-2")], + [ + button(h, { variant: "ghost", size: "sm", className: "gap-1.5", attributes: [h.OnClick(Message.ClickedBack())] }, [ + icon(h, "ArrowLeftIcon"), + options.backLabel, + ]), + h.span( + [h.Class("flex min-w-0 items-center gap-2")], + [ + serviceDot(h, { serviceName: options.serviceName }), + h.span([h.Class("truncate text-sm font-medium")], [options.serviceName]), + ], + ), + ...options.environments.map((environment) => + badge(h, { variant: "outline", className: "px-1.5 py-0 text-[10px]", attributes: [h.Key(environment)] }, [environment]), + ), + h.div( + [h.Class("ml-auto flex items-center gap-2")], + [ + linkButton(h, { variant: "outline", size: "sm", href: tracesHref(options.serviceName, options.range) }, ["View traces"]), + refreshButton(h, { isSpinning: options.isRefreshing, hasNewData: options.hasNewData, onRefresh: Message.ClickedRefresh() }), + timeRangeSelect(h, options.range, (range) => Message.ChangedRange({ range })), + ], + ), + ], + ) + +/** `StatCard`: `valueClassName` comes after `danger`, so the latency ramp wins. */ +const statCard = (h: HtmlBuilder, label: string, value: string, isDanger = false, valueClassName?: string): Html => + h.div( + [h.Class("rounded-md border bg-card px-3 py-2")], + [ + h.div([h.Class("text-[10px] font-medium uppercase tracking-wider text-muted-foreground")], [label]), + h.div([h.Class(cn("text-lg font-semibold tabular-nums", isDanger && "text-severity-error", valueClassName))], [value]), + ], + ) + +export const statCards = (h: HtmlBuilder, stats: ServiceCatalogEntry): Html => + h.section( + [h.Class("grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6")], + [ + statCard(h, "Spans", formatNumber(stats.spanCount)), + statCard(h, "Errors", formatNumber(stats.errorCount), stats.errorCount > 0), + statCard(h, "Error rate", formatErrorRate(stats.errorRate), false, errorRateClass(stats.errorRate, { neutral: "" })), + statCard(h, "p50", formatDuration(stats.p50LatencyMs), false, latencyToneClass(stats.p50LatencyMs, "p50")), + statCard(h, "p95", formatDuration(stats.p95LatencyMs), false, latencyToneClass(stats.p95LatencyMs, "p95")), + statCard(h, "p99", formatDuration(stats.p99LatencyMs), false, latencyToneClass(stats.p99LatencyMs, "p99")), + ], + ) diff --git a/apps/local-ui-foldkit/src/page/service-detail/view.ts b/apps/local-ui-foldkit/src/page/service-detail/view.ts new file mode 100644 index 0000000000..16d133b5d3 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-detail/view.ts @@ -0,0 +1,108 @@ +import { WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as Chart from "@maple/web-foldkit/charts/chart" +import { AREA_CHART_TOKENS } from "@maple/web-foldkit/charts/area-chart" +import { isChartRuntimeReady } from "@maple/web-foldkit/charts/chart-runtime" +import { ResolveThemeColors } from "@maple/web-foldkit/charts/theme-colors" +import { button } from "@maple/web-foldkit/ui/button" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { seriesChart } from "../kit/series-chart" +import { emptyState, errorState } from "../kit/view-states" +import { boundsOf, chartOf, topSpanNames } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { GOLDEN_TOKENS, goldenSignals } from "./view-golden" +import { topOperations } from "./view-operations" +import { detailHeader, statCards } from "./view-parts" + +// `ServiceDetailView` (apps/local-ui/src/views/service-detail-view.tsx). + +const THEME_TOKENS = [...GOLDEN_TOKENS, ...AREA_CHART_TOKENS] + +const quiet = (h: HtmlBuilder, range: string): Html => + h.div( + [h.Class("flex h-full flex-col items-center justify-center gap-3")], + [ + emptyState(h, { + title: "No spans from this service in this range", + hint: ["Widen the time range, or send some traffic to this service."], + }), + ...(range !== WIDEST_RANGE + ? [button(h, { variant: "outline", size: "sm", attributes: [h.OnClick(Message.ClickedWidenRange())] }, ["Widen to 30 days"])] + : []), + ], + ) + +const loaded = (h: HtmlBuilder, model: Model, inputs: PageViewInputs, stats: Parameters[1]): Html => { + const range = rangeOf(inputs.context) + const bounds = Option.getOrNull(boundsOf(model, inputs.context)) + const context = + model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) ? { theme: model.themeColors, nowMs: model.nowMs } : null + const goldenFailure = failureWithoutData(model.golden) + const charts = + bounds === null + ? [] + : [ + Option.isSome(goldenFailure) + ? errorState(h, { label: "service charts", failure: goldenFailure.value, onRetry: Message.ClickedRetryGoldenSignals() }) + : goldenSignals( + h, + model, + Option.getOrElse(AsyncData.getData(model.golden.data), () => []), + chartOf(model, bounds), + isPending(model.golden), + context, + ), + h.section( + [h.Class("space-y-2")], + [ + h.h3([h.Class("text-sm font-medium")], ["Throughput by operation"]), + seriesChart(h, { + chartId: "operations-throughput", + points: Option.getOrElse(AsyncData.getData(model.timeseries.data), () => []), + window: chartOf(model, bounds), + fill: "zero", + unit: "requests_per_sec", + isPending: isPending(model.timeseries) && topSpanNames(model).length > 0, + chart: model.charts["operations-throughput"] ?? Chart.init(), + toChartMessage: (message) => Message.GotChartMessage({ chartId: "operations-throughput", message }), + theme: context === null ? null : context.theme, + nowMs: model.nowMs, + }), + ], + ), + ] + return h.div([h.Class("space-y-6 p-4")], [statCards(h, stats), ...charts, topOperations(h, model, range)]) +} + +const content = (h: HtmlBuilder, model: Model, inputs: PageViewInputs): Html => { + if (isPending(model.overview)) return h.div([h.Class("flex h-full items-center justify-center")], [spinner(h)]) + const failure = failureWithoutData(model.overview) + if (Option.isSome(failure)) + return errorState(h, { label: "service", failure: failure.value, onRetry: Message.ClickedRetryOverview() }) + const overview = Option.getOrNull(AsyncData.getData(model.overview.data)) + return overview === null ? quiet(h, rangeOf(inputs.context)) : loaded(h, model, inputs, overview.stats) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const overview = Option.getOrNull(AsyncData.getData(model.overview.data)) + return h.div( + [h.Class("flex h-full flex-col"), h.OnMount(ResolveThemeColors({ tokens: THEME_TOKENS }))], + [ + detailHeader(h, { + serviceName: model.serviceName, + backLabel: inputs.context.canGoBack ? "Back" : "Services", + environments: overview?.stats.deploymentEnvironments ?? [], + range: rangeOf(inputs.context), + isRefreshing: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.overviewUpdatedAtMs), + }), + h.div([h.Class("min-h-0 flex-1 overflow-auto")], [content(h, model, inputs)]), + ], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/service-map/command.ts b/apps/local-ui-foldkit/src/page/service-map/command.ts new file mode 100644 index 0000000000..09e889a86d --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/command.ts @@ -0,0 +1,89 @@ +import { readLocalStorage, writeLocalStorage } from "@maple/ui/lib/local-storage" +import { Snapshot, ViewPrefs } from "@maple/web-foldkit/service-map/map/state" +import { Clock, Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { withCache } from "../../data/cached" +import { mapFromCached } from "./data" +import { fetchServiceMap } from "./fetch" +import { Message } from "./message" + +// The map query, the clock reads and the per-browser storage of lib/service-map-state.ts. + +const LAYOUT_KEY = "maple-local:service-map.layout" +const VIEW_PREFS_KEY = "maple-local:service-map.view-prefs" + +const LayoutJson = Schema.fromJsonString(Schema.Struct({ snapshots: Schema.Array(Snapshot) })) +const PrefsJson = Schema.fromJsonString(ViewPrefs) + +export const ReadClock = Command.define("ReadServiceMapClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadServiceMapRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + +/** Undecodable entries fall back to the defaults, as `storedValue` does. */ +export const ReadStored = Command.define("ReadLocalServiceMapStored", { + messages: [Message.CompletedReadStored], + execute: Effect.sync(() => + Message.CompletedReadStored({ + stored: { + prefs: Option.getOrElse( + Option.flatMap(readLocalStorage(VIEW_PREFS_KEY), Schema.decodeUnknownOption(PrefsJson)), + () => ({ minTrafficPct: 0, collapsedNamespaces: [] }), + ), + snapshots: Option.match( + Option.flatMap(readLocalStorage(LAYOUT_KEY), Schema.decodeUnknownOption(LayoutJson)), + { onNone: () => [], onSome: (layout) => layout.snapshots }, + ), + }, + }), + ), +}) + +export const SaveLayout = Command.define("SaveLocalServiceMapLayout", { + args: { snapshots: Schema.Array(Snapshot) }, + messages: [Message.CompletedSaveLayout], + execute: ({ snapshots }) => + Effect.sync(() => { + writeLocalStorage(LAYOUT_KEY, JSON.stringify({ snapshots })) + return Message.CompletedSaveLayout() + }), +}) + +export const SaveViewPrefs = Command.define("SaveLocalServiceMapViewPrefs", { + args: { prefs: ViewPrefs }, + messages: [Message.CompletedSaveViewPrefs], + execute: ({ prefs }) => + Effect.sync(() => { + writeLocalStorage(VIEW_PREFS_KEY, JSON.stringify(prefs)) + return Message.CompletedSaveViewPrefs() + }), +}) + +/** `useLocalServiceMap`: `windowed(["local", "service-map", durationSeconds, boundsKey(bounds)])`. */ +export const FetchServiceMap = Command.define("FetchLocalServiceMap", { + args: { + key: Schema.String, + startTime: Schema.String, + endTime: Schema.String, + durationSeconds: Schema.Number, + apiBase: Schema.String, + readsCache: Schema.Boolean, + }, + messages: [Message.SucceededFetchServiceMap, Message.FailedFetchServiceMap, Message.FoundCachedServiceMap], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, startTime, endTime, durationSeconds, apiBase, readsCache }) => + withCache({ + key: ["local", "service-map", durationSeconds, `${startTime}/${endTime}`], + readsCache, + fromCached: mapFromCached, + toCached: (data) => data, + fetch: fetchServiceMap({ startTime, endTime }, durationSeconds, apiBase), + found: (cached) => Message.FoundCachedServiceMap({ key, cached }), + succeeded: (data, fetchedAtMs) => Message.SucceededFetchServiceMap({ key, data, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchServiceMap({ key, failure })))), +}) diff --git a/apps/local-ui-foldkit/src/page/service-map/data.test.ts b/apps/local-ui-foldkit/src/page/service-map/data.test.ts new file mode 100644 index 0000000000..78479c873f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/data.test.ts @@ -0,0 +1,60 @@ +import { Option } from "effect" +import { describe, expect, test } from "vitest" +import { asCacheValue, hashKey, parseSaved, serialize } from "../../data/cache-store" +import { type LocalServiceMapData, mapFromCached } from "./data" + +const data: LocalServiceMapData = { + edges: [ + { + sourceService: "storefront", + targetService: "checkout-api", + callCount: 30, + estimatedCallCount: 30, + errorCount: 1, + errorRate: 1 / 30, + avgDurationMs: 12, + maxDurationMs: 40, + hasSampling: false, + samplingWeight: 1, + }, + ], + dbEdges: [], + overviews: [ + { + serviceName: "storefront", + serviceNamespace: "shop", + environment: "production", + commits: [{ commitSha: "abc", spanCount: 3, percentage: 100, errorCount: 0, firstSeen: "" }], + p50LatencyMs: 10, + p95LatencyMs: 20, + p99LatencyMs: 30, + errorRate: 0, + throughput: 1, + tracedThroughput: 1, + hasSampling: false, + samplingWeight: 1, + spanCount: 3, + }, + ], + platforms: new Map([["storefront", "web"]]), + runtimes: new Map([["storefront", "nodejs"]]), +} + +describe("service map result cache", () => { + test("a recorded map reads back as the same rows", () => { + const restored = Option.flatMap(asCacheValue(data), mapFromCached) + expect(restored).toEqual(Option.some(data)) + }) + + test("a saved map survives a reload", () => { + const key = ["local", "service-map", 3600, "2026-10-01 11:00:00/2026-10-01 13:00:00"] + const entry = { key, data: Option.getOrThrow(asCacheValue(data)), updatedAtMs: 1_000 } + const restored = parseSaved(serialize(new Map([[hashKey(key), entry]])), 2_000).get(hashKey(key)) + expect(Option.flatMap(Option.fromNullishOr(restored), (saved) => mapFromCached(saved.data))).toEqual(Option.some(data)) + }) + + test("an older build's shape is not offered as the map", () => { + const olderBuildEntry = Option.flatMap(asCacheValue({ ...data, platforms: { storefront: "web" } }), mapFromCached) + expect(Option.isNone(olderBuildEntry)).toBe(true) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/service-map/data.ts b/apps/local-ui-foldkit/src/page/service-map/data.ts new file mode 100644 index 0000000000..898189d182 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/data.ts @@ -0,0 +1,96 @@ +import type { ServiceOverview } from "@maple/query-engine" +import type { + ServiceMapDbEdgeRow, + ServiceMapEdgeRow, + ServicePlatform, +} from "@maple/ui/components/service-map/service-map-types" +import { Schema } from "effect" +import { cachedAs } from "../../data/cached" + +// `LocalServiceMapData` (apps/local-ui/src/hooks/use-local-service-map.ts) as Schemas, so the map +// goes through the result cache like the other pages. Each mirrors the shared row type it decodes. + +export const EdgeRow = Schema.Struct({ + sourceService: Schema.String, + targetService: Schema.String, + callCount: Schema.Number, + estimatedCallCount: Schema.Number, + errorCount: Schema.Number, + errorRate: Schema.Number, + avgDurationMs: Schema.Number, + maxDurationMs: Schema.Number, + hasSampling: Schema.Boolean, + samplingWeight: Schema.Number, +}) + +export const DbEdgeRow = Schema.Struct({ + sourceService: Schema.String, + dbSystem: Schema.String, + dbNamespace: Schema.String, + callCount: Schema.Number, + estimatedCallCount: Schema.Number, + errorCount: Schema.Number, + errorRate: Schema.Number, + avgDurationMs: Schema.Number, + maxDurationMs: Schema.Number, + p95DurationMs: Schema.Number, + hasSampling: Schema.Boolean, + samplingWeight: Schema.Number, +}) + +const CommitBreakdown = Schema.Struct({ + commitSha: Schema.String, + spanCount: Schema.Number, + percentage: Schema.Number, + errorCount: Schema.Number, + firstSeen: Schema.String, +}) + +export const OverviewRow = Schema.Struct({ + serviceName: Schema.String, + serviceNamespace: Schema.String, + environment: Schema.String, + commits: Schema.Array(CommitBreakdown), + p50LatencyMs: Schema.Number, + p95LatencyMs: Schema.Number, + p99LatencyMs: Schema.Number, + errorRate: Schema.Number, + throughput: Schema.Number, + tracedThroughput: Schema.Number, + hasSampling: Schema.Boolean, + samplingWeight: Schema.Number, + spanCount: Schema.Number, +}) + +export const Platform = Schema.Literals(["kubernetes", "cloudflare", "gcp", "lambda", "web", "unknown"]) + +export const LocalServiceMapData = Schema.Struct({ + edges: Schema.Array(EdgeRow), + dbEdges: Schema.Array(DbEdgeRow), + /** One row per (service, environment), sampling-corrected like the cloud map's. */ + overviews: Schema.Array(OverviewRow), + platforms: Schema.ReadonlyMap(Schema.String, Platform), + /** `process.runtime.name` per service. */ + runtimes: Schema.ReadonlyMap(Schema.String, Schema.String), +}) +export type LocalServiceMapData = typeof LocalServiceMapData.Type + +/** The rows `buildSourceGraph` takes; building them type-checks the Schemas against the shared types. */ +export const graphRowsOf = ( + data: LocalServiceMapData, +): Readonly<{ + edges: Array + dbEdges: Array + serviceOverviews: Array + platforms: Map + runtimes: Map +}> => ({ + edges: data.edges.map((row) => ({ ...row })), + dbEdges: data.dbEdges.map((row) => ({ ...row })), + serviceOverviews: data.overviews.map((row) => ({ ...row, commits: row.commits.map((commit) => ({ ...commit })) })), + platforms: new Map(data.platforms), + runtimes: new Map(data.runtimes), +}) + +/** The saved map as this build reads it, or none for an older build's shape. */ +export const mapFromCached = cachedAs(LocalServiceMapData) diff --git a/apps/local-ui-foldkit/src/page/service-map/fetch.ts b/apps/local-ui-foldkit/src/page/service-map/fetch.ts new file mode 100644 index 0000000000..f813521e95 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/fetch.ts @@ -0,0 +1,39 @@ +import { CH, coerceServiceOverviewRows } from "@maple/query-engine" +import { LOCAL_ORG_ID } from "@maple/query-engine/local" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { Effect } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import type { LocalServiceMapData } from "./data" +import { aggregateEdges, classifyPlatform, toDbEdge } from "./rows" + +// `useLocalServiceMap` (apps/local-ui/src/hooks/use-local-service-map.ts): everything the map +// draws, from the query-engine builders the cloud map's bundle uses. + +export const fetchServiceMap = (bounds: TimeBounds, durationSeconds: number, apiBase: string) => + Effect.gen(function* () { + const params = localParams(bounds) + const [edgeRows, dbEdgeRows, overviewRows, platformRows] = yield* Effect.all( + [ + queryRows( + CH.serviceMapEdgesRollupSQL({ orgId: LOCAL_ORG_ID, hourStart: bounds.startTime, hourEnd: bounds.endTime }), + apiBase, + ), + queryRows(CH.serviceDbEdgesSQL({}, params), apiBase), + queryRows(CH.compile(CH.serviceOverviewQuery({}), params), apiBase), + queryRows(CH.servicePlatformsSQL({}, params), apiBase), + ], + { concurrency: "unbounded" }, + ) + const platforms = new Map(platformRows.map((row) => [row.serviceName, classifyPlatform(row)] as const)) + const runtimes = new Map( + platformRows.flatMap((row) => (row.processRuntimeName ? [[row.serviceName, row.processRuntimeName] as const] : [])), + ) + const data: LocalServiceMapData = { + edges: aggregateEdges(edgeRows, durationSeconds), + dbEdges: dbEdgeRows.map((row) => toDbEdge(row, durationSeconds)), + overviews: coerceServiceOverviewRows(overviewRows, durationSeconds), + platforms, + runtimes, + } + return data + }) diff --git a/apps/local-ui-foldkit/src/page/service-map/index.ts b/apps/local-ui-foldkit/src/page/service-map/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/service-map/lanes.ts b/apps/local-ui-foldkit/src/page/service-map/lanes.ts new file mode 100644 index 0000000000..8d72dadc44 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/lanes.ts @@ -0,0 +1,8 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchServiceMap } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchServiceMap.name, model.data, (key) => FetchServiceMap.Interrupt({ key }, outcomeOf)), +] diff --git a/apps/local-ui-foldkit/src/page/service-map/load.ts b/apps/local-ui-foldkit/src/page/service-map/load.ts new file mode 100644 index 0000000000..ae9ea95cf5 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/load.ts @@ -0,0 +1,45 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, resolveRange, type TimeBounds } from "@maple/local-ui/lib/time" +import { buildSourceGraph, type SourceGraph } from "@maple/web-foldkit/service-map/map/graph" +import { Option } from "effect" +import type { Command } from "foldkit" +import { keyOf, request } from "../../data/slot" +import { type PageContext, rangeOf } from "../contract" +import { FetchServiceMap } from "./command" +import { graphRowsOf, type LocalServiceMapData } from "./data" +import type { Message } from "./message" +import type { Model } from "./model" + +/** The window's logical length: rates divide by it, not by the clock-skew pad on the bounds. */ +export const durationSecondsOf = (context: PageContext): number => resolveRange(rangeOf(context)).minutes * 60 + +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +/** `buildGraph`: the flow graph and its focusable services from the rows. */ +export const sourceGraphOf = (data: LocalServiceMapData, durationSeconds: number): SourceGraph => + buildSourceGraph({ ...graphRowsOf(data), durationSeconds }) + +/** Requests the map for the current window (`["local", "service-map", durationSeconds, boundsKey]`). */ +export const load = ( + model: Model, + context: PageContext, + force = false, +): Readonly<{ model: Model; commands?: ReadonlyArray> }> => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const durationSeconds = durationSecondsOf(context) + const key = keyOf("service-map", { durationSeconds, bounds: `${bounds.startTime}/${bounds.endTime}` }) + return Option.match(request(model.data, key, force), { + onNone: () => ({ model }), + onSome: (data) => ({ + model: { ...model, data }, + // A new window reads the cache first; a refetch of the same one goes to the server. + commands: [ + FetchServiceMap({ key, ...bounds, durationSeconds, apiBase: context.env.apiBase, readsCache: model.data.key !== key }), + ], + }), + }) + }, + }) diff --git a/apps/local-ui-foldkit/src/page/service-map/message.ts b/apps/local-ui-foldkit/src/page/service-map/message.ts new file mode 100644 index 0000000000..6596d4c899 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/message.ts @@ -0,0 +1,27 @@ +import * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { LocalServiceMapData } from "./data" +import { Stored } from "./model" + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + CompletedReadStored: { stored: Stored }, + SucceededFetchServiceMap: { key: Schema.String, data: LocalServiceMapData, fetchedAtMs: Schema.Number }, + /** The result cache held this window (or an older one standing in) before the fetch ran. */ + FoundCachedServiceMap: { key: Schema.String, cached: CachedOf(LocalServiceMapData) }, + FailedFetchServiceMap: { key: Schema.String, failure: LoadFailure }, + ClickedRefresh: {}, + ClickedRetry: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + CompletedSaveLayout: {}, + CompletedSaveViewPrefs: {}, + GotMapMessage: { message: ServiceMap.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/service-map/model.ts b/apps/local-ui-foldkit/src/page/service-map/model.ts new file mode 100644 index 0000000000..8a71420130 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/model.ts @@ -0,0 +1,27 @@ +import * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import { Snapshot, ViewPrefs } from "@maple/web-foldkit/service-map/map/state" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" +import { LocalServiceMapData } from "./data" + +// `ServiceMapView` (apps/local-ui/src/views/service-map-view.tsx). + +/** What local mode keeps per browser (lib/service-map-state.ts): declutter prefs and layout snapshots. */ +export const Stored = Schema.Struct({ prefs: ViewPrefs, snapshots: Schema.Array(Snapshot) }) +export type Stored = typeof Stored.Type + +export const Model = Schema.TaggedStruct("ServiceMapPage", { + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + nowMs: Schema.Number, + data: SlotSchema(LocalServiceMapData), + /** `dataUpdatedAt` of the map query, for the "New data" pill. */ + dataUpdatedAtMs: Schema.Number, + /** localStorage read on entry; the map is built once it and the rows are in. */ + maybeStored: Schema.Option(Stored), + maybeMap: Schema.Option(ServiceMap.Model), + isRefreshing: Schema.Boolean, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/service-map/rows.ts b/apps/local-ui-foldkit/src/page/service-map/rows.ts new file mode 100644 index 0000000000..66fb9ee635 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/rows.ts @@ -0,0 +1,93 @@ +import { CH, summarizeSampling } from "@maple/query-engine" +import type { + ServiceMapDbEdgeRow, + ServiceMapEdgeRow, + ServicePlatform, +} from "@maple/ui/components/service-map/service-map-types" + +// copied: `aggregateEdges`, `toDbEdge`, `classifyPlatform` (apps/local-ui/src/hooks/use-local-service-map.ts; +// the React module also holds the query hook). + +type EdgeBucket = Pick< + CH.ServiceMapEdgesHourlyOutput, + "SourceService" | "TargetService" | "CallCount" | "ErrorCount" | "DurationSumMs" | "MaxDurationMs" | "SampleRateSum" +> + +interface EdgeTotals { + readonly source: string + readonly target: string + readonly callCount: number + readonly errorCount: number + readonly durationSumMs: number + readonly maxDurationMs: number + readonly estimatedSpanCount: number +} + +const addBucket = (totals: ReadonlyMap, row: EdgeBucket): ReadonlyMap => { + const key = `${row.SourceService}\u0000${row.TargetService}` + const entry = totals.get(key) ?? { + source: row.SourceService, + target: row.TargetService, + callCount: 0, + errorCount: 0, + durationSumMs: 0, + maxDurationMs: 0, + estimatedSpanCount: 0, + } + return new Map(totals).set(key, { + ...entry, + callCount: entry.callCount + row.CallCount, + errorCount: entry.errorCount + row.ErrorCount, + durationSumMs: entry.durationSumMs + row.DurationSumMs, + maxDurationMs: Math.max(entry.maxDurationMs, row.MaxDurationMs), + estimatedSpanCount: entry.estimatedSpanCount + (row.SampleRateSum > 0 ? row.SampleRateSum : row.CallCount), + }) +} + +/** Service-to-service edges over the whole window, summed from the raw span join's buckets. */ +export const aggregateEdges = (rows: ReadonlyArray, durationSeconds: number): Array => + Array.from(rows.reduce(addBucket, new Map()).values(), (t) => { + const sampling = summarizeSampling(t.estimatedSpanCount, t.callCount, durationSeconds) + return { + sourceService: t.source, + targetService: t.target, + callCount: t.callCount, + estimatedCallCount: sampling.hasSampling ? Math.round(t.estimatedSpanCount) : t.callCount, + errorCount: t.errorCount, + errorRate: t.callCount > 0 ? t.errorCount / t.callCount : 0, + avgDurationMs: t.callCount > 0 ? t.durationSumMs / t.callCount : 0, + maxDurationMs: t.maxDurationMs, + hasSampling: sampling.hasSampling, + samplingWeight: sampling.weight, + } + }).sort((a, b) => b.callCount - a.callCount) + +export const toDbEdge = (row: CH.ServiceDbEdgesOutput, durationSeconds: number): ServiceMapDbEdgeRow => { + const { callCount, errorCount, estimatedSpanCount } = row + const sampling = summarizeSampling(estimatedSpanCount, callCount, durationSeconds) + return { + sourceService: row.sourceService, + dbSystem: row.dbSystem, + dbNamespace: row.dbNamespace, + callCount, + estimatedCallCount: sampling.hasSampling ? Math.round(estimatedSpanCount) : callCount, + errorCount, + errorRate: callCount > 0 ? errorCount / callCount : 0, + avgDurationMs: row.avgDurationMs, + maxDurationMs: row.maxDurationMs, + p95DurationMs: row.p95DurationMs, + hasSampling: sampling.hasSampling, + samplingWeight: sampling.weight, + } +} + +/** Same precedence as the cloud API's platform classifier: host infrastructure beats SDK self-report. */ +export const classifyPlatform = (row: CH.ServicePlatformsOutput): ServicePlatform => { + if (row.cloudPlatform === "cloudflare.workers" || row.cloudProvider === "cloudflare") return "cloudflare" + // Before the Lambda rule: Google Cloud's detector sets `faas.name` on Cloud Run and Cloud Functions. + if (row.cloudProvider === "gcp") return "gcp" + if (row.faasName !== "" || row.cloudPlatform === "aws_lambda") return "lambda" + // `k8s.cluster.name` alone does not prove the service runs in Kubernetes. + if (row.k8sPodName !== "" || row.k8sDeploymentName !== "") return "kubernetes" + return row.mapleSdkType === "client" ? "web" : "unknown" +} diff --git a/apps/local-ui-foldkit/src/page/service-map/service-map.scene.test.ts b/apps/local-ui-foldkit/src/page/service-map/service-map.scene.test.ts new file mode 100644 index 0000000000..dd037745d6 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/service-map.scene.test.ts @@ -0,0 +1,126 @@ +import * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import { Option } from "effect" +import { expect as expectView, given, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { FetchServiceMap, ReadClock, ReadStored, SaveViewPrefs } from "./command" +import type { LocalServiceMapData } from "./data" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const NOW = Date.parse("2026-10-01T12:00:30Z") + +const context: PageContext = { + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + location: { path: "/service-map", search: "" }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +} +const inputs: PageViewInputs = { + context, + presenceOf: () => Option.some({ signal: "traces", status: "absent", lastSeenMs: null }), + presenceNowMs: NOW, + clockMs: 0, + hasNewDataSince: () => false, +} +const view = withViewInputs(pageView, inputs)() +const pageUpdate = (model: Model, message: Message) => update(model, message, context) + +const edge = (sourceService: string, targetService: string) => ({ + sourceService, + targetService, + callCount: 30, + estimatedCallCount: 30, + errorCount: 0, + errorRate: 0, + avgDurationMs: 12, + maxDurationMs: 40, + hasSampling: false, + samplingWeight: 1, +}) + +const mapData = (edges: LocalServiceMapData["edges"]): LocalServiceMapData => ({ + edges, + dbEdges: [], + overviews: [], + platforms: new Map(), + runtimes: new Map(), +}) +const twoServices = mapData([edge("storefront", "checkout-api")]) +const noServices = mapData([]) + +/** Entered, anchored and the stored state read: the map request in flight. */ +const requested = (): Model => { + const anchored = pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })).model + return pageUpdate(anchored, Message.CompletedReadStored({ stored: { prefs: { minTrafficPct: 0, collapsedNamespaces: [] }, snapshots: [] } })).model +} + +const answered = (data: LocalServiceMapData) => { + const base = requested() + return pageUpdate(base, Message.SucceededFetchServiceMap({ key: base.data.key ?? "", data, fetchedAtMs: NOW })) +} + +const mapOf = (model: Model) => Option.getOrThrow(model.maybeMap) + +describe("local service map", () => { + test("entry reads the clock and the stored layout, then asks the map once", () => { + expect(init(context).commands.map(({ name }) => name)).toEqual([ReadClock.name, ReadStored.name]) + const anchored = pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + expect((anchored.commands ?? []).map(({ name }) => name)).toEqual([FetchServiceMap.name]) + }) + + test("an answer for a superseded key is dropped and builds no map", () => { + const base = requested() + const stale = pageUpdate(base, Message.SucceededFetchServiceMap({ key: "stale", data: twoServices, fetchedAtMs: NOW })) + expect(stale.model.data).toEqual(base.data) + expect(Option.isNone(stale.model.maybeMap)).toBe(true) + }) + + test("a new window reads the result cache first; an older window stands in while it refetches", () => { + const base = requested() + const anchored = pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + expect((anchored.commands ?? []).map(({ args }) => args)).toMatchObject([{ readsCache: true }]) + const cached = { data: twoServices, updatedAtMs: NOW - 3_600_000, isStandIn: true, isFresh: false } + const found = pageUpdate(base, Message.FoundCachedServiceMap({ key: base.data.key ?? "", cached })) + expect(mapOf(found.model).source.services).toEqual(["checkout-api", "storefront"]) + expect(found.model.data.isStandIn).toBe(true) + const refetches = (found.commands ?? []).filter(({ name }) => name === FetchServiceMap.name) + expect(refetches.map(({ args }) => args)).toMatchObject([{ key: base.data.key, readsCache: false }]) + }) + + test("a fresh cached window is shown without a fetch", () => { + const base = requested() + const cached = { data: twoServices, updatedAtMs: NOW, isStandIn: false, isFresh: true } + const found = pageUpdate(base, Message.FoundCachedServiceMap({ key: base.data.key ?? "", cached })) + expect((found.commands ?? []).map(({ name }) => name)).not.toContain(FetchServiceMap.name) + expect(found.model.data.updatedAtMs).toBe(NOW) + }) + + test("rows build the graph: two services, one connection, no databases", () => { + const map = mapOf(answered(twoServices).model) + expect(map.source.services).toEqual(["checkout-api", "storefront"]) + expect(map.source.rawNodes.length).toBe(2) + }) + + test("an empty map asks for signal presence and renders the on-ramp", () => { + const result = answered(noServices) + expect(result.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + scene({ update: pageUpdate, view }, given(result.model), expectView(text("No services yet")).toExist()) + }) + + test("focusing a selected service keeps the prefs; showing all traffic is saved", () => { + const loaded = answered(twoServices).model + const selected = pageUpdate(loaded, Message.GotMapMessage({ message: ServiceMap.Message.ClickedNode({ nodeId: "storefront" }) })) + const focused = pageUpdate(selected.model, Message.GotMapMessage({ message: ServiceMap.Message.ClickedFocusSelected() })) + expect(Option.map(mapOf(focused.model).focus, (focus) => focus.serviceId)).toEqual(Option.some("storefront")) + expect((focused.commands ?? []).map(({ name }) => name)).not.toContain(SaveViewPrefs.name) + const filtered = { ...focused.model, maybeMap: Option.some({ ...mapOf(focused.model), minTrafficPct: 5 }) } + const reset = pageUpdate(filtered, Message.GotMapMessage({ message: ServiceMap.Message.ClickedShowAllTraffic() })) + expect(mapOf(reset.model).minTrafficPct).toBe(0) + expect((reset.commands ?? []).map(({ name }) => name)).toContain(SaveViewPrefs.name) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/service-map/update-map.ts b/apps/local-ui-foldkit/src/page/service-map/update-map.ts new file mode 100644 index 0000000000..bd0e68520b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/update-map.ts @@ -0,0 +1,71 @@ +import type { Resources } from "../../platform/resources" +import * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import { Option } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { type PageContext, PageOutMessage } from "../contract" +import { SaveLayout, SaveViewPrefs } from "./command" +import { durationSecondsOf, sourceGraphOf } from "./load" +import { Message } from "./message" +import type { Model } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +const toMap = (message: ServiceMap.Message) => Message.GotMapMessage({ message }) + +const merge = (first: UpdateReturn, next: UpdateReturn): UpdateReturn => ({ + model: next.model, + commands: [...(first.commands ?? []), ...(next.commands ?? [])], + outMessage: next.outMessage ?? first.outMessage, +}) + +/** + * Folds a map step into the page; `onLayoutChange` / `onViewPrefsChange` write localStorage + * whenever the map's drags, cameras or declutter prefs moved. + */ +export const withMapStep = (model: Model, step: Update.Return): UpdateReturn => { + const map = step.model + const previous = Option.getOrUndefined(model.maybeMap) + const isPrefsChanged = + previous !== undefined && + (previous.minTrafficPct !== map.minTrafficPct || previous.collapsedNamespaces !== map.collapsedNamespaces) + const isLayoutChanged = previous !== undefined && previous.snapshots !== map.snapshots + return { + model: { ...model, maybeMap: Option.some(map) }, + commands: [ + ...Command.mapMessages(step.commands ?? [], toMap), + ...(isPrefsChanged + ? [SaveViewPrefs({ prefs: { minTrafficPct: map.minTrafficPct, collapsedNamespaces: map.collapsedNamespaces } })] + : []), + ...(isLayoutChanged ? [SaveLayout({ snapshots: map.snapshots })] : []), + ], + } +} + +/** + * The graph follows the rows on screen (`buildFlowElements` every render in legacy): the first + * time the rows and the stored state are both in, the map is created; later rows refine it. + */ +export const syncMap = (result: UpdateReturn, context: PageContext): UpdateReturn => { + const { model } = result + const maybeData = AsyncData.getData(model.data.data) + if (Option.isNone(maybeData) || Option.isNone(model.maybeStored)) return result + const source = sourceGraphOf(maybeData.value, durationSecondsOf(context)) + const stored = model.maybeStored.value + const step = Option.match(model.maybeMap, { + onNone: () => + ServiceMap.init(source, { + minTrafficPct: stored.prefs.minTrafficPct, + collapsedNamespaces: stored.prefs.collapsedNamespaces, + focus: Option.none(), + snapshots: stored.snapshots, + // local-ui's map frames itself on its first layout, namespace boxes or not. + isFirstBoxedFitKept: true, + }), + onSome: (map) => ServiceMap.setSource(map, source), + }) + const next = merge(result, withMapStep(model, step)) + // An empty graph renders `SignalEmptyState`, which reads the shared signal presence. + return source.rawNodes.length === 0 && next.outMessage === undefined + ? { ...next, outMessage: PageOutMessage.RequestedSignalPresence() } + : next +} diff --git a/apps/local-ui-foldkit/src/page/service-map/update.ts b/apps/local-ui-foldkit/src/page/service-map/update.ts new file mode 100644 index 0000000000..f36bae986d --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/update.ts @@ -0,0 +1,96 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import { Option, Result } from "effect" +import { AsyncData, type Command } from "foldkit" +import { accept, acceptCached, idle } from "../../data/slot" +import { type PageContext, PageOutMessage, withParams } from "../contract" +import { updateCopy } from "../kit/copies" +import { ReadClock, ReadRefreshClock, ReadStored } from "./command" +import { load } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { syncMap, type UpdateReturn, withMapStep } from "./update-map" + +export type { UpdateReturn } from "./update-map" + +export const init = ( + _context: PageContext, +): Readonly<{ model: Model; commands: ReadonlyArray> }> => ({ + model: { + _tag: "ServiceMapPage", + maybeAnchorMs: Option.none(), + nowMs: 0, + data: idle(), + dataUpdatedAtMs: 0, + maybeStored: Option.none(), + maybeMap: Option.none(), + isRefreshing: false, + copies: {}, + }, + commands: [ReadClock(), ReadStored()], +}) + +/** A new range re-keys the query; the map stays on the previous rows until the new ones land. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => load(model, context) + +/** `setRange`: remember it for pages opened without one, then put it in the hash. */ +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +/** The refresh spinner stops once the refetch it started has settled. */ +const settleRefresh = (model: Model): Model => + model.isRefreshing && !AsyncData.isLoading(model.data.data) && !AsyncData.isRefreshing(model.data.data) + ? { ...model, isRefreshing: false } + : model + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadClock: ({ nowMs }) => load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + CompletedReadStored: ({ stored }) => syncMap({ model: { ...model, maybeStored: Option.some(stored) } }, context), + SucceededFetchServiceMap: ({ key, data, fetchedAtMs }) => { + if (model.data.key !== key) return { model } + const next = { ...model, data: accept(model.data, key, Result.succeed(data), fetchedAtMs), dataUpdatedAtMs: fetchedAtMs } + return syncMap({ model: settleRefresh(next) }, context) + }, + FoundCachedServiceMap: ({ key, cached }) => { + if (model.data.key !== key) return { model } + const next = { ...model, data: acceptCached(model.data, key, cached, cached.isFresh), dataUpdatedAtMs: cached.updatedAtMs } + const shown = syncMap({ model: cached.isFresh ? settleRefresh(next) : next }, context) + if (cached.isFresh) return shown + // Shown while the window's own rows load (an older window, or a stale exact one). + const again = load(shown.model, context, true) + return { ...shown, model: again.model, commands: [...(shown.commands ?? []), ...(again.commands ?? [])] } + }, + FailedFetchServiceMap: ({ key, failure }) => ({ + model: settleRefresh({ ...model, data: accept(model.data, key, Result.fail(failure)) }), + }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (a new key); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ClickedRetry: () => load(model, context, true), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + CompletedSaveLayout: () => ({ model }), + CompletedSaveViewPrefs: () => ({ model }), + GotMapMessage: ({ message: child }) => + Option.match(model.maybeMap, { + onNone: () => ({ model }), + onSome: (map) => withMapStep(model, ServiceMap.update(map, child)), + }), + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => + Message.GotCopyMessage({ id: copyId, message: copyMessage }), + ) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/service-map/view-db-panel.ts b/apps/local-ui-foldkit/src/page/service-map/view-db-panel.ts new file mode 100644 index 0000000000..8f23edae69 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/view-db-panel.ts @@ -0,0 +1,97 @@ +import { resolveDbNodeStyle, withAlpha } from "@maple/ui/components/service-map/service-map-db-style" +import { getServiceColor } from "@maple/ui/lib/colors" +import { errorRateClass } from "@maple/ui/lib/error-rate" +import { formatErrorRate, formatLatency, formatRate } from "@maple/ui/lib/format" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" +import { DB_ICON } from "@maple/web-foldkit/service-map/map/node-card" +import { button } from "@maple/web-foldkit/ui/button" +import { icon } from "@maple/web-foldkit/ui/icon" +import { scrollArea } from "@maple/web-foldkit/ui/scroll-area" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { LocalServiceMapData } from "./data" +import { metricTile, panelHeader, peerList, type PeerRow, rates, sectionTitle, serviceHref } from "./view-panel-kit" + +// `ServiceMapDatabasePanel` (apps/local-ui/src/components/service-map-panel.tsx). + +const sum = (values: ReadonlyArray): number => values.reduce((total, value) => total + value, 0) + +export const databasePanel = ( + h: HtmlBuilder, + options: Readonly<{ dbSystem: string; dbNamespace: string; data: LocalServiceMapData; durationSeconds: number; range: string; onClose: M }>, +): Html => { + const { durationSeconds, range } = options + const callers = options.data.dbEdges.filter((e) => e.dbSystem === options.dbSystem && e.dbNamespace === options.dbNamespace) + const totalCalls = sum(callers.map((e) => e.callCount)) + const totalErrors = sum(callers.map((e) => e.errorCount)) + const estimatedCalls = sum(callers.map((e) => e.estimatedCallCount)) + const errorRate = totalCalls > 0 ? totalErrors / totalCalls : 0 + const avgLatencyMs = totalCalls > 0 ? sum(callers.map((e) => e.avgDurationMs * e.callCount)) / totalCalls : 0 + // The worst caller's p95 (a real p95); the max stands in, labelled as one, when no digest exists. + const p95 = callers.reduce((worst, e) => Math.max(worst, e.p95DurationMs), 0) + const max = callers.reduce((worst, e) => Math.max(worst, e.maxDurationMs), 0) + const hasSampling = callers.some((e) => e.hasSampling) + const db = resolveDbNodeStyle(options.dbSystem, options.dbNamespace) + const calledBy = callers.map( + (e): PeerRow => ({ + key: e.sourceService, + label: e.sourceService, + color: getServiceColor(e.sourceService), + href: serviceHref(e.sourceService, range), + ...rates(e, durationSeconds), + }), + ) + const subtitle = h.span( + [h.Class("flex items-center gap-1")], + [ + h.span( + [ + h.Class("inline-flex size-3.5 items-center justify-center rounded-sm"), + h.Style({ "background-color": withAlpha(db.color, 0.16), color: db.color }), + ], + [icon(h, DB_ICON[db.icon], { size: 10 })], + ), + db.systemLabel, + ], + ) + const latencyTile = + p95 > 0 + ? metricTile(h, { label: "P95 latency", value: formatLatency(p95), className: latencyToneClass(p95, "p95") }) + : metricTile(h, { label: "Max latency", value: formatLatency(max), title: "Slowest call in the window" }) + return h.div( + [h.Class("flex h-full flex-col overflow-hidden bg-background")], + [ + panelHeader(h, { accentColor: db.color, errorRate, title: db.title, subtitle: [subtitle] }, [ + button(h, { variant: "ghost", size: "icon-xs", attributes: [h.OnClick(options.onClose), h.AriaLabel("Close panel")] }, [ + icon(h, "XmarkIcon"), + ]), + ]), + scrollArea(h, { className: "min-h-0 flex-1", idPrefix: "service-map-db-panel" }, [ + h.div( + [h.Class("space-y-5 p-4")], + [ + h.div( + [h.Class("space-y-3")], + [ + sectionTitle(h, "Metrics"), + h.div( + [h.Class("grid grid-cols-2 gap-x-6 gap-y-4")], + [ + metricTile(h, { + label: "Calls", + value: `${hasSampling ? "~" : ""}${formatRate(estimatedCalls / Math.max(durationSeconds, 1))}`, + unit: "calls/s", + }), + metricTile(h, { label: "Error rate", value: formatErrorRate(errorRate), className: errorRateClass(errorRate, { neutral: "text-foreground" }) }), + metricTile(h, { label: "Avg latency", value: formatLatency(avgLatencyMs), className: latencyToneClass(avgLatencyMs, "avg") }), + latencyTile, + ], + ), + ], + ), + ...peerList(h, "Called by", calledBy), + ], + ), + ]), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/service-map/view-map.ts b/apps/local-ui-foldkit/src/page/service-map/view-map.ts new file mode 100644 index 0000000000..8a9e2fc712 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/view-map.ts @@ -0,0 +1,73 @@ +import { DB_NODE_PREFIX, parseDbNodeId } from "@maple/ui/components/service-map/service-map-utils" +import * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import { view as mapView } from "@maple/web-foldkit/service-map/map/view" +import { serviceMapLoading } from "@maple/web-foldkit/service-map/map/loading" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { PageViewInputs } from "../kit/inputs" +import { signalEmptyState } from "../kit/signal-empty-state" +import { rangeOf } from "../contract" +import type { LocalServiceMapData } from "./data" +import { durationSecondsOf } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { databasePanel } from "./view-db-panel" +import { servicePanel } from "./view-service-panel" + +// `ServiceMapFlowCanvas` with local mode's empty state and detail panels. + +const toMap = (message: ServiceMap.Message) => Message.GotMapMessage({ message }) + +/** `renderDetailPanel`: a database node gets its panel, anything else the service panel. */ +const detailPanel = + (h: HtmlBuilder, data: LocalServiceMapData, map: ServiceMap.Model, inputs: PageViewInputs) => + (selectedId: string): Html => { + const durationSeconds = durationSecondsOf(inputs.context) + const range = rangeOf(inputs.context) + if (selectedId.startsWith(DB_NODE_PREFIX)) + return databasePanel(h, { + ...parseDbNodeId(selectedId), + data, + durationSeconds, + range, + onClose: toMap(ServiceMap.Message.ClickedCloseDetail()), + }) + return servicePanel(h, { + serviceName: selectedId, + data, + colorMode: map.colorMode, + durationSeconds, + range, + actions: { + onFocus: toMap(ServiceMap.Message.ClickedFocusSelected()), + onClose: toMap(ServiceMap.Message.ClickedCloseDetail()), + }, + }) + } + +export const mapCanvas = ( + h: HtmlBuilder, + model: Model, + data: LocalServiceMapData, + map: ServiceMap.Model | undefined, + inputs: PageViewInputs, +): Html => { + if (map === undefined) return serviceMapLoading(h) + const range = rangeOf(inputs.context) + return mapView(h, map, toMap, { + emptyState: signalEmptyState(h, { + signal: "traces", + noun: "services", + range, + onWidenRange: Message.ClickedWidenRange(), + maybePresence: inputs.presenceOf("traces"), + nowMs: inputs.presenceNowMs, + guide: { + id: "service-map-empty", + endpoint: inputs.context.env.otlpEndpoint, + copies: model.copies, + toCopyMessage: (id, message) => Message.GotCopyMessage({ id, message }), + }, + }), + detailPanel: detailPanel(h, data, map, inputs), + }) +} diff --git a/apps/local-ui-foldkit/src/page/service-map/view-panel-kit.ts b/apps/local-ui-foldkit/src/page/service-map/view-panel-kit.ts new file mode 100644 index 0000000000..b0cfbf0bde --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/view-panel-kit.ts @@ -0,0 +1,144 @@ +import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" +import { formatErrorRate, formatRate } from "@maple/ui/lib/format" +import { TONE_FILL } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import type { ServiceMapEdgeRow } from "@maple/ui/components/service-map/service-map-types" +import { HEALTH_TONE } from "@maple/web-foldkit/service-map/map/node-card" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../route" + +// The building blocks of apps/local-ui/src/components/service-map-panel.tsx. + +export const serviceHref = (serviceName: string, range: string): string => + hrefFor(`/services/${encodeURIComponent(serviceName)}`, new URLSearchParams({ range })) + +/** Span scope: most services never own a trace's root span. */ +export const serviceTracesHref = (serviceName: string, range: string): string => + hrefFor("/traces", new URLSearchParams({ scope: "spans", service: serviceName, range })) + +/** `getHealthDotClass` (service-map-node.tsx). */ +const healthDotClass = (errorRate: number): string => TONE_FILL[HEALTH_TONE[errorRateLevel(errorRate)]] + +export const panelHeader = ( + h: HtmlBuilder, + options: Readonly<{ accentColor: string; errorRate: number; title: string; subtitle?: ReadonlyArray }>, + actions: ReadonlyArray, +): Html => + h.div( + [h.Class("flex shrink-0 items-center justify-between gap-2 border-b px-4 py-3")], + [ + h.div( + [h.Class("flex min-w-0 items-center gap-2")], + [ + h.div([h.Class("h-[18px] w-[3px] shrink-0 rounded-sm"), h.Style({ "background-color": options.accentColor })], []), + h.div([h.Class(cn("size-1.5 shrink-0 rounded-full", healthDotClass(options.errorRate)))], []), + h.div( + [h.Class("flex min-w-0 flex-col")], + [ + h.span([h.Class("truncate text-sm font-semibold text-foreground")], [options.title]), + ...(options.subtitle === undefined + ? [] + : [h.span([h.Class("truncate text-[10px] text-muted-foreground")], options.subtitle)]), + ], + ), + ], + ), + h.div([h.Class("flex shrink-0 items-center gap-1")], actions), + ], + ) + +export const sectionTitle = (h: HtmlBuilder, text: string): Html => + h.h4([h.Class("text-[10px] font-medium tracking-widest text-muted-foreground/60 uppercase")], [text]) + +export const metricTile = ( + h: HtmlBuilder, + options: Readonly<{ label: string; value: string; unit?: string; className?: string; title?: string }>, +): Html => + h.div( + [h.Class("space-y-0.5"), ...(options.title === undefined ? [] : [h.Title(options.title)])], + [ + h.span([h.Class("text-[10px] text-muted-foreground")], [options.label]), + h.p([h.Class(cn("font-mono text-xl font-semibold tabular-nums text-foreground", options.className))], [options.value]), + ...(options.unit ? [h.span([h.Class("text-[10px] text-muted-foreground")], [options.unit])] : []), + ], + ) + +export interface PeerRow { + readonly key: string + readonly label: string + readonly color: string + readonly href?: string + readonly reqPerSec: number + readonly tracedReqPerSec: number + readonly errorRate: number + readonly hasSampling: boolean + readonly samplingWeight: number +} + +type EdgeLike = Pick + +export const rates = (edge: EdgeLike, durationSeconds: number) => { + const safe = Math.max(durationSeconds, 1) + return { + reqPerSec: (edge.hasSampling ? edge.estimatedCallCount : edge.callCount) / safe, + tracedReqPerSec: edge.callCount / safe, + errorRate: edge.errorRate, + hasSampling: edge.hasSampling, + samplingWeight: edge.samplingWeight, + } +} + +const peerRow = (h: HtmlBuilder, row: PeerRow): Html => { + const name = h.span( + [h.Class("flex min-w-0 items-center gap-1.5")], + [ + h.span([h.Class("h-3.5 w-[3px] shrink-0 rounded-sm"), h.Style({ "background-color": row.color })], []), + h.span([h.Class("truncate text-foreground")], [row.label]), + ], + ) + return h.div( + [ + h.Key(row.key), + h.Class( + cn( + "flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 text-xs", + errorRateLevel(row.errorRate) === "crit" ? "border-severity-error/[0.12] bg-severity-error/[0.04]" : "border-border bg-card", + ), + ), + ...(row.hasSampling + ? [h.Title(`Estimated x${row.samplingWeight.toFixed(0)} from ${formatRate(row.tracedReqPerSec)} traced req/s`)] + : []), + ], + [ + row.href === undefined ? name : h.a([h.Href(row.href), h.Class("min-w-0 hover:underline")], [name]), + h.span( + [h.Class("flex shrink-0 items-center gap-2 text-[10px]")], + [ + h.span( + [h.Class("font-mono tabular-nums text-muted-foreground")], + [...(row.hasSampling ? ["~"] : []), formatRate(row.reqPerSec), " req/s"], + ), + h.span( + [h.Class(cn("font-mono tabular-nums", errorRateClass(row.errorRate, { neutral: "text-severity-info" })))], + [formatErrorRate(row.errorRate)], + ), + ], + ), + ], + ) +} + +/** `PeerList`: nothing when there are no peers. */ +export const peerList = (h: HtmlBuilder, title: string, rows: ReadonlyArray): ReadonlyArray => + rows.length === 0 + ? [] + : [ + h.div( + [h.Class("space-y-3")], + [ + h.div([h.Class("h-px bg-border")], []), + sectionTitle(h, title), + h.div([h.Class("space-y-1.5")], rows.map((row) => peerRow(h, row))), + ], + ), + ] diff --git a/apps/local-ui-foldkit/src/page/service-map/view-parts.ts b/apps/local-ui-foldkit/src/page/service-map/view-parts.ts new file mode 100644 index 0000000000..20c190954d --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/view-parts.ts @@ -0,0 +1,27 @@ +import { icon } from "@maple/web-foldkit/ui/icon" +import type { Html, HtmlBuilder } from "foldkit/html" + +/** `NoEdgesNotice`: services are on the map but none called another; say what draws an edge. */ +export const noEdgesNotice = (h: HtmlBuilder): Html => { + const strong = (text: string) => h.span([h.Class("font-medium text-foreground")], [text]) + return h.div( + [h.Class("flex shrink-0 items-start gap-2 border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground")], + [ + icon(h, "CircleInfoIcon", { size: 14, className: "mt-px shrink-0" }), + h.p( + [], + [ + "No service-to-service calls in this window. An edge appears when a", + " ", + strong("Client"), + " (or Producer) span in one service has a ", + strong("Server"), + " (or Consumer) child span in another, so both services need tracing and must propagate trace context between them. Database calls show up from client spans that carry", + " ", + h.code([h.Class("rounded bg-muted px-1 font-mono")], ["db.system.name"]), + ".", + ], + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/service-map/view-service-panel.ts b/apps/local-ui-foldkit/src/page/service-map/view-service-panel.ts new file mode 100644 index 0000000000..60b75e1c45 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/view-service-panel.ts @@ -0,0 +1,119 @@ +import { resolveDbNodeStyle } from "@maple/ui/components/service-map/service-map-db-style" +import { + aggregateServiceOverviews, + dbNodeId, + getServiceMapNodeColor, +} from "@maple/ui/components/service-map/service-map-utils" +import { getServiceColor } from "@maple/ui/lib/colors" +import { errorRateClass } from "@maple/ui/lib/error-rate" +import { formatErrorRate, formatLatency, formatRate } from "@maple/ui/lib/format" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" +import type * as ServiceMap from "@maple/web-foldkit/service-map/map/service-map" +import { button, linkButton } from "@maple/web-foldkit/ui/button" +import { icon } from "@maple/web-foldkit/ui/icon" +import { scrollArea } from "@maple/web-foldkit/ui/scroll-area" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { LocalServiceMapData } from "./data" +import { metricTile, panelHeader, peerList, type PeerRow, rates, sectionTitle, serviceHref, serviceTracesHref } from "./view-panel-kit" + +// `ServiceMapServicePanel` (apps/local-ui/src/components/service-map-panel.tsx). + +export type PanelActions = Readonly<{ onFocus: M; onClose: M }> + +const peer = (name: string, range: string): Pick => ({ + key: name, + label: name, + color: getServiceColor(name), + href: serviceHref(name, range), +}) + +export const servicePanel = ( + h: HtmlBuilder, + options: Readonly<{ + serviceName: string + data: LocalServiceMapData + colorMode: ServiceMap.ColorMode + durationSeconds: number + range: string + actions: PanelActions + }>, +): Html => { + const { serviceName, data, durationSeconds, range } = options + // Several environments can report one service: merged exactly as the map node does. + const overview = aggregateServiceOverviews(data.overviews).get(serviceName) + const errorRate = overview?.errorRate ?? 0 + const p50 = overview?.p50LatencyMs ?? 0 + const p95 = overview?.p95LatencyMs ?? 0 + const accentColor = getServiceMapNodeColor( + { label: serviceName, kind: "service", errorRate, platform: data.platforms.get(serviceName) }, + options.colorMode, + ) + const calls = data.edges + .filter((e) => e.sourceService === serviceName) + .map((e): PeerRow => ({ ...peer(e.targetService, range), ...rates(e, durationSeconds) })) + const databases = data.dbEdges + .filter((e) => e.sourceService === serviceName) + .map((e): PeerRow => { + const db = resolveDbNodeStyle(e.dbSystem, e.dbNamespace) + return { key: dbNodeId(e.dbSystem, e.dbNamespace), label: db.title, color: db.color, ...rates(e, durationSeconds) } + }) + const calledBy = data.edges + .filter((e) => e.targetService === serviceName) + .map((e): PeerRow => ({ ...peer(e.sourceService, range), ...rates(e, durationSeconds) })) + return h.div( + [h.Class("flex h-full flex-col overflow-hidden bg-background")], + [ + panelHeader( + h, + { accentColor, errorRate, title: serviceName, subtitle: overview?.serviceNamespace ? [overview.serviceNamespace] : undefined }, + [ + button( + h, + { + variant: "ghost", + size: "icon-xs", + attributes: [h.OnClick(options.actions.onFocus), h.Title("Focus the map on this service's neighborhood")], + }, + [icon(h, "MagnifierIcon")], + ), + button(h, { variant: "ghost", size: "icon-xs", attributes: [h.OnClick(options.actions.onClose), h.AriaLabel("Close panel")] }, [ + icon(h, "XmarkIcon"), + ]), + ], + ), + scrollArea(h, { className: "min-h-0 flex-1", idPrefix: "service-map-panel" }, [ + h.div( + [h.Class("space-y-5 p-4")], + [ + h.div( + [h.Class("flex flex-wrap gap-2")], + [ + linkButton(h, { variant: "outline", size: "sm", href: serviceHref(serviceName, range) }, ["View service"]), + linkButton(h, { variant: "outline", size: "sm", href: serviceTracesHref(serviceName, range) }, ["View traces"]), + ], + ), + h.div( + [h.Class("space-y-3")], + [ + sectionTitle(h, "Metrics"), + h.div( + [h.Class("grid grid-cols-2 gap-x-6 gap-y-4")], + [ + metricTile(h, { label: "Throughput", value: `${overview?.hasSampling ? "~" : ""}${formatRate(overview?.throughput ?? 0)}`, unit: "req/s" }), + metricTile(h, { label: "Error rate", value: formatErrorRate(errorRate), className: errorRateClass(errorRate, { neutral: "text-foreground" }) }), + metricTile(h, { label: "P50 latency", value: formatLatency(p50), className: latencyToneClass(p50, "p50") }), + // A p95 far above the service's own p50 is a tail problem worth flagging. + metricTile(h, { label: "P95 latency", value: formatLatency(p95), className: p95 > p50 * 3 ? "text-severity-warn" : latencyToneClass(p95, "p95") }), + ], + ), + ], + ), + ...peerList(h, "Calls", calls), + ...peerList(h, "Databases", databases), + ...peerList(h, "Called by", calledBy), + ], + ), + ]), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/service-map/view.ts b/apps/local-ui-foldkit/src/page/service-map/view.ts new file mode 100644 index 0000000000..9c08e69d6b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/service-map/view.ts @@ -0,0 +1,62 @@ +import { cn } from "@maple/ui/lib/utils" +import { serviceMapLoading } from "@maple/web-foldkit/service-map/map/loading" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { refreshButton, timeRangeSelect, toolbar, toolbarStat, toolbarStats } from "../kit/toolbar" +import { errorState } from "../kit/view-states" +import { Message } from "./message" +import type { Model } from "./model" +import { mapCanvas } from "./view-map" +import { noEdgesNotice } from "./view-parts" + +// `ServiceMapView` (apps/local-ui/src/views/service-map-view.tsx). + +const content = (h: HtmlBuilder, model: Model, inputs: PageViewInputs): Html | null => { + if (isPending(model.data)) return serviceMapLoading(h) + const failure = failureWithoutData(model.data) + if (Option.isSome(failure)) + return errorState(h, { label: "the service map", failure: failure.value, onRetry: Message.ClickedRetry() }) + return Option.match(AsyncData.getData(model.data.data), { + onNone: () => null, + onSome: (data) => mapCanvas(h, model, data, Option.getOrUndefined(model.maybeMap), inputs), + }) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const maybeData = AsyncData.getData(model.data.data) + const source = Option.map(model.maybeMap, (map) => map.source) + const serviceCount = Option.match(source, { onNone: () => 0, onSome: (graph) => graph.services.length }) + const nodeCount = Option.match(source, { onNone: () => 0, onSome: (graph) => graph.rawNodes.length }) + const edgeCount = Option.match(maybeData, { onNone: () => 0, onSome: (data) => data.edges.length }) + const isEdgeless = Option.exists(maybeData, (data) => data.overviews.length > 0 && data.edges.length === 0) + const body = content(h, model, inputs) + return h.div( + [h.Class("flex h-full flex-col")], + [ + toolbar(h, [ + toolbarStats(h, [ + toolbarStat(h, { value: serviceCount, label: "services" }), + toolbarStat(h, { value: edgeCount, label: "connections" }), + toolbarStat(h, { value: nodeCount - serviceCount, label: "databases" }), + ]), + toolbarStats(h, [ + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.dataUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + }), + timeRangeSelect(h, rangeOf(inputs.context), (range) => Message.ChangedRange({ range })), + ]), + ]), + ...(isEdgeless ? [noEdgesNotice(h)] : []), + h.div( + [h.Class(cn("min-h-0 flex-1", model.data.isPlaceholder && "opacity-60 transition-opacity"))], + body === null ? [] : [body], + ), + ], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/services/command.ts b/apps/local-ui-foldkit/src/page/services/command.ts new file mode 100644 index 0000000000..a6a35ff682 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/command.ts @@ -0,0 +1,48 @@ +import { Clock, Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { DEBOUNCE } from "../traces/command" +import { cachedAs, readCached, recordCached } from "../../data/cached" +import { fetchCatalog } from "./fetch" +import { Catalog } from "./model" +import { Message } from "./message" + +export const ReadClock = Command.define("ReadServicesClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadServicesRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + +/** One `useCatalog`: an env/ns slice (absent = unfiltered) over the window. */ +export const FetchCatalog = Command.define("FetchLocalServiceCatalog", { + args: { + key: Schema.String, + env: Schema.NullOr(Schema.String), + ns: Schema.NullOr(Schema.String), + startTime: Schema.String, + endTime: Schema.String, + apiBase: Schema.String, + readsCache: Schema.Boolean, + }, + messages: [Message.SucceededFetchCatalog, Message.FailedFetchCatalog, Message.FoundCachedCatalog], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, env, ns, startTime, endTime, apiBase, readsCache }) => + Effect.gen(function* () { + const cacheKey = ["local", "services", "catalog", env, ns, `${startTime}/${endTime}`] + const maybeCached = readsCache ? yield* readCached(cacheKey, cachedAs(Catalog)) : Option.none() + if (Option.isSome(maybeCached)) return Message.FoundCachedCatalog({ key, cached: maybeCached.value }) + const catalog = yield* fetchCatalog(env ?? undefined, ns ?? undefined, { startTime, endTime }, apiBase) + const fetchedAtMs = yield* Clock.currentTimeMillis + yield* recordCached(cacheKey, catalog, fetchedAtMs) + return Message.SucceededFetchCatalog({ key, catalog, fetchedAtMs }) + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchCatalog({ key, failure })))), +}) + +export const WaitSearchDebounce = Command.define("WaitServicesSearchDebounce", { + args: { version: Schema.Number }, + messages: [Message.ElapsedSearchDebounce], + execute: ({ version }) => Effect.as(Effect.sleep(DEBOUNCE), Message.ElapsedSearchDebounce({ version })), +}) diff --git a/apps/local-ui-foldkit/src/page/services/derive.ts b/apps/local-ui-foldkit/src/page/services/derive.ts new file mode 100644 index 0000000000..5c4337629b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/derive.ts @@ -0,0 +1,55 @@ +import type { FilterOption } from "@maple/web-foldkit/ui/filters/filter-section" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Catalog, Model, ServiceCatalogEntry } from "./model" + +// The derived parts of `useLocalServiceCatalog` and `ServicesListView`. + +export interface ServicesFilters { + readonly env: string | undefined + readonly ns: string | undefined + readonly search: string | undefined +} + +export const filtersOf = (query: URLSearchParams): ServicesFilters => ({ + env: query.get("env") || undefined, + ns: query.get("ns") || undefined, + search: query.get("q") || undefined, +}) + +export const activeFilterCount = (filters: ServicesFilters): number => + [filters.env, filters.ns].filter(Boolean).length + +const dataOf = (slot: Model["catalog"]): Catalog => Option.getOrElse(AsyncData.getData(slot.data), () => []) + +/** The rows: the filtered catalog, narrowed client-side by the name search. */ +export const visibleEntries = (model: Model, filters: ServicesFilters): ReadonlyArray => { + const all = dataOf(model.catalog) + const needle = filters.search?.toLowerCase() + return needle ? all.filter((entry) => entry.serviceName.toLowerCase().includes(needle)) : all +} + +/** With no filter the filtered and unfiltered queries are one key, so the rows slot answers both. */ +export const unfilteredSlot = (model: Model, filters: ServicesFilters): Model["catalog"] => + activeFilterCount(filters) === 0 ? model.catalog : model.unfiltered + +const countBy = ( + entries: ReadonlyArray, + pick: (entry: ServiceCatalogEntry) => ReadonlyArray, +): ReadonlyArray => { + const counts = new Map() + entries.forEach((entry) => pick(entry).forEach((name) => counts.set(name, (counts.get(name) ?? 0) + 1))) + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) +} + +/** Facets from the unfiltered catalog, each counted under the other facet only. */ +export const facetsOf = (model: Model, filters: ServicesFilters) => { + const all = dataOf(unfilteredSlot(model, filters)) + const { env, ns } = filters + return { + envFacets: countBy(ns ? all.filter((e) => e.serviceNamespaces.includes(ns)) : all, (e) => e.deploymentEnvironments), + nsFacets: countBy(env ? all.filter((e) => e.deploymentEnvironments.includes(env)) : all, (e) => e.serviceNamespaces), + } +} diff --git a/apps/local-ui-foldkit/src/page/services/fetch.ts b/apps/local-ui-foldkit/src/page/services/fetch.ts new file mode 100644 index 0000000000..54a8e9def3 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/fetch.ts @@ -0,0 +1,51 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { Effect } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import type { ServiceCatalogEntry } from "./model" + +// `useCatalog` and `toCatalogEntry` (apps/local-ui/src/hooks/use-local-service-catalog.ts). + +/** Sampled counts win when the store estimated them. */ +export const toCatalogEntry = (row: CH.ServiceCatalogOutput, logCount: number): ServiceCatalogEntry => { + const spanCount = row.estimatedSpanCount || row.spanCount + const errorCount = row.estimatedErrorCount || row.errorCount + return { + serviceName: row.serviceName, + serviceNamespaces: row.serviceNamespaces, + deploymentEnvironments: row.deploymentEnvironments, + spanCount, + errorCount, + errorRate: spanCount > 0 ? errorCount / spanCount : 0, + p50LatencyMs: row.p50LatencyMs, + p95LatencyMs: row.p95LatencyMs, + p99LatencyMs: row.p99LatencyMs, + logCount, + } +} + +/** The catalog for one env/ns slice, with log volume from the usage rollup. */ +export const fetchCatalog = ( + env: string | undefined, + ns: string | undefined, + bounds: TimeBounds, + apiBase: string, +) => + Effect.gen(function* () { + const params = localParams(bounds) + const [catalogRows, usageRows] = yield* Effect.all( + [ + queryRows( + CH.compile( + CH.serviceCatalogQuery({ deploymentEnvironment: env, serviceNamespace: ns, limit: 200 }), + params, + ), + apiBase, + ), + queryRows(CH.compile(CH.serviceUsageQuery({}), params), apiBase), + ], + { concurrency: "unbounded" }, + ) + const logsByService = new Map(usageRows.map((row) => [row.serviceName, row.totalLogCount])) + return catalogRows.map((row) => toCatalogEntry(row, logsByService.get(row.serviceName) ?? 0)) + }) diff --git a/apps/local-ui-foldkit/src/page/services/index.ts b/apps/local-ui-foldkit/src/page/services/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/services/lanes.ts b/apps/local-ui-foldkit/src/page/services/lanes.ts new file mode 100644 index 0000000000..1ca078bb50 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/lanes.ts @@ -0,0 +1,9 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchCatalog } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchCatalog.name, model.catalog, (key) => FetchCatalog.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchCatalog.name, model.unfiltered, (key) => FetchCatalog.Interrupt({ key }, outcomeOf)), +] diff --git a/apps/local-ui-foldkit/src/page/services/load.ts b/apps/local-ui-foldkit/src/page/services/load.ts new file mode 100644 index 0000000000..e76d9d923b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/load.ts @@ -0,0 +1,62 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, type TimeBounds } from "@maple/local-ui/lib/time" +import { Array, Option } from "effect" +import { AsyncData, type Command } from "foldkit" +import { keyOf, request, type Slot } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, rangeOf } from "../contract" +import { FetchCatalog } from "./command" +import { activeFilterCount, filtersOf } from "./derive" +import type { Message } from "./message" +import type { Catalog, Model } from "./model" + +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +/** `["local", "services", "catalog", env, ns, boundsKey(bounds)]`. */ +const catalogKey = (env: string | undefined, ns: string | undefined, bounds: TimeBounds): string => + keyOf("services-catalog", { env: env ?? null, ns: ns ?? null, bounds: `${bounds.startTime}/${bounds.endTime}` }) + +type Step = Readonly<{ slot: Slot; fetch: boolean; readsCache: boolean }> + +/** + * One slot onto `key`: unchanged when it holds it, served from the React Query cache when the + * other slot already has that key's rows, else a fetch (or a forced refetch). + */ +const stepSlot = (slot: Slot, key: string, cache: ReadonlyArray>, force: boolean): Step => { + const cached = cache.find((other) => other.key === key && Option.isSome(AsyncData.getData(other.data))) + if (!force && slot.key !== key && cached !== undefined) return { slot: cached, fetch: false, readsCache: false } + return Option.match(request(slot, key, force), { + onNone: () => ({ slot, fetch: false, readsCache: false }), + onSome: (next) => ({ slot: next, fetch: true, readsCache: slot.key !== key }), + }) +} + +export const load = ( + model: Model, + context: PageContext, + force = false, +): Readonly<{ model: Model; commands?: ReadonlyArray> }> => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const filters = filtersOf(queryOf(context.location)) + const args = { ...bounds, apiBase: context.env.apiBase } + const cache = [model.catalog, model.unfiltered] + const filteredKey = catalogKey(filters.env, filters.ns, bounds) + const rows = stepSlot(model.catalog, filteredKey, cache, force) + // With no filter both hooks read one key: the rows slot serves the facets. + const isFiltered = activeFilterCount(filters) > 0 + const unfilteredKey = catalogKey(undefined, undefined, bounds) + const facets = isFiltered ? stepSlot(model.unfiltered, unfilteredKey, cache, force) : { slot: model.unfiltered, fetch: false, readsCache: false } + return { + model: { ...model, catalog: rows.slot, unfiltered: facets.slot }, + commands: Array.getSomes([ + rows.fetch + ? Option.some(FetchCatalog({ key: filteredKey, env: filters.env ?? null, ns: filters.ns ?? null, ...args, readsCache: rows.readsCache })) + : Option.none(), + facets.fetch ? Option.some(FetchCatalog({ key: unfilteredKey, env: null, ns: null, ...args, readsCache: facets.readsCache })) : Option.none(), + ]), + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/services/message.ts b/apps/local-ui-foldkit/src/page/services/message.ts new file mode 100644 index 0000000000..697617af66 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/message.ts @@ -0,0 +1,33 @@ +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { Catalog } from "./model" + +/** The two single-select facets: their URL params. */ +export const FacetParam = Schema.Literals(["env", "ns"]) +export type FacetParam = typeof FacetParam.Type + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchCatalog: { key: Schema.String, catalog: Catalog, fetchedAtMs: Schema.Number }, + FailedFetchCatalog: { key: Schema.String, failure: LoadFailure }, + /** The result cache held this slice (or an older window of it) before the fetch ran. */ + FoundCachedCatalog: { key: Schema.String, cached: CachedOf(Catalog) }, + ClickedRefresh: {}, + ClickedOpenFilters: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ChangedSearch: { text: Schema.String }, + ClickedClearSearch: {}, + ElapsedSearchDebounce: { version: Schema.Number }, + ToggledFacetOption: { param: FacetParam, name: Schema.String }, + ClickedClearFacets: {}, + ClickedClearFilters: {}, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + GotFilterSectionsMessage: { message: FilterSections.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/services/model.ts b/apps/local-ui-foldkit/src/page/services/model.ts new file mode 100644 index 0000000000..8117641540 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/model.ts @@ -0,0 +1,42 @@ +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" +import { Draft } from "../traces/model" + +// apps/local-ui/src/hooks/use-local-service-catalog.ts, as Schemas. + +export const ServiceCatalogEntry = Schema.Struct({ + serviceName: Schema.String, + serviceNamespaces: Schema.Array(Schema.String), + deploymentEnvironments: Schema.Array(Schema.String), + spanCount: Schema.Number, + errorCount: Schema.Number, + errorRate: Schema.Number, + p50LatencyMs: Schema.Number, + p95LatencyMs: Schema.Number, + p99LatencyMs: Schema.Number, + /** Log volume from the usage rollup (0 when the service has none). */ + logCount: Schema.Number, +}) +export type ServiceCatalogEntry = typeof ServiceCatalogEntry.Type + +export const Catalog = Schema.Array(ServiceCatalogEntry) +export type Catalog = typeof Catalog.Type + +export const Model = Schema.TaggedStruct("ServicesPage", { + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + nowMs: Schema.Number, + /** The env/ns-filtered catalog (the rows). */ + catalog: SlotSchema(Catalog), + /** The unfiltered catalog (the facets); unused while no filter is set, as both keys are one. */ + unfiltered: SlotSchema(Catalog), + /** `dataUpdatedAt` of the rows, for the "New data" pill. */ + catalogUpdatedAtMs: Schema.Number, + search: Draft, + sections: FilterSections.Model, + isRefreshing: Schema.Boolean, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/services/services.scene.test.ts b/apps/local-ui-foldkit/src/page/services/services.scene.test.ts new file mode 100644 index 0000000000..0869685ce9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/services.scene.test.ts @@ -0,0 +1,89 @@ +import { Option } from "effect" +import { click, expect as expectView, expectOutMessage, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { FetchCatalog } from "./command" +import { Message } from "./message" +import type { Model, ServiceCatalogEntry } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const NOW = Date.parse("2026-10-01T12:00:30Z") + +const contextAt = (search: string): PageContext => ({ + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + location: { path: "/services", search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +}) +const context = contextAt("") +const inputs: PageViewInputs = { + context, + presenceOf: () => Option.some({ signal: "traces", status: "absent", lastSeenMs: null }), + presenceNowMs: NOW, + clockMs: 0, + hasNewDataSince: () => false, +} +const view = withViewInputs(pageView, inputs)() +const pageUpdate = (model: Model, message: Message) => update(model, message, context) + +const entry = (serviceName: string): ServiceCatalogEntry => ({ + serviceName, + serviceNamespaces: ["shop"], + deploymentEnvironments: ["production"], + spanCount: 100, + errorCount: 2, + errorRate: 0.02, + p50LatencyMs: 10, + p95LatencyMs: 20, + p99LatencyMs: 30, + logCount: 5, +}) + +/** Entered and anchored: the catalog request in flight. */ +const anchored = () => pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + +const loaded = (catalog: ReadonlyArray): Model => { + const base = anchored().model + return pageUpdate(base, Message.SucceededFetchCatalog({ key: base.catalog.key ?? "", catalog, fetchedAtMs: NOW })).model +} + +describe("local services list", () => { + test("entry reads the clock, then asks one catalog: unfiltered rows and facets are one key", () => { + expect(init(context).commands.map(({ name }) => name)).toContain("ReadServicesClock") + const { commands } = anchored() + expect((commands ?? []).map(({ name }) => name)).toEqual([FetchCatalog.name]) + }) + + test("a filter adds the unfiltered catalog for the facets", () => { + const filtered = contextAt("env=production") + const result = update(init(filtered).model, Message.CompletedReadClock({ nowMs: NOW }), filtered) + expect((result.commands ?? []).map(({ name }) => name)).toEqual([FetchCatalog.name, FetchCatalog.name]) + expect(result.model.catalog.key).not.toBe(result.model.unfiltered.key) + }) + + test("an answer for a superseded key is dropped", () => { + const base = anchored().model + const stale = pageUpdate(base, Message.SucceededFetchCatalog({ key: "stale", catalog: [entry("x")], fetchedAtMs: NOW })) + expect(stale.model.catalog).toEqual(base.catalog) + }) + + test("rows render and a facet click filters the hash", () => { + scene( + { update: pageUpdate, view }, + given(loaded([entry("checkout-api"), entry("cart-svc")])), + expectView(text("checkout-api")).toExist(), + click(role("checkbox", { name: "production" })), + expectOutMessage(PageOutMessage.RequestedNavigation({ hash: "#/services?env=production", isReplace: true })), + ) + }) + + test("an empty catalog asks for signal presence and renders the on-ramp", () => { + const base = anchored().model + const result = pageUpdate(base, Message.SucceededFetchCatalog({ key: base.catalog.key ?? "", catalog: [], fetchedAtMs: NOW })) + expect(result.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + scene({ update: pageUpdate, view }, given(result.model), expectView(text("No services yet")).toExist()) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/services/update.ts b/apps/local-ui-foldkit/src/page/services/update.ts new file mode 100644 index 0000000000..4e5f842be8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/update.ts @@ -0,0 +1,163 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Option, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import type { Cached } from "../../data/cached" +import { accept, acceptCached, idle } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage, replaceParams, withParams } from "../contract" +import { updateCopy } from "../kit/copies" +import { FetchCatalog, ReadClock, ReadRefreshClock, WaitSearchDebounce } from "./command" +import { filtersOf, visibleEntries } from "./derive" +import { boundsOf, load } from "./load" +import { Message } from "./message" +import type { Catalog, Model } from "./model" +import type { LoadFailure } from "../../data/local-query" + +export type UpdateReturn = Update.ReturnWithOutMessage + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +export const init = ( + context: PageContext, +): Readonly<{ model: Model; commands: ReadonlyArray> }> => { + const q = queryOf(context.location).get("q") ?? "" + return { + model: { + _tag: "ServicesPage", + maybeAnchorMs: Option.none(), + nowMs: 0, + catalog: idle(), + unfiltered: idle(), + catalogUpdatedAtMs: 0, + search: { text: q, version: 0, sent: q }, + sections: FilterSections.init(), + isRefreshing: false, + copies: {}, + }, + commands: [ReadClock(), Command.mapMessage(FilterSections.ReadRemembered(), toSections)], + } +} + +/** An empty list renders `SignalEmptyState`, which reads the shared signal presence. */ +const withPresence = (step: UpdateReturn, context: PageContext): UpdateReturn => { + const isLoaded = Option.isSome(AsyncData.getData(step.model.catalog.data)) && !step.model.catalog.isPlaceholder + const isEmpty = visibleEntries(step.model, filtersOf(queryOf(context.location))).length === 0 + return isLoaded && isEmpty && step.outMessage === undefined + ? { ...step, outMessage: PageOutMessage.RequestedSignalPresence() } + : step +} + +/** The hash changed: sync the search input unless it is the page's own debounced echo. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => { + const q = queryOf(context.location).get("q") ?? "" + const search = q === model.search.sent ? model.search : { ...model.search, text: q, sent: q } + return withPresence(load({ ...model, search }, context), context) +} + +const navigate = (model: Model, context: PageContext, updates: Record): UpdateReturn => ({ + model, + outMessage: replaceParams(context.location, updates), +}) + +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +/** The refresh spinner stops once nothing the refresh started is still loading. */ +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + return model.isRefreshing && !isLoading(model.catalog.data) && !isLoading(model.unfiltered.data) + ? { ...model, isRefreshing: false } + : model +} + +/** One fetch answers whichever slots hold its key (both, while no filter is set). */ +const settled = (model: Model, key: string, result: Result.Result, atMs: number | null = null): Model => ({ + ...model, + catalog: accept(model.catalog, key, result, atMs), + unfiltered: accept(model.unfiltered, key, result, atMs), +}) + +/** The cache answered a slice first: whichever slots hold its key show it, then the fetch runs. */ +const settledFromCache = (model: Model, context: PageContext, key: string, cached: Cached): UpdateReturn => { + const next = { + ...model, + catalog: acceptCached(model.catalog, key, cached, cached.isFresh), + unfiltered: acceptCached(model.unfiltered, key, cached, cached.isFresh), + catalogUpdatedAtMs: model.catalog.key === key ? cached.updatedAtMs : model.catalogUpdatedAtMs, + } + const holder = next.catalog.key === key ? "catalog" : next.unfiltered.key === key ? "unfiltered" : null + if (holder === null || cached.isFresh) return { model: settleRefresh(next) } + const bounds = Option.getOrNull(boundsOf(next, context)) + if (bounds === null) return { model: next } + const filters = filtersOf(queryOf(context.location)) + const isRows = holder === "catalog" + return { + model: next, + commands: [ + FetchCatalog({ + key, + env: isRows ? (filters.env ?? null) : null, + ns: isRows ? (filters.ns ?? null) : null, + ...bounds, + apiBase: context.env.apiBase, + readsCache: false, + }), + ], + } +} + +const searchStep = (model: Model, text: string): UpdateReturn => { + const version = model.search.version + 1 + return { model: { ...model, search: { ...model.search, text, version } }, commands: [WaitSearchDebounce({ version })] } +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => { + const query = queryOf(context.location) + return Message.match(message, { + CompletedReadClock: ({ nowMs }) => + load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + SucceededFetchCatalog: ({ key, catalog, fetchedAtMs }) => { + const catalogUpdatedAtMs = model.catalog.key === key ? fetchedAtMs : model.catalogUpdatedAtMs + const next = settleRefresh({ ...settled(model, key, Result.succeed(catalog), fetchedAtMs), catalogUpdatedAtMs }) + return model.catalog.key === key ? withPresence({ model: next }, context) : { model: next } + }, + FoundCachedCatalog: ({ key, cached }) => settledFromCache(model, context, key, cached), + FailedFetchCatalog: ({ key, failure }) => ({ model: settleRefresh(settled(model, key, Result.fail(failure))) }), + ClickedOpenFilters: () => ({ model, outMessage: PageOutMessage.RequestedFilterDrawer() }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ChangedSearch: ({ text }) => searchStep(model, text), + ClickedClearSearch: () => searchStep(model, ""), + ElapsedSearchDebounce: ({ version }) => { + if (version !== model.search.version) return { model } + const trimmed = model.search.text.trim() + return navigate({ ...model, search: { ...model.search, sent: trimmed } }, context, { q: trimmed === "" ? null : trimmed }) + }, + ToggledFacetOption: ({ param, name }) => navigate(model, context, { [param]: query.get(param) === name ? null : name }), + ClickedClearFacets: () => navigate(model, context, { env: null, ns: null }), + ClickedClearFilters: () => navigate(model, context, { env: null, ns: null, q: null }), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + GotFilterSectionsMessage: ({ message: child }) => { + const next = FilterSections.update(model.sections, child) + return { model: { ...model, sections: next.model }, commands: Command.mapMessages(next.commands ?? [], toSections) } + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) +} diff --git a/apps/local-ui-foldkit/src/page/services/view-table.ts b/apps/local-ui-foldkit/src/page/services/view-table.ts new file mode 100644 index 0000000000..bdec6958d7 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/view-table.ts @@ -0,0 +1,70 @@ +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/web-foldkit/ui/table" +import { tableSkeleton } from "@maple/web-foldkit/ui/table-skeleton" +import { errorRateValue, latencyValue, serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../route" +import { linkRow, rowLink } from "../kit/row-link" +import type { Message } from "./message" +import type { ServiceCatalogEntry } from "./model" + +// The table of `ServicesListView` and its `ServiceRow`. + +const NUMBER_COLUMNS = ["Spans", "Errors", "Error rate", "p50", "p95", "Logs"] + +export const servicesSkeleton = (h: HtmlBuilder): Html => + h.div( + [h.Class("p-4")], + [ + tableSkeleton(h, { + rows: 8, + columns: [ + { header: "Service", skeleton: "w-40" }, + ...NUMBER_COLUMNS.map((header) => ({ header, headClassName: "text-right", skeleton: "ml-auto w-12" })), + ], + }), + ], + ) + +const serviceRow = (h: HtmlBuilder, entry: ServiceCatalogEntry, query: URLSearchParams): Html => + linkRow(h, undefined, [ + tableCell(h, [ + rowLink(h, { href: hrefFor(`/services/${encodeURIComponent(entry.serviceName)}`, query), className: "flex items-center gap-2" }, [ + serviceDot(h, { serviceName: entry.serviceName }), + h.span([h.Class("font-medium")], [entry.serviceName]), + ...(entry.serviceNamespaces.length > 0 + ? [h.span([h.Class("truncate text-xs text-muted-foreground")], [entry.serviceNamespaces.join(", ")])] + : []), + ]), + ]), + tableCell(h, [formatNumber(entry.spanCount)], "text-right tabular-nums"), + tableCell(h, [formatNumber(entry.errorCount)], cn("text-right tabular-nums", entry.errorCount > 0 && "text-severity-error")), + tableCell(h, [errorRateValue(h, { rate: entry.errorRate })], "text-right"), + tableCell(h, [latencyValue(h, { ms: entry.p50LatencyMs, scale: "p50" })], "text-right"), + tableCell(h, [latencyValue(h, { ms: entry.p95LatencyMs, scale: "p95" })], "text-right"), + tableCell(h, [formatNumber(entry.logCount)], "text-right tabular-nums"), + ]) + +export const servicesTable = ( + h: HtmlBuilder, + entries: ReadonlyArray, + query: URLSearchParams, + isPlaceholder: boolean, +): Html => + h.div( + [h.Class(cn("p-4", isPlaceholder && "opacity-60 transition-opacity"))], + [ + h.div( + [h.Class("rounded-md border")], + [ + table(h, {}, [ + tableHeader(h, [ + tableRow(h, [tableHead(h, ["Service"]), ...NUMBER_COLUMNS.map((header) => tableHead(h, [header], "text-right"))]), + ]), + tableBody(h, entries.map((entry) => serviceRow(h, entry, query))), + ]), + ], + ), + ], + ) diff --git a/apps/local-ui-foldkit/src/page/services/view.ts b/apps/local-ui-foldkit/src/page/services/view.ts new file mode 100644 index 0000000000..10678a3281 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/services/view.ts @@ -0,0 +1,118 @@ +import type * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { filterSection } from "@maple/web-foldkit/ui/filters/filter-section" +import type * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { filterSidebarBody, filterSidebarFrame, filterSidebarHeader } from "@maple/web-foldkit/ui/filters/filter-sidebar" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { queryOf } from "../../route" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { pageShell } from "../kit/page-shell" +import { signalEmptyState } from "../kit/signal-empty-state" +import { refreshButton, timeRangeSelect, toolbar, toolbarSearch, toolbarStat, toolbarStats } from "../kit/toolbar" +import { errorState } from "../kit/view-states" +import { activeFilterCount, facetsOf, filtersOf, type ServicesFilters, unfilteredSlot, visibleEntries } from "./derive" +import { Message } from "./message" +import type { Model } from "./model" +import { servicesSkeleton, servicesTable } from "./view-table" + +// `ServicesListView` (apps/local-ui/src/views/services-list-view.tsx). + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) +const toCopy = (id: string, message: CopyButton.Message) => Message.GotCopyMessage({ id, message }) + +const isFetching = (slot: Model["catalog"]) => AsyncData.isLoading(slot.data) || AsyncData.isRefreshing(slot.data) + +const sidebar = (h: HtmlBuilder, model: Model, filters: ServicesFilters): Html => { + const { envFacets, nsFacets } = facetsOf(model, filters) + const facet = (title: string, param: "env" | "ns", options: typeof envFacets, selected: string | undefined) => + filterSection(h, { + title, + options, + selected: selected ? [selected] : [], + isSearchable: true, + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param, name }), + }) + return filterSidebarFrame( + h, + { className: "w-56 shrink-0 px-4", isWaiting: isFetching(model.catalog) || isFetching(unfilteredSlot(model, filters)) }, + [ + filterSidebarHeader(h, activeFilterCount(filters) > 0 ? { onClear: Message.ClickedClearFacets() } : {}), + ...filterSidebarBody( + h, + "services-filters", + [facet("Environment", "env", envFacets, filters.env), facet("Namespace", "ns", nsFacets, filters.ns)].filter( + (section) => section !== null, + ), + ), + ], + ) +} + +const servicesToolbar = (h: HtmlBuilder, model: Model, inputs: PageViewInputs, count: number, errors: number) => + toolbar(h, [ + toolbarSearch(h, { + id: "services-search", + value: model.search.text, + placeholder: "Filter by service name…", + onInput: (text) => Message.ChangedSearch({ text }), + onClear: Message.ClickedClearSearch(), + className: "min-w-48 flex-1", + }), + toolbarStats( + h, + [ + toolbarStat(h, { value: count, label: "services" }), + toolbarStat(h, { value: Math.round(errors), label: "errors", isDanger: true }), + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.catalogUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + }), + timeRangeSelect(h, rangeOf(inputs.context), (range) => Message.ChangedRange({ range })), + ], + "shrink-0", + ), + ]) + +export const view = Submodel.defineView((model, inputs, h) => { + const { context } = inputs + const query = queryOf(context.location) + const filters = filtersOf(query) + const count = activeFilterCount(filters) + const entries = visibleEntries(model, filters) + const totalErrorCount = entries.reduce((sum, entry) => sum + entry.errorCount, 0) + const content = (): Html => { + if (isPending(model.catalog)) return servicesSkeleton(h) + const failure = failureWithoutData(model.catalog) + if (Option.isSome(failure)) + return errorState(h, { label: "services", failure: failure.value, onRetry: Message.ClickedRefresh() }) + if (entries.length === 0) + return signalEmptyState(h, { + signal: "traces", + noun: "services", + isFiltered: count > 0 || filters.search !== undefined, + onClearFilters: Message.ClickedClearFilters(), + range: rangeOf(context), + onWidenRange: Message.ClickedWidenRange(), + maybePresence: inputs.presenceOf("traces"), + nowMs: inputs.presenceNowMs, + guide: { id: "services-empty", endpoint: context.env.otlpEndpoint, copies: model.copies, toCopyMessage: toCopy }, + }) + return servicesTable(h, entries, query, model.catalog.isPlaceholder) + } + return pageShell( + h, + { + onOpenFilters: Message.ClickedOpenFilters(), + sidebar: sidebar(h, model, filters), + toolbar: servicesToolbar(h, model, inputs, entries.length, totalErrorCount), + activeFilterCount: count, + }, + [content()], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/session-detail/command.ts b/apps/local-ui-foldkit/src/page/session-detail/command.ts new file mode 100644 index 0000000000..12df0f03a9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/command.ts @@ -0,0 +1,122 @@ +import { cachedAs, readCached, recordCached, withCache } from "../../data/cached" +import { CH } from "@maple/query-engine" +import { LOCAL_ORG_ID } from "@maple/query-engine/local" +import { Clock, Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { queryFirstRow, queryRows } from "../../data/local-query" +import { Message } from "./message" +import { SessionDetail, SessionTrace, TranscriptEvent } from "./model" + +// apps/local-ui/src/hooks/use-local-session-detail.ts as Commands. + +export const ReadClock = Command.define("ReadSessionDetailClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +/** Finalized metadata for one session (latest ReplacingMergeTree version), or null. */ +export const FetchDetail = Command.define("FetchLocalSessionDetail", { + args: { key: Schema.String, sessionId: Schema.String, apiBase: Schema.String, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchDetail, Message.FailedFetchDetail, Message.FoundCachedDetail], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, sessionId, apiBase, readsCache }) => + Effect.gen(function* () { + const cacheKey = ["local", "session", sessionId] + const maybeCached = readsCache ? yield* readCached(cacheKey, cachedAs(Schema.NullOr(SessionDetail))) : Option.none() + if (Option.isSome(maybeCached)) return Message.FoundCachedDetail({ key, cached: maybeCached.value }) + const row = yield* queryFirstRow(CH.compile(CH.getSessionReplayQuery(), { orgId: LOCAL_ORG_ID, sessionId }), apiBase) + const fetchedAtMs = yield* Clock.currentTimeMillis + const detail = Option.match(row, { + onNone: () => null, + onSome: (session): SessionDetail => ({ + sessionId: session.sessionId, + startTime: session.startTime, + durationMs: session.durationMs, + status: session.status, + lastActivityAt: session.lastActivityAt, + userId: session.userId, + userName: session.userName, + userEmail: session.userEmail, + urlInitial: session.urlInitial, + userAgent: session.userAgent, + browserName: session.browserName, + osName: session.osName, + deviceType: session.deviceType, + serviceName: session.serviceName, + pageViews: session.pageViews, + clickCount: session.clickCount, + errorCount: session.errorCount, + traceIds: session.traceIds, + }), + }) + yield* recordCached(cacheKey, detail, fetchedAtMs) + return Message.SucceededFetchDetail({ key, detail, fetchedAtMs }) + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchDetail({ key, failure })))), +}) + +/** Distilled event transcript (navigation / click / console / network / error). */ +export const FetchTranscript = Command.define("FetchLocalSessionTranscript", { + args: { key: Schema.String, sessionId: Schema.String, apiBase: Schema.String, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTranscript, Message.FailedFetchTranscript, Message.FoundCachedTranscript], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, sessionId, apiBase, readsCache }) => + withCache({ + key: ["local", "session-transcript", sessionId], + readsCache, + fromCached: cachedAs(Schema.Array(TranscriptEvent)), + toCached: (events: ReadonlyArray) => events, + fetch: Effect.map( + queryRows(CH.compile(CH.sessionTranscriptQuery({ limit: 250 }), { orgId: LOCAL_ORG_ID, sessionId }), apiBase), + (rows): ReadonlyArray => + rows.map( + (row): TranscriptEvent => ({ + timestamp: row.timestamp, + seq: row.seq, + type: row.type, + url: row.url, + level: row.level, + message: row.message, + targetSelector: row.targetSelector, + targetText: row.targetText, + netMethod: row.netMethod, + netUrl: row.netUrl, + netStatus: row.netStatus, + netDurationMs: row.netDurationMs, + errorStack: row.errorStack, + }), + ), + ), + found: (cached) => Message.FoundCachedTranscript({ key, cached }), + succeeded: (events, fetchedAtMs) => Message.SucceededFetchTranscript({ key, events, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTranscript({ key, failure })))), +}) + +/** Per-trace summaries for the session's correlated backend traces. */ +export const FetchTraces = Command.define("FetchLocalSessionTraces", { + args: { key: Schema.String, traceIds: Schema.Array(Schema.String), apiBase: Schema.String, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTraces, Message.FailedFetchTraces, Message.FoundCachedTraces], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, traceIds, apiBase, readsCache }) => + withCache({ + key: ["local", "session-traces", traceIds], + readsCache, + fromCached: cachedAs(Schema.Array(SessionTrace)), + toCached: (traces: ReadonlyArray) => traces, + fetch: Effect.map( + queryRows(CH.compile(CH.sessionTraceSummariesQuery({ traceIds }), { orgId: LOCAL_ORG_ID }), apiBase), + (rows): ReadonlyArray => + rows.map( + (row): SessionTrace => ({ + traceId: row.traceId, + durationMs: row.durationMs, + rootSpanName: row.rootSpanName, + rootServiceName: row.rootServiceName, + spanCount: row.spanCount, + hasError: row.hasError, + }), + ), + ), + found: (cached) => Message.FoundCachedTraces({ key, cached }), + succeeded: (traces, fetchedAtMs) => Message.SucceededFetchTraces({ key, traces, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTraces({ key, failure })))), +}) diff --git a/apps/local-ui-foldkit/src/page/session-detail/index.ts b/apps/local-ui-foldkit/src/page/session-detail/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/session-detail/lanes.ts b/apps/local-ui-foldkit/src/page/session-detail/lanes.ts new file mode 100644 index 0000000000..ffdbac40a8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/lanes.ts @@ -0,0 +1,10 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchDetail, FetchTraces, FetchTranscript } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchDetail.name, model.detail, (key) => FetchDetail.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchTranscript.name, model.transcript, (key) => FetchTranscript.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchTraces.name, model.traces, (key) => FetchTraces.Interrupt({ key }, outcomeOf)), +] diff --git a/apps/local-ui-foldkit/src/page/session-detail/message.ts b/apps/local-ui-foldkit/src/page/session-detail/message.ts new file mode 100644 index 0000000000..4bc418859d --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/message.ts @@ -0,0 +1,25 @@ +import { CachedOf } from "../../data/cached" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "../../data/local-query" +import { SessionDetail, SessionTrace, TranscriptEvent } from "./model" + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchDetail: { key: Schema.String, detail: Schema.NullOr(SessionDetail), fetchedAtMs: Schema.Number }, + FailedFetchDetail: { key: Schema.String, failure: LoadFailure }, + /** The result cache held this read before the fetch ran (exact keys: no older windows here). */ + FoundCachedDetail: { key: Schema.String, cached: CachedOf(Schema.NullOr(SessionDetail)) }, + SucceededFetchTranscript: { key: Schema.String, events: Schema.Array(TranscriptEvent), fetchedAtMs: Schema.Number }, + FailedFetchTranscript: { key: Schema.String, failure: LoadFailure }, + FoundCachedTranscript: { key: Schema.String, cached: CachedOf(Schema.Array(TranscriptEvent)) }, + SucceededFetchTraces: { key: Schema.String, traces: Schema.Array(SessionTrace), fetchedAtMs: Schema.Number }, + FailedFetchTraces: { key: Schema.String, failure: LoadFailure }, + FoundCachedTraces: { key: Schema.String, cached: CachedOf(Schema.Array(SessionTrace)) }, + ClickedBack: {}, + ClickedRefresh: {}, + ClickedRetryDetail: {}, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/session-detail/model.ts b/apps/local-ui-foldkit/src/page/session-detail/model.ts new file mode 100644 index 0000000000..9844dc40bd --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/model.ts @@ -0,0 +1,69 @@ +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" + +// apps/local-ui/src/hooks/use-local-session-detail.ts, as Schemas: the columns the page reads. + +export const SessionDetail = Schema.Struct({ + sessionId: Schema.String, + startTime: Schema.DateTimeUtc, + durationMs: Schema.NullOr(Schema.Number), + status: Schema.String, + lastActivityAt: Schema.NullOr(Schema.DateTimeUtc), + userId: Schema.String, + userName: Schema.String, + userEmail: Schema.String, + urlInitial: Schema.String, + userAgent: Schema.String, + browserName: Schema.String, + osName: Schema.String, + deviceType: Schema.String, + serviceName: Schema.String, + pageViews: Schema.Number, + clickCount: Schema.Number, + errorCount: Schema.Number, + traceIds: Schema.Array(Schema.String), +}) +export type SessionDetail = typeof SessionDetail.Type + +export const TranscriptEvent = Schema.Struct({ + timestamp: Schema.DateTimeUtc, + seq: Schema.Number, + type: Schema.String, + url: Schema.String, + level: Schema.String, + message: Schema.String, + targetSelector: Schema.String, + targetText: Schema.String, + netMethod: Schema.String, + netUrl: Schema.String, + netStatus: Schema.Number, + netDurationMs: Schema.Number, + errorStack: Schema.String, +}) +export type TranscriptEvent = typeof TranscriptEvent.Type + +export const SessionTrace = Schema.Struct({ + traceId: Schema.String, + durationMs: Schema.Number, + rootSpanName: Schema.String, + rootServiceName: Schema.String, + spanCount: Schema.Number, + hasError: Schema.Number, +}) +export type SessionTrace = typeof SessionTrace.Type + +export const Model = Schema.TaggedStruct("SessionDetailPage", { + sessionId: Schema.String, + /** The clock at the last read, for live-ness and "started 3m ago". */ + nowMs: Schema.Number, + /** `null` once loaded: the session was not found. */ + detail: SlotSchema(Schema.NullOr(SessionDetail)), + transcript: SlotSchema(Schema.Array(TranscriptEvent)), + traces: SlotSchema(Schema.Array(SessionTrace)), + /** `dataUpdatedAt` of the detail, for the "New data" pill. */ + detailUpdatedAtMs: Schema.Number, + isRefreshing: Schema.Boolean, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/session-detail/session-detail.scene.test.ts b/apps/local-ui-foldkit/src/page/session-detail/session-detail.scene.test.ts new file mode 100644 index 0000000000..53093e70c8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/session-detail.scene.test.ts @@ -0,0 +1,78 @@ +import { DateTime, Option } from "effect" +import { click, expect as expectView, expectOutMessage, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { FetchDetail, FetchTraces, FetchTranscript } from "./command" +import { Message } from "./message" +import type { Model, SessionDetail } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const NOW = Date.UTC(2026, 9, 1, 12, 0, 0) +const env = { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false } +const context: PageContext = { + env, + location: { path: "/sessions/sess-1", search: "range=1h&spanId=abc" }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +} +const inputs: PageViewInputs = { context, presenceOf: () => Option.none(), presenceNowMs: NOW, clockMs: 0, hasNewDataSince: () => false } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, inputs)() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +const session = (traceIds: ReadonlyArray): SessionDetail => ({ + sessionId: "sess-1", + startTime: DateTime.makeUnsafe(NOW - 60_000), + durationMs: 30_000, + status: "ended", + lastActivityAt: null, + userId: "", + userName: "Ada", + userEmail: "", + urlInitial: "https://shop.test/", + userAgent: "Mozilla/5.0", + browserName: "Chrome", + osName: "macOS", + deviceType: "desktop", + serviceName: "storefront", + pageViews: 3, + clickCount: 5, + errorCount: 0, + traceIds, +}) + +describe("local session detail", () => { + test("entry asks for the session and its transcript at once", () => { + expect(names(init(context, "sess-1").commands)).toEqual(["ReadSessionDetailClock", FetchDetail.name, FetchTranscript.name]) + }) + + test("a found session with traces asks for their summaries; an answer for another key is dropped", () => { + const model = init(context, "sess-1").model + const stale = pageUpdate(model, Message.SucceededFetchDetail({ key: "session:other", detail: null, fetchedAtMs: NOW })) + expect(stale.model.detail).toEqual(model.detail) + const found = pageUpdate(model, Message.SucceededFetchDetail({ key: model.detail.key ?? "", detail: session(["t1"]), fetchedAtMs: NOW })) + expect(names(found.commands)).toEqual([FetchTraces.name]) + }) + + test("a missing session says so, and Back opens the list without the detail-only params", () => { + const model = init(context, "sess-1").model + const missing = pageUpdate(model, Message.SucceededFetchDetail({ key: model.detail.key ?? "", detail: null, fetchedAtMs: NOW })) + scene( + { update: pageUpdate, view }, + given(missing.model), + expectView(text("Session not found.")).toExist(), + click(role("button", { name: "Sessions" })), + expectOutMessage(PageOutMessage.RequestedBack({ fallbackHash: "#/sessions?range=1h" })), + ) + }) + + test("the start time reads the root's newer clock, as legacy reads the time at each render", () => { + const opened = pageUpdate(init(context, "sess-1").model, Message.CompletedReadClock({ nowMs: NOW })).model + const found = pageUpdate(opened, Message.SucceededFetchDetail({ key: opened.detail.key ?? "", detail: session([]), fetchedAtMs: NOW })).model + const later = withViewInputs(pageView, { ...inputs, clockMs: NOW + 20 * 60_000 })() + scene({ update: pageUpdate, view: later }, given(found), expectView(text("21m ago")).toExist()) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/session-detail/update.ts b/apps/local-ui-foldkit/src/page/session-detail/update.ts new file mode 100644 index 0000000000..bccba3ed74 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/update.ts @@ -0,0 +1,165 @@ +import type { Resources } from "../../platform/resources" +import { Array, Option, Result } from "effect" +import { AsyncData, type Command, type Update } from "foldkit" +import { accept, idle, keyOf, request, acceptCached } from "../../data/slot" +import { hrefFor, queryOf } from "../../route" +import { type PageContext, PageOutMessage } from "../contract" +import { updateCopy } from "../kit/copies" +import { FetchDetail, FetchTraces, FetchTranscript, ReadClock } from "./command" +import { Message } from "./message" +import type { Model } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage +type Commands = ReadonlyArray> + +/** Detail-page params that must not leak back into the list's filters (App.tsx). */ +const DETAIL_ONLY_PARAMS = ["spanId", "view"] + +const detailKey = (sessionId: string) => keyOf("session", sessionId) +const transcriptKey = (sessionId: string) => keyOf("session-transcript", sessionId) + +/** `useLocalSessionDetail` and `useLocalSessionTranscript`; `force` refetches (`invalidateQueries`). */ +const loadSession = (model: Model, apiBase: string, force: boolean): Readonly<{ model: Model; commands?: Commands }> => { + const { sessionId } = model + const detail = request(model.detail, detailKey(sessionId), force) + const transcript = request(model.transcript, transcriptKey(sessionId), force) + return { + model: { + ...model, + detail: Option.getOrElse(detail, () => model.detail), + transcript: Option.getOrElse(transcript, () => model.transcript), + }, + commands: Array.getSomes([ + Option.map(detail, () => FetchDetail({ key: detailKey(sessionId), sessionId, apiBase, readsCache: !force })), + Option.map(transcript, () => FetchTranscript({ key: transcriptKey(sessionId), sessionId, apiBase, readsCache: !force })), + ]), + } +} + +/** `useLocalSessionTraces(session?.traceIds)`: only once the detail lists some. */ +const loadTraces = (model: Model, apiBase: string, force: boolean): Readonly<{ model: Model; commands?: Commands }> => { + const traceIds = Option.match(AsyncData.getData(model.detail.data), { onNone: () => [], onSome: (detail) => detail?.traceIds ?? [] }) + if (traceIds.length === 0) return { model } + const key = keyOf("session-traces", traceIds) + return Option.match(request(model.traces, key, force), { + onNone: () => ({ model }), + onSome: (traces) => ({ model: { ...model, traces }, commands: [FetchTraces({ key, traceIds, apiBase, readsCache: !force && model.traces.key !== key })] }), + }) +} + +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + return model.isRefreshing && ![model.detail.data, model.transcript.data, model.traces.data].some(isLoading) + ? { ...model, isRefreshing: false } + : model +} + +export const init = (context: PageContext, sessionId: string): Readonly<{ model: Model; commands: Commands }> => { + const loaded = loadSession( + { + _tag: "SessionDetailPage", + sessionId, + nowMs: 0, + detail: idle(), + transcript: idle(), + traces: idle(), + detailUpdatedAtMs: 0, + isRefreshing: false, + copies: {}, + }, + context.env.apiBase, + false, + ) + return { model: loaded.model, commands: [ReadClock(), ...(loaded.commands ?? [])] } +} + +/** The page reads nothing from the fragment's query. */ +export const locationChanged = (model: Model, _context: PageContext): UpdateReturn => ({ model }) + +/** `back("/sessions")`: history back, or the list with the detail-only params dropped. */ +const back = (model: Model, context: PageContext): UpdateReturn => { + const fallback = queryOf(context.location) + DETAIL_ONLY_PARAMS.forEach((key) => fallback.delete(key)) + return { model, outMessage: PageOutMessage.RequestedBack({ fallbackHash: hrefFor("/sessions", fallback) }) } +} + +/** `invalidateQueries(["local"])`: every query the page holds refetches; the spinner waits for all. */ +const refresh = (model: Model, context: PageContext): UpdateReturn => { + if (model.isRefreshing) return { model } + const session = loadSession({ ...model, isRefreshing: true }, context.env.apiBase, true) + const traces = loadTraces(session.model, context.env.apiBase, true) + return { + model: traces.model, + commands: [...(session.commands ?? []), ...(traces.commands ?? [])], + outMessage: PageOutMessage.RefetchedInPlace(), + } +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + CompletedReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + SucceededFetchDetail: ({ key, detail, fetchedAtMs }) => { + const isCurrent = model.detail.key === key + const next = { + ...model, + detail: accept(model.detail, key, Result.succeed(detail), fetchedAtMs), + detailUpdatedAtMs: isCurrent ? fetchedAtMs : model.detailUpdatedAtMs, + } + const traces = loadTraces(next, context.env.apiBase, false) + return { model: settleRefresh(traces.model), commands: traces.commands } + }, + FailedFetchDetail: ({ key, failure }) => ({ + model: settleRefresh({ ...model, detail: accept(model.detail, key, Result.fail(failure)) }), + }), + SucceededFetchTranscript: ({ key, events, fetchedAtMs }) => ({ + model: settleRefresh({ ...model, transcript: accept(model.transcript, key, Result.succeed(events), fetchedAtMs) }), + }), + FoundCachedDetail: ({ key, cached }) => { + if (model.detail.key !== key) return { model } + const next = { ...model, detail: acceptCached(model.detail, key, cached, cached.isFresh), detailUpdatedAtMs: cached.updatedAtMs } + const traces = loadTraces(next, context.env.apiBase, false) + const fetch = cached.isFresh ? [] : [FetchDetail({ key, sessionId: model.sessionId, apiBase: context.env.apiBase, readsCache: false })] + return { model: settleRefresh(traces.model), commands: [...fetch, ...(traces.commands ?? [])] } + }, + FoundCachedTranscript: ({ key, cached }) => + model.transcript.key === key + ? { + model: settleRefresh({ ...model, transcript: acceptCached(model.transcript, key, cached, cached.isFresh) }), + commands: cached.isFresh ? [] : [FetchTranscript({ key, sessionId: model.sessionId, apiBase: context.env.apiBase, readsCache: false })], + } + : { model }, + FoundCachedTraces: ({ key, cached }) => { + if (model.traces.key !== key) return { model } + const next = { ...model, traces: acceptCached(model.traces, key, cached, cached.isFresh) } + const traceIds = Option.match(AsyncData.getData(next.detail.data), { onNone: () => [], onSome: (detail) => detail?.traceIds ?? [] }) + return { + model: settleRefresh(next), + commands: cached.isFresh ? [] : [FetchTraces({ key, traceIds, apiBase: context.env.apiBase, readsCache: false })], + } + }, + FailedFetchTranscript: ({ key, failure }) => ({ + model: settleRefresh({ ...model, transcript: accept(model.transcript, key, Result.fail(failure)) }), + }), + SucceededFetchTraces: ({ key, traces, fetchedAtMs }) => ({ + model: settleRefresh({ ...model, traces: accept(model.traces, key, Result.succeed(traces), fetchedAtMs) }), + }), + FailedFetchTraces: ({ key, failure }) => ({ + model: settleRefresh({ ...model, traces: accept(model.traces, key, Result.fail(failure)) }), + }), + ClickedBack: () => back(model, context), + ClickedRefresh: () => refresh(model, context), + ClickedRetryDetail: () => { + const detail = request(model.detail, detailKey(model.sessionId), true) + return Option.match(detail, { + onNone: () => ({ model }), + onSome: (next) => ({ + model: { ...model, detail: next }, + commands: [FetchDetail({ key: detailKey(model.sessionId), sessionId: model.sessionId, apiBase: context.env.apiBase, readsCache: false })], + }), + }) + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/session-detail/view-bits.ts b/apps/local-ui-foldkit/src/page/session-detail/view-bits.ts new file mode 100644 index 0000000000..6461b509a1 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/view-bits.ts @@ -0,0 +1,26 @@ +import { cn } from "@maple/ui/lib/utils" +import { badge } from "@maple/web-foldkit/ui/display" +import { statusDot } from "@maple/web-foldkit/ui/status-dot" +import type { Html, HtmlBuilder } from "foldkit/html" + +// The small pieces of `SessionDetailView`: status badge, stat tiles and cards. + +export const statusBadge = (h: HtmlBuilder, isActive: boolean): Html => + isActive + ? badge(h, { variant: "ok", pill: true, className: "gap-1.5" }, [statusDot(h, { tone: "ok" }), "Active"]) + : badge(h, { variant: "muted", pill: true }, ["Ended"]) + +export const statTile = (h: HtmlBuilder, label: string, value: string, isDanger = false): Html => + h.div( + [h.Class("rounded-xl border bg-card px-3 py-2.5")], + [ + h.p([h.Class("text-xs text-muted-foreground")], [label]), + h.p( + [h.Class(cn("mt-1 text-2xl font-semibold tabular-nums tracking-tight", isDanger && "text-severity-error"))], + [value], + ), + ], + ) + +export const card = (h: HtmlBuilder, title: string, children: ReadonlyArray): Html => + h.section([h.Class("rounded-xl border bg-card p-4")], [h.h2([h.Class("mb-3 text-sm font-medium")], [title]), ...children]) diff --git a/apps/local-ui-foldkit/src/page/session-detail/view-client.ts b/apps/local-ui-foldkit/src/page/session-detail/view-client.ts new file mode 100644 index 0000000000..a3a0fa2d6d --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/view-client.ts @@ -0,0 +1,89 @@ +import { EMPTY_VALUE, countLabel, formatDuration } from "@maple/ui/lib/format" +import { shortId } from "@maple/ui/lib/ids" +import { hostFromUrl } from "@maple/ui/lib/replay" +import { cn } from "@maple/ui/lib/utils" +import { icon } from "@maple/web-foldkit/ui/icon" +import { keyValue, keyValueList } from "@maple/web-foldkit/ui/key-value" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { truncatedText } from "@maple/web-foldkit/ui/truncated-text" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import { isPending } from "../../data/slot" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../route" +import type { Model, SessionDetail, SessionTrace } from "./model" +import { card } from "./view-bits" + +// The Client and Correlated traces cards of `SessionDetailView`. + +const text = (h: HtmlBuilder, value: string, shown: string): Html => truncatedText(h, { text: value }, [shown]) + +export const clientCard = (h: HtmlBuilder, session: SessionDetail, label: string): Html => + card(h, "Client", [ + keyValueList(h, { layout: "stacked", className: "grid-cols-2 gap-x-4 gap-y-3 text-sm [&_dt]:text-xs" }, [ + keyValue(h, { label: "User" }, [text(h, label, label || "-")]), + keyValue(h, { label: "Browser" }, [text(h, session.browserName, session.browserName || "-")]), + keyValue(h, { label: "OS" }, [text(h, session.osName, session.osName || "-")]), + keyValue(h, { label: "Device" }, [text(h, session.deviceType, session.deviceType || "-")]), + keyValue(h, { label: "Service" }, [ + session.serviceName + ? h.a( + [ + h.Href(hrefFor(`/services/${encodeURIComponent(session.serviceName)}`)), + h.Class("flex min-w-0 items-center gap-1.5 hover:underline"), + ], + [serviceDot(h, { serviceName: session.serviceName, size: "sm" }), text(h, session.serviceName, session.serviceName)], + ) + : EMPTY_VALUE, + ]), + keyValue(h, { label: "Entry URL", className: "col-span-2" }, [text(h, session.urlInitial, hostFromUrl(session.urlInitial) || "-")]), + keyValue(h, { label: "User agent", className: "col-span-2" }, [text(h, session.userAgent, session.userAgent || "-")]), + ]), + ]) + +const traceItem = (h: HtmlBuilder, trace: SessionTrace): Html => + h.li( + [h.Key(trace.traceId)], + [ + h.a( + [ + h.Href(hrefFor(`/traces/${encodeURIComponent(trace.traceId)}`)), + h.Class("group flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"), + ], + [ + h.span([h.Class(cn("size-1.5 shrink-0 rounded-full", trace.hasError ? "bg-severity-error" : "bg-muted-foreground/40"))], []), + h.span( + [h.Class("min-w-0 flex-1")], + [ + h.span([h.Class("block truncate text-sm")], [trace.rootSpanName || shortId(trace.traceId, "trace", { length: 12 })]), + h.span( + [h.Class("flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground")], + [ + ...(trace.rootServiceName ? [serviceDot(h, { serviceName: trace.rootServiceName, size: "sm" })] : []), + h.span([h.Class("truncate")], [trace.rootServiceName || "unknown", " ·", " ", countLabel(trace.spanCount, "span")]), + ], + ), + ], + ), + h.span([h.Class("shrink-0 font-mono text-xs tabular-nums text-muted-foreground")], [formatDuration(trace.durationMs)]), + icon(h, "ArrowRightIcon", { + size: 14, + className: "shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100", + }), + ], + ), + ], + ) + +export const tracesCard = (h: HtmlBuilder, model: Model, traceIds: ReadonlyArray): Html => + card(h, `Correlated traces · ${traceIds.length}`, [ + traceIds.length === 0 + ? h.p([h.Class("text-sm text-muted-foreground")], ["No backend traces correlated."]) + : isPending(model.traces) + ? spinner(h, "size-4") + : h.ul( + [h.Class("space-y-1.5")], + Option.getOrElse(AsyncData.getData(model.traces.data), () => []).map((trace) => traceItem(h, trace)), + ), + ]) diff --git a/apps/local-ui-foldkit/src/page/session-detail/view-hero.ts b/apps/local-ui-foldkit/src/page/session-detail/view-hero.ts new file mode 100644 index 0000000000..e1f096cb51 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/view-hero.ts @@ -0,0 +1,92 @@ +import { formatUtcTitle } from "@maple/local-ui/lib/time" +import { formatDateTime, type TimeFormat } from "../kit/local-time" +import { isLocalSessionLive, localSessionDurationMs } from "@maple/local-ui/lib/session-liveness" +import { initialsFrom } from "@maple/ui/lib/initials" +import { gradientFor, isMobileDevice } from "@maple/ui/lib/replay" +import { formatSessionDuration } from "@maple/ui/lib/replay-format" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { icon } from "@maple/web-foldkit/ui/icon" +import { truncatedId } from "@maple/web-foldkit/ui/truncated-id" +import { DateTime } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { copyOf } from "../kit/copies" +import { Message } from "./message" +import type { Model, SessionDetail } from "./model" +import { statTile, statusBadge } from "./view-bits" + +// The hero and stat tiles of `SessionDetailView`. + +export const SESSION_ID_COPY = "session-id" + +export const hero = (h: HtmlBuilder, model: Model, session: SessionDetail, label: string, timeFormat: TimeFormat): Html => { + const copy = copyOf(model.copies, SESSION_ID_COPY) + return h.div( + [h.Class("flex flex-wrap items-center gap-4 border-b pb-5")], + [ + h.div( + [ + h.Class( + `grid size-12 shrink-0 place-items-center rounded-full bg-gradient-to-br ${gradientFor(model.sessionId)} text-base font-semibold text-white shadow-sm`, + ), + ], + [initialsFrom(label)], + ), + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [h.h1([h.Class("truncate text-xl font-semibold tracking-tight")], [label]), statusBadge(h, isLocalSessionLive(session, model.nowMs))], + ), + h.div( + [h.Class("mt-1 flex flex-wrap items-center gap-3 text-sm text-muted-foreground")], + [ + truncatedId(h, { + value: model.sessionId, + kind: "session", + length: 8, + className: "text-xs", + copy: { + status: copy.status, + onClick: Message.GotCopyMessage({ id: SESSION_ID_COPY, message: CopyButton.Message.ClickedCopy({ text: model.sessionId }) }), + }, + }), + h.span( + [h.Class("inline-flex items-center gap-1.5")], + [ + icon(h, isMobileDevice(session.deviceType) ? "MobileIcon" : "ComputerIcon", { className: "size-3.5 opacity-60" }), + session.browserName || "Unknown", + ...(session.osName ? [` · ${session.osName}`] : []), + ], + ), + h.span( + [ + h.Class("inline-flex items-center gap-1.5"), + h.Title(`${formatDateTime(timeFormat, session.startTime)} (${formatUtcTitle(session.startTime)})`), + ], + [ + icon(h, "ClockIcon", { className: "size-3.5 opacity-60" }), + "started ", + formatRelativeFrom(DateTime.toEpochMillis(session.startTime), model.nowMs), + ], + ), + ], + ), + ], + ), + ], + ) +} + +export const statTiles = (h: HtmlBuilder, session: SessionDetail): Html => + h.div( + [h.Class("mt-5 grid grid-cols-2 gap-3 sm:grid-cols-5")], + [ + statTile(h, "Duration", formatSessionDuration(localSessionDurationMs(session))), + statTile(h, "Page views", String(session.pageViews)), + statTile(h, "Clicks", String(session.clickCount)), + statTile(h, "Errors", String(session.errorCount), session.errorCount > 0), + statTile(h, "Traces", String(session.traceIds.length)), + ], + ) diff --git a/apps/local-ui-foldkit/src/page/session-detail/view-transcript.ts b/apps/local-ui-foldkit/src/page/session-detail/view-transcript.ts new file mode 100644 index 0000000000..d0eac52ad0 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/view-transcript.ts @@ -0,0 +1,106 @@ +import { parseClickHouseDateTime, type WarehouseTime } from "@maple/local-ui/lib/time" +import { cn } from "@maple/ui/lib/utils" +import { icon, type IconName } from "@maple/web-foldkit/ui/icon" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { TranscriptEvent } from "./model" + +// `Transcript` and `TranscriptBody` (apps/local-ui/src/views/session-detail-view.tsx). + +const offsetLabel = (startTime: WarehouseTime, ts: WarehouseTime): string => { + const start = parseClickHouseDateTime(startTime) + const at = parseClickHouseDateTime(ts) + if (start === null || at === null) return "" + const deltaMs = Math.max(0, at - start) + if (deltaMs < 1000) return `+${deltaMs}ms` + return `+${(deltaMs / 1000).toFixed(1)}s` +} + +const EVENT_ICONS: ReadonlyMap = new Map([ + ["navigation", "GlobeIcon"], + ["click", "PulseIcon"], + ["input", "PulseIcon"], + ["console", "CodeIcon"], + ["network", "NetworkNodesIcon"], + ["error", "CircleWarningIcon"], +] as const) + +const isErrorEvent = (event: TranscriptEvent): boolean => + event.type === "error" || + (event.type === "console" && event.level === "error") || + (event.type === "network" && event.netStatus >= 400) + +const line = (h: HtmlBuilder, className: string, children: ReadonlyArray): Html => + h.p([h.Class(className)], children) + +const body = (h: HtmlBuilder, event: TranscriptEvent): Html => { + const muted = "truncate text-xs text-muted-foreground" + if (event.type === "navigation") return line(h, muted, [event.url || "-"]) + if (event.type === "click") return line(h, muted, [event.targetText || event.targetSelector || "element"]) + if (event.type === "input") return line(h, "truncate font-mono text-xs text-muted-foreground", [event.targetSelector || "input"]) + if (event.type === "console") return line(h, "break-words text-xs text-muted-foreground", [event.message]) + if (event.type === "network") + return line(h, muted, [ + h.span([h.Class("font-medium text-foreground")], [event.netMethod]), + " ", + event.netUrl, + h.span( + [h.Class(cn("ml-1.5 tabular-nums", event.netStatus >= 400 && "text-severity-error"))], + [event.netStatus ? String(event.netStatus) : "-", " · ", String(Math.round(event.netDurationMs)), "ms"], + ), + ]) + if (event.type === "error") + return h.div( + [], + [ + line(h, "break-words text-xs text-severity-error", [event.message]), + ...(event.errorStack + ? [ + h.pre( + [h.Class("mt-1 max-h-24 overflow-auto whitespace-pre-wrap rounded bg-muted/50 p-1.5 font-mono text-[10px] text-muted-foreground")], + [event.errorStack], + ), + ] + : []), + ], + ) + return line(h, muted, [event.message]) +} + +export const transcript = (h: HtmlBuilder, events: ReadonlyArray, startTime: WarehouseTime): Html => + h.ol( + [h.Class("space-y-3")], + events.map((event) => + h.li( + [h.Key(`${event.seq}-${String(event.timestamp)}`), h.Class("flex gap-3")], + [ + h.span( + [ + h.Class( + cn( + "mt-0.5 grid size-6 shrink-0 place-items-center rounded-full", + isErrorEvent(event) ? "bg-severity-error/10 text-severity-error" : "bg-muted text-muted-foreground", + ), + ), + ], + [icon(h, EVENT_ICONS.get(event.type) ?? "CodeIcon", { className: "size-3.5" })], + ), + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.div( + [h.Class("flex items-baseline justify-between gap-2")], + [ + h.span([h.Class("text-xs font-medium capitalize")], [event.type]), + h.span( + [h.Class("shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground")], + [offsetLabel(startTime, event.timestamp)], + ), + ], + ), + body(h, event), + ], + ), + ], + ), + ), + ) diff --git a/apps/local-ui-foldkit/src/page/session-detail/view.ts b/apps/local-ui-foldkit/src/page/session-detail/view.ts new file mode 100644 index 0000000000..228ddde2bc --- /dev/null +++ b/apps/local-ui-foldkit/src/page/session-detail/view.ts @@ -0,0 +1,84 @@ +import { button } from "@maple/web-foldkit/ui/button" +import { icon } from "@maple/web-foldkit/ui/icon" +import { spinner } from "@maple/web-foldkit/ui/spinner" +import { truncatedText } from "@maple/web-foldkit/ui/truncated-text" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import type { PageViewInputs } from "../kit/inputs" +import type { TimeFormat } from "../kit/local-time" +import { refreshButton } from "../kit/toolbar" +import { errorState } from "../kit/view-states" +import { Message } from "./message" +import type { Model, SessionDetail } from "./model" +import { card } from "./view-bits" +import { clientCard, tracesCard } from "./view-client" +import { hero, statTiles } from "./view-hero" +import { transcript } from "./view-transcript" + +// `SessionDetailView` (apps/local-ui/src/views/session-detail-view.tsx). + +const transcriptBody = (h: HtmlBuilder, model: Model, session: SessionDetail): Html => { + if (isPending(model.transcript)) return spinner(h, "size-4") + const failure = failureWithoutData(model.transcript) + if (Option.isSome(failure)) return errorState(h, { label: "transcript", failure: failure.value }) + const events = Option.getOrElse(AsyncData.getData(model.transcript.data), () => []) + if (events.length === 0) return h.p([h.Class("text-sm text-muted-foreground")], ["No distilled events for this session."]) + return transcript(h, events, session.startTime) +} + +const sessionBody = (h: HtmlBuilder, model: Model, session: SessionDetail, timeFormat: TimeFormat): Html => { + const label = session.userName || session.userEmail || session.userId || "Anonymous" + return h.div( + [h.Class("mx-auto max-w-5xl px-4 py-5")], + [ + hero(h, model, session, label, timeFormat), + statTiles(h, session), + h.div( + [h.Class("mt-5 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]")], + [ + h.div([h.Class("flex flex-col gap-5")], [clientCard(h, session, label), tracesCard(h, model, session.traceIds)]), + card(h, "Event transcript", [transcriptBody(h, model, session)]), + ], + ), + ], + ) +} + +const content = (h: HtmlBuilder, model: Model, timeFormat: TimeFormat): Html => { + if (isPending(model.detail)) return h.div([h.Class("flex h-full items-center justify-center")], [spinner(h)]) + const failure = failureWithoutData(model.detail) + if (Option.isSome(failure)) return errorState(h, { label: "session", failure: failure.value, onRetry: Message.ClickedRetryDetail() }) + const session = Option.getOrNull(AsyncData.getData(model.detail.data)) + if (session === null) + return h.div([h.Class("flex h-full items-center justify-center text-sm text-muted-foreground")], ["Session not found."]) + return sessionBody(h, model, session, timeFormat) +} + +export const view = Submodel.defineView((opened, inputs, h) => { + // "Live" and "started 3m ago" follow the root's clock, as legacy's render-time `Date.now()` does. + const model = { ...opened, nowMs: Math.max(opened.nowMs, inputs.clockMs) } + return h.div( + [h.Class("flex h-full flex-col")], + [ + h.div( + [h.Class("flex shrink-0 items-center gap-3 border-b px-4 py-2")], + [ + button(h, { variant: "ghost", size: "sm", className: "gap-1.5", attributes: [h.OnClick(Message.ClickedBack())] }, [ + icon(h, "ArrowLeftIcon"), + inputs.context.canGoBack ? "Back" : "Sessions", + ]), + truncatedText(h, { text: model.sessionId, mono: true, className: "text-xs text-muted-foreground" }, [model.sessionId]), + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.detailUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + className: "ml-auto", + }), + ], + ), + h.div([h.Class("min-h-0 flex-1 overflow-auto")], [content(h, model, inputs.context.timeFormat)]), + ], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/sessions/cache.ts b/apps/local-ui-foldkit/src/page/sessions/cache.ts new file mode 100644 index 0000000000..3a32c0c468 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/cache.ts @@ -0,0 +1,37 @@ +import { Option, Schema } from "effect" +import type { CacheValue, QueryKey } from "../../data/cache-store" +import { cachedAs } from "../../data/cached" +import { filtersOf, nextCursor } from "./fetch" +import { SessionFacets, type SessionPages, SessionRow } from "./model" + +// `useLocalSessions` / `useLocalSessionFacets` keys and data, as the result cache keeps them. + +type Window = Readonly<{ search: string; startTime: string; endTime: string }> + +const keyFor = (name: string, window: Window): QueryKey => [ + "local", + name, + filtersOf(new URLSearchParams(window.search)), + `${window.startTime}/${window.endTime}`, +] + +/** `["local", "sessions", filters, boundsKey]`. */ +export const sessionsKey = (window: Window) => keyFor("sessions", window) +/** `["local", "session-facets", filters, boundsKey]`. */ +export const facetsKey = (window: Window) => keyFor("session-facets", window) + +const CachedPages = Schema.Struct({ pages: Schema.Array(Schema.Array(SessionRow)) }) + +/** `useInfiniteQuery`'s data: the pages and the cursor each was fetched from. */ +export const pagesToCached = (data: Pick) => ({ + pages: data.pages, + pageParams: [undefined, ...data.pages.slice(0, -1).map(nextCursor)], +}) + +export const pagesFromCached = (value: CacheValue): Option.Option => + Option.map(cachedAs(CachedPages)(value), ({ pages }) => ({ + pages, + hasNextPage: nextCursor(pages[pages.length - 1] ?? []) !== undefined, + })) + +export const facetsFromCached = cachedAs(SessionFacets) diff --git a/apps/local-ui-foldkit/src/page/sessions/command.ts b/apps/local-ui-foldkit/src/page/sessions/command.ts new file mode 100644 index 0000000000..3ee6099b8e --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/command.ts @@ -0,0 +1,120 @@ +import { Clock, Duration, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { recordCached, withCache } from "../../data/cached" +import type { LoadFailure } from "../../data/local-query" +import { facetsFromCached, facetsKey, pagesFromCached, pagesToCached, sessionsKey } from "./cache" +import { fetchSessionFacets, fetchSessionPage, filtersOf, nextCursor, type SessionCursor } from "./fetch" +import { Message } from "./message" +import { SessionRow } from "./model" + +/** What one list or facets request reads: the hash query (filters), the window and the server. */ +const RequestArgs = { + key: Schema.String, + search: Schema.String, + startTime: Schema.String, + endTime: Schema.String, + apiBase: Schema.String, +} + +export const ReadClock = Command.define("ReadSessionsClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadSessionsRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + +/** React Query refetches an infinite query page by page, each cursor from the page before. */ +const fetchPages = ( + run: (cursor: SessionCursor | undefined) => Effect.Effect, LoadFailure, R>, + remaining: number, + cursor: SessionCursor | undefined, + loaded: ReadonlyArray>, +): Effect.Effect>, LoadFailure, R> => + Effect.flatMap(run(cursor), (page) => { + const pages = [...loaded, page] + const next = nextCursor(page) + return remaining <= 1 || next === undefined ? Effect.succeed(pages) : fetchPages(run, remaining - 1, next, pages) + }) + +/** `readsCache`: a key the page has no rows for yet opens on its cached result first. */ +export const FetchSessions = Command.define("FetchLocalSessions", { + args: { ...RequestArgs, pageCount: Schema.Number, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchSessions, Message.FailedFetchSessions, Message.FoundCachedSessions], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, pageCount, readsCache }) => { + const filters = filtersOf(new URLSearchParams(search)) + const run = (cursor: SessionCursor | undefined) => fetchSessionPage(filters, { startTime, endTime }, cursor, apiBase) + return withCache({ + key: sessionsKey({ search, startTime, endTime }), + readsCache, + fromCached: pagesFromCached, + toCached: pagesToCached, + fetch: Effect.map(fetchPages(run, pageCount, undefined, []), (pages) => ({ + pages, + hasNextPage: nextCursor(pages[pages.length - 1] ?? []) !== undefined, + })), + found: (cached) => Message.FoundCachedSessions({ key, cached }), + succeeded: (data, fetchedAtMs) => Message.SucceededFetchSessions({ key, data, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchSessions({ key, failure })))) + }, +}) + +export const FetchNextPage = Command.define("FetchNextLocalSessionsPage", { + args: { + ...RequestArgs, + /** The list generation the page extends; a reload retires it. */ + generation: Schema.Number, + cursorStartTime: Schema.DateTimeUtc, + cursorSessionId: Schema.String, + /** The pages loaded so far; the cache keeps them with the new one. */ + pages: Schema.Array(Schema.Array(SessionRow)), + }, + messages: [Message.SucceededFetchNextPage, Message.FailedFetchNextPage], + interrupt: { keyFields: ["key", "generation"], toKey: ({ key, generation }) => `${key}#${generation}` }, + execute: ({ key, generation, search, startTime, endTime, apiBase, cursorStartTime, cursorSessionId, pages }) => + fetchSessionPage( + filtersOf(new URLSearchParams(search)), + { startTime, endTime }, + { startTime: cursorStartTime, sessionId: cursorSessionId }, + apiBase, + ).pipe( + // `fetchNextPage` updates the cached query too, so a reload opens on every page loaded. + Effect.tap((page) => + Effect.flatMap(Clock.currentTimeMillis, (atMs) => + recordCached(sessionsKey({ search, startTime, endTime }), pagesToCached({ pages: [...pages, page] }), atMs), + ), + ), + Effect.map((page) => Message.SucceededFetchNextPage({ key, generation, page })), + Effect.catch((failure) => Effect.succeed(Message.FailedFetchNextPage({ key, generation, failure }))), + ), +}) + +/** `useLocalSessionFacets`' own `staleTime`. */ +const FACETS_STALE_MS = 30_000 + +export const FetchFacets = Command.define("FetchLocalSessionFacets", { + args: { ...RequestArgs, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchFacets, Message.FailedFetchFacets, Message.FoundCachedFacets], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, readsCache }) => + withCache({ + key: facetsKey({ search, startTime, endTime }), + readsCache, + fromCached: facetsFromCached, + staleMs: FACETS_STALE_MS, + toCached: (facets) => facets, + fetch: fetchSessionFacets(filtersOf(new URLSearchParams(search)), { startTime, endTime }, apiBase), + found: (cached) => Message.FoundCachedFacets({ key, cached }), + succeeded: (facets, fetchedAtMs) => Message.SucceededFetchFacets({ key, facets, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchFacets({ key, failure })))), +}) + +/** `ToolbarSearch`'s 300 ms pause before a typed value commits. */ +export const WaitSearchDebounce = Command.define("WaitSessionsSearchDebounce", { + args: { version: Schema.Number }, + messages: [Message.ElapsedSearchDebounce], + execute: ({ version }) => Effect.as(Effect.sleep(Duration.millis(300)), Message.ElapsedSearchDebounce({ version })), +}) diff --git a/apps/local-ui-foldkit/src/page/sessions/fetch.ts b/apps/local-ui-foldkit/src/page/sessions/fetch.ts new file mode 100644 index 0000000000..8fa6bae5e2 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/fetch.ts @@ -0,0 +1,108 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import type { DateTime } from "effect" +import { Effect } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import type { SessionFacets, SessionRow } from "./model" + +// apps/local-ui/src/hooks/use-local-sessions.ts as Effects. + +export const PAGE_SIZE = 50 + +export interface SessionFilters { + readonly service?: string + readonly browser?: string + readonly device?: string + readonly errorsOnly?: boolean + readonly search?: string +} + +export const filtersOf = (query: URLSearchParams): SessionFilters => ({ + service: query.get("service") || undefined, + browser: query.get("browser") || undefined, + device: query.get("device") || undefined, + errorsOnly: query.get("errors") === "1", + search: query.get("q") || undefined, +}) + +/** (StartTime, SessionId): start times are millisecond-resolution, so ties are ordinary. */ +export interface SessionCursor { + readonly startTime: DateTime.Utc + readonly sessionId: string +} + +const toRow = (row: CH.SessionReplaysListOutput): SessionRow => ({ + sessionId: row.sessionId, + startTime: row.startTime, + durationMs: row.durationMs, + status: row.status, + lastActivityAt: row.lastActivityAt, + userId: row.userId, + userName: row.userName, + userEmail: row.userEmail, + urlInitial: row.urlInitial, + browserName: row.browserName, + osName: row.osName, + deviceType: row.deviceType, + pageViews: row.pageViews, + clickCount: row.clickCount, + errorCount: row.errorCount, + traceCount: row.traceCount, +}) + +export const fetchSessionPage = ( + filters: SessionFilters, + bounds: TimeBounds, + cursor: SessionCursor | undefined, + apiBase: string, +) => + Effect.map( + queryRows( + CH.compile( + CH.sessionReplaysListQuery({ + limit: PAGE_SIZE, + cursor, + serviceName: filters.service, + browser: filters.browser, + deviceType: filters.device, + hasErrors: filters.errorsOnly, + search: filters.search, + }), + localParams(bounds), + ), + apiBase, + ), + (rows) => rows.map(toRow), + ) + +export const nextCursor = (page: ReadonlyArray): SessionCursor | undefined => { + const last = page.length === PAGE_SIZE ? page[page.length - 1] : undefined + return last ? { startTime: last.startTime, sessionId: last.sessionId } : undefined +} + +export const fetchSessionFacets = (filters: SessionFilters, bounds: TimeBounds, apiBase: string) => + Effect.map( + queryRows( + CH.compileUnion( + CH.sessionReplaysFacetsQuery({ + serviceName: filters.service, + browser: filters.browser, + deviceType: filters.device, + hasErrors: filters.errorsOnly, + search: filters.search, + }), + localParams(bounds), + ), + apiBase, + ), + (rows): SessionFacets => { + const pick = (facetType: string) => + rows.filter((row) => row.facetType === facetType && row.name).map((row) => ({ name: row.name, count: row.count })) + return { + service: pick("service"), + browser: pick("browser"), + device: pick("device"), + errorCount: rows.find((row) => row.facetType === "error")?.count ?? 0, + } + }, + ) diff --git a/apps/local-ui-foldkit/src/page/sessions/index.ts b/apps/local-ui-foldkit/src/page/sessions/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/sessions/lanes.ts b/apps/local-ui-foldkit/src/page/sessions/lanes.ts new file mode 100644 index 0000000000..bed31eeb4e --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/lanes.ts @@ -0,0 +1,18 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchFacets, FetchNextPage, FetchSessions } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchSessions.name, model.sessions, (key) => FetchSessions.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchFacets.name, model.facets, (key) => FetchFacets.Interrupt({ key }, outcomeOf)), + ...(model.sessions.key === null ? [] : [nextPageLane(model.sessions.key, model.pageGeneration, model.isFetchingNextPage)]), +] + +/** The next page in flight: one per list generation, retired by a reload or a new key. */ +const nextPageLane = (key: string, generation: number, isInFlight: boolean): Lane => ({ + scope: FetchNextPage.name, + key: `${key}#${generation}`, + isInFlight, + stop: () => FetchNextPage.Interrupt({ key, generation }, outcomeOf), +}) diff --git a/apps/local-ui-foldkit/src/page/sessions/load.ts b/apps/local-ui-foldkit/src/page/sessions/load.ts new file mode 100644 index 0000000000..3e080d0308 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/load.ts @@ -0,0 +1,70 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, type TimeBounds } from "@maple/local-ui/lib/time" +import { Array, Option } from "effect" +import { AsyncData, type Command } from "foldkit" +import { keyOf, request } from "../../data/slot" +import { type PageContext, rangeOf } from "../contract" +import { queryOf } from "../../route" +import { FetchFacets, FetchSessions } from "./command" +import { filtersOf } from "./fetch" +import type { Message } from "./message" +import type { Model } from "./model" + +/** The window the list, facets and refresh share: the range resolved at the anchor. */ +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +/** React Query's key: `["local", name, filters, boundsKey(bounds)]`. */ +const keyFor = (name: string, context: PageContext, bounds: TimeBounds): string => + keyOf(name, { filters: filtersOf(queryOf(context.location)), bounds: `${bounds.startTime}/${bounds.endTime}` }) + +/** Requests what the current filters and window need; `force` refetches held keys (`invalidateQueries`). */ +export const load = ( + model: Model, + context: PageContext, + force = false, +): Readonly<{ model: Model; commands?: ReadonlyArray> }> => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const args = { search: context.location.search, ...bounds, apiBase: context.env.apiBase } + const sessionsKey = keyFor("sessions", context, bounds) + const pageCount = Option.match(AsyncData.getData(model.sessions.data), { + onNone: () => 1, + onSome: (data) => (force && model.sessions.key === sessionsKey ? Math.max(1, data.pages.length) : 1), + }) + const sessions = request(model.sessions, sessionsKey, force) + const facetsKey = keyFor("session-facets", context, bounds) + const facets = request(model.facets, facetsKey, force) + return { + model: { + ...model, + sessions: Option.getOrElse(sessions, () => model.sessions), + facets: Option.getOrElse(facets, () => model.facets), + isFetchingNextPage: Option.isSome(sessions) ? false : model.isFetchingNextPage, + pageGeneration: Option.isSome(sessions) ? model.pageGeneration + 1 : model.pageGeneration, + }, + commands: Array.getSomes([ + Option.map(sessions, () => FetchSessions({ key: sessionsKey, ...args, pageCount, readsCache: model.sessions.key !== sessionsKey })), + Option.map(facets, () => FetchFacets({ key: facetsKey, ...args, readsCache: model.facets.key !== facetsKey })), + ]), + } + }, + }) + +/** A cached result is on screen: fetch the real one (`readsCache: false`), unless it is fresh. */ +export const fetchAfterCache = ( + model: Model, + context: PageContext, + which: "sessions" | "facets", + isFresh: boolean, +): ReadonlyArray> => + isFresh + ? [] + : Option.match(boundsOf(model, context), { + onNone: () => [], + onSome: (bounds) => { + const args = { key: model[which].key ?? "", search: context.location.search, ...bounds, apiBase: context.env.apiBase, readsCache: false } + return [which === "sessions" ? FetchSessions({ ...args, pageCount: 1 }) : FetchFacets(args)] + }, + }) diff --git a/apps/local-ui-foldkit/src/page/sessions/message.ts b/apps/local-ui-foldkit/src/page/sessions/message.ts new file mode 100644 index 0000000000..8486639398 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/message.ts @@ -0,0 +1,40 @@ +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { SessionFacets, SessionPages, SessionRow } from "./model" + +/** A sidebar facet the list takes one value of: its URL param. */ +export const FacetParam = Schema.Literals(["service", "browser", "device"]) +export type FacetParam = typeof FacetParam.Type + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchSessions: { key: Schema.String, data: SessionPages, fetchedAtMs: Schema.Number }, + FailedFetchSessions: { key: Schema.String, failure: LoadFailure }, + /** The result cache held this key (or an older window of its view) before the fetch ran. */ + FoundCachedSessions: { key: Schema.String, cached: CachedOf(SessionPages) }, + ClickedLoadMore: {}, + SucceededFetchNextPage: { key: Schema.String, generation: Schema.Number, page: Schema.Array(SessionRow) }, + FailedFetchNextPage: { key: Schema.String, generation: Schema.Number, failure: LoadFailure }, + SucceededFetchFacets: { key: Schema.String, facets: SessionFacets, fetchedAtMs: Schema.Number }, + FailedFetchFacets: { key: Schema.String, failure: LoadFailure }, + FoundCachedFacets: { key: Schema.String, cached: CachedOf(SessionFacets) }, + ClickedRefresh: {}, + ClickedOpenFilters: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ChangedSearch: { text: Schema.String }, + ClickedClearSearch: {}, + ElapsedSearchDebounce: { version: Schema.Number }, + ToggledErrorsOnly: {}, + ToggledFacetOption: { param: FacetParam, name: Schema.String }, + ClickedClearFilters: {}, + ClickedClearAllFilters: {}, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + GotFilterSectionsMessage: { message: FilterSections.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/sessions/model.ts b/apps/local-ui-foldkit/src/page/sessions/model.ts new file mode 100644 index 0000000000..816264487e --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/model.ts @@ -0,0 +1,63 @@ +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" + +// apps/local-ui/src/hooks/use-local-sessions.ts, as Schemas: the columns the list card reads. + +export const SessionRow = Schema.Struct({ + sessionId: Schema.String, + startTime: Schema.DateTimeUtc, + durationMs: Schema.NullOr(Schema.Number), + status: Schema.String, + lastActivityAt: Schema.NullOr(Schema.DateTimeUtc), + userId: Schema.String, + userName: Schema.String, + userEmail: Schema.String, + urlInitial: Schema.String, + browserName: Schema.String, + osName: Schema.String, + deviceType: Schema.String, + pageViews: Schema.Number, + clickCount: Schema.Number, + errorCount: Schema.Number, + traceCount: Schema.Number, +}) +export type SessionRow = typeof SessionRow.Type + +export const SessionPages = Schema.Struct({ pages: Schema.Array(Schema.Array(SessionRow)), hasNextPage: Schema.Boolean }) +export type SessionPages = typeof SessionPages.Type + +export const FacetOption = Schema.Struct({ name: Schema.String, count: Schema.Number }) +export type FacetOption = typeof FacetOption.Type + +export const SessionFacets = Schema.Struct({ + service: Schema.Array(FacetOption), + browser: Schema.Array(FacetOption), + device: Schema.Array(FacetOption), + errorCount: Schema.Number, +}) +export type SessionFacets = typeof SessionFacets.Type + +/** A debounced text input: what is typed, a version retiring superseded timers, the last sent. */ +export const Draft = Schema.Struct({ text: Schema.String, version: Schema.Number, sent: Schema.String }) +export type Draft = typeof Draft.Type + +export const Model = Schema.TaggedStruct("SessionsPage", { + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + /** The clock at the last read, for live-ness and relative times. */ + nowMs: Schema.Number, + sessions: SlotSchema(SessionPages), + facets: SlotSchema(SessionFacets), + isFetchingNextPage: Schema.Boolean, + /** Bumped when the list is refetched or rekeyed; a next page from an older generation is dropped. */ + pageGeneration: Schema.Number, + /** `dataUpdatedAt` of the list, for the "New data" pill. */ + sessionsUpdatedAtMs: Schema.Number, + search: Draft, + sections: FilterSections.Model, + isRefreshing: Schema.Boolean, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/sessions/sessions.scene.test.ts b/apps/local-ui-foldkit/src/page/sessions/sessions.scene.test.ts new file mode 100644 index 0000000000..d96759a29f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/sessions.scene.test.ts @@ -0,0 +1,104 @@ +import { DateTime, Option } from "effect" +import { AsyncData } from "foldkit" +import { expect as expectView, given, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { FetchFacets, FetchSessions, ReadClock } from "./command" +import { Message } from "./message" +import { PAGE_SIZE } from "./fetch" +import type { Model, SessionRow } from "./model" +import { init, locationChanged, update } from "./update" +import { view as pageView } from "./view" + +const NOW = Date.UTC(2026, 9, 1, 12, 0, 30) +const env = { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false } +const contextAt = (search: string): PageContext => ({ + env, + location: { path: "/sessions", search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +}) +const context = contextAt("") +const inputs: PageViewInputs = { + context, + presenceOf: () => Option.none(), + presenceNowMs: NOW, + clockMs: 0, + hasNewDataSince: () => false, +} +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, inputs)() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +const sessionRow: SessionRow = { + sessionId: "s", + startTime: DateTime.makeUnsafe(NOW - 60_000), + durationMs: 1_000, + status: "ended", + lastActivityAt: null, + userId: "", + userName: "", + userEmail: "", + urlInitial: "/", + browserName: "Chrome", + osName: "macOS", + deviceType: "desktop", + pageViews: 1, + clickCount: 0, + errorCount: 0, + traceCount: 0, +} + +const clocked = () => pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + +describe("local sessions list", () => { + test("entry reads the clock, then asks for the list and its facets in the minute's window", () => { + expect(names(init(context).commands)).toContain(ReadClock.name) + const loaded = clocked() + expect(names(loaded.commands)).toEqual([FetchSessions.name, FetchFacets.name]) + expect(Option.getOrNull(loaded.model.maybeAnchorMs)).toBe(Date.UTC(2026, 9, 1, 12, 0, 0)) + }) + + test("a list answer for superseded filters is dropped", () => { + const first = clocked().model + const second = locationChanged(first, contextAt("browser=Chrome")).model + expect(second.sessions.key).not.toBe(first.sessions.key) + const late = update( + second, + Message.SucceededFetchSessions({ key: first.sessions.key ?? "", data: { pages: [[]], hasNextPage: false }, fetchedAtMs: NOW }), + contextAt("browser=Chrome"), + ) + expect(late.model.sessions).toEqual(second.sessions) + }) + + test("an empty list asks for the signal presence and states the fact while it is unknown", () => { + const model = clocked().model + const empty = pageUpdate( + model, + Message.SucceededFetchSessions({ key: model.sessions.key ?? "", data: { pages: [[]], hasNextPage: false }, fetchedAtMs: NOW }), + ) + expect(empty.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + expect(AsyncData.isSuccess(empty.model.sessions.data)).toBe(true) + scene({ update: pageUpdate, view }, given(empty.model), expectView(text("No sessions found")).toExist()) + }) + + test("Load more on the previous filter's rows waits for the new filter's list", () => { + const model = clocked().model + const placeholderRow = (index: number): SessionRow => ({ ...sessionRow, sessionId: `s${index}` }) + const data = { pages: [Array.from({ length: PAGE_SIZE }, (_, index) => placeholderRow(index))], hasNextPage: true } + const full = pageUpdate(model, Message.SucceededFetchSessions({ key: model.sessions.key ?? "", data, fetchedAtMs: NOW })).model + const filtered = contextAt("browser=Chrome") + const pending = locationChanged(full, filtered).model + expect(pending.sessions.isPlaceholder).toBe(true) + expect(names(update(pending, Message.ClickedLoadMore(), filtered).commands)).toEqual([]) + }) + + test("a fresh cached empty list asks for signal presence like a fetched one", () => { + const base = clocked().model + const cached = { data: { pages: [[]], hasNextPage: false }, updatedAtMs: NOW - 5_000, isStandIn: false, isFresh: true } + const found = pageUpdate(base, Message.FoundCachedSessions({ key: base.sessions.key ?? "", cached })) + expect(found.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/sessions/update-helpers.ts b/apps/local-ui-foldkit/src/page/sessions/update-helpers.ts new file mode 100644 index 0000000000..56dc38a202 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/update-helpers.ts @@ -0,0 +1,75 @@ +import type { Resources } from "../../platform/resources" +import { Option } from "effect" +import { AsyncData, type Update } from "foldkit" +import { type PageContext, PageOutMessage, replaceParams, withParams } from "../contract" +import { FetchNextPage } from "./command" +import { nextCursor } from "./fetch" +import { canExtend } from "../../data/slot" +import { boundsOf } from "./load" +import type { Message } from "./message" +import type { Model, SessionRow } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +export const navigate = (model: Model, context: PageContext, updates: Record): UpdateReturn => ({ + model, + outMessage: replaceParams(context.location, updates), +}) + +/** `setRange`: remember it for pages opened without one, then put it in the hash. */ +export const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +/** The refresh spinner stops once nothing the refresh started is still loading. */ +export const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + return model.isRefreshing && !isLoading(model.sessions.data) && !isLoading(model.facets.data) + ? { ...model, isRefreshing: false } + : model +} + +/** `fetchNextPage`: the cursor after the last loaded page. */ +export const loadMore = (model: Model, context: PageContext): UpdateReturn => { + const maybeNext = Option.flatMap(AsyncData.getData(model.sessions.data), (data) => + Option.fromNullishOr(data.hasNextPage ? nextCursor(data.pages[data.pages.length - 1] ?? []) : undefined), + ) + const maybeBounds = boundsOf(model, context) + if (model.isFetchingNextPage || !canExtend(model.sessions) || model.sessions.key === null || Option.isNone(maybeNext) || Option.isNone(maybeBounds)) + return { model } + return { + model: { ...model, isFetchingNextPage: true }, + commands: [ + FetchNextPage({ + key: model.sessions.key, + generation: model.pageGeneration, + search: context.location.search, + ...maybeBounds.value, + apiBase: context.env.apiBase, + cursorStartTime: maybeNext.value.startTime, + cursorSessionId: maybeNext.value.sessionId, + pages: Option.match(AsyncData.getData(model.sessions.data), { onNone: () => [], onSome: (data) => data.pages }), + }), + ], + } +} + +/** `fetchNextPage` landed: the page joins the list if it is still the same query. */ +export const appendPage = (model: Model, key: string, page: ReadonlyArray): Model => { + if (model.sessions.key !== key) return model + return Option.match(AsyncData.getData(model.sessions.data), { + onNone: () => ({ ...model, isFetchingNextPage: false }), + onSome: (current) => ({ + ...model, + isFetchingNextPage: false, + sessions: { + ...model.sessions, + key, + data: AsyncData.Success({ data: { pages: [...current.pages, page], hasNextPage: nextCursor(page) !== undefined } }), + isPlaceholder: false, + }, + }), + }) +} diff --git a/apps/local-ui-foldkit/src/page/sessions/update.ts b/apps/local-ui-foldkit/src/page/sessions/update.ts new file mode 100644 index 0000000000..3739dba8d8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/update.ts @@ -0,0 +1,132 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Option, Result } from "effect" +import { AsyncData, Command } from "foldkit" +import { accept, acceptCached, idle } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage } from "../contract" +import { updateCopy } from "../kit/copies" +import { ReadClock, ReadRefreshClock, WaitSearchDebounce } from "./command" +import { filtersOf } from "./fetch" +import { fetchAfterCache, load } from "./load" +import { Message } from "./message" +import type { Model } from "./model" +import { appendPage, loadMore, navigate, setRange, settleRefresh, type UpdateReturn } from "./update-helpers" + +export type { UpdateReturn } from "./update-helpers" + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +const SIDEBAR_FILTERS = { service: null, browser: null, device: null, errors: null } + +export const init = (context: PageContext): Readonly<{ model: Model; commands: ReadonlyArray> }> => { + const q = queryOf(context.location).get("q") ?? "" + return { + model: { + _tag: "SessionsPage", + maybeAnchorMs: Option.none(), + nowMs: 0, + sessions: idle(), + facets: idle(), + isFetchingNextPage: false, + pageGeneration: 0, + sessionsUpdatedAtMs: 0, + search: { text: q, version: 0, sent: q }, + sections: FilterSections.init(), + isRefreshing: false, + copies: {}, + }, + commands: [ReadClock(), Command.mapMessage(FilterSections.ReadRemembered(), toSections)], + } +} + +/** The hash changed: keep the search input in sync unless it is the page's own echo, then load. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => { + const q = queryOf(context.location).get("q") ?? "" + const search = q === model.search.sent ? model.search : { ...model.search, text: q, sent: q } + return load({ ...model, search }, context) +} + +const typed = (model: Model, text: string): UpdateReturn => { + const version = model.search.version + 1 + return { model: { ...model, search: { ...model.search, text, version } }, commands: [WaitSearchDebounce({ version })] } +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => { + const query = queryOf(context.location) + return Message.match(message, { + CompletedReadClock: ({ nowMs }) => load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + SucceededFetchSessions: ({ key, data, fetchedAtMs }) => { + const isCurrent = model.sessions.key === key + const sessions = accept(model.sessions, key, Result.succeed(data), fetchedAtMs) + const sessionsUpdatedAtMs = isCurrent ? fetchedAtMs : model.sessionsUpdatedAtMs + const next = { model: settleRefresh({ ...model, sessions, sessionsUpdatedAtMs }) } + // An empty list renders `SignalEmptyState`, which reads the shared signal presence. + return isCurrent && data.pages.every((page) => page.length === 0) + ? { ...next, outMessage: PageOutMessage.RequestedSignalPresence() } + : next + }, + FailedFetchSessions: ({ key, failure }) => ({ + model: settleRefresh({ ...model, sessions: accept(model.sessions, key, Result.fail(failure)) }), + }), + ClickedLoadMore: () => loadMore(model, context), + SucceededFetchNextPage: ({ key, generation, page }) => + generation === model.pageGeneration ? { model: appendPage(model, key, page) } : { model }, + FailedFetchNextPage: ({ key, generation, failure }) => + generation !== model.pageGeneration + ? { model } + : { model: { ...model, isFetchingNextPage: false, sessions: accept(model.sessions, key, Result.fail(failure)) } }, + SucceededFetchFacets: ({ key, facets, fetchedAtMs }) => ({ + model: settleRefresh({ ...model, facets: accept(model.facets, key, Result.succeed(facets), fetchedAtMs) }), + }), + FoundCachedSessions: ({ key, cached }) => { + if (model.sessions.key !== key) return { model } + const next = { ...model, sessions: acceptCached(model.sessions, key, cached, cached.isFresh), sessionsUpdatedAtMs: cached.updatedAtMs } + const step = { model: settleRefresh(next), commands: fetchAfterCache(next, context, "sessions", cached.isFresh) } + // A cached empty list renders `SignalEmptyState` as a fetched one does. + const isEmpty = Option.exists(AsyncData.getData(next.sessions.data), (data) => data.pages.every((page) => page.length === 0)) + return isEmpty ? { ...step, outMessage: PageOutMessage.RequestedSignalPresence() } : step + }, + FoundCachedFacets: ({ key, cached }) => { + if (model.facets.key !== key) return { model } + const next = { ...model, facets: acceptCached(model.facets, key, cached, cached.isFresh) } + return { model: settleRefresh(next), commands: fetchAfterCache(next, context, "facets", cached.isFresh) } + }, + FailedFetchFacets: ({ key, failure }) => ({ + model: settleRefresh({ ...model, facets: accept(model.facets, key, Result.fail(failure)) }), + }), + ClickedOpenFilters: () => ({ model, outMessage: PageOutMessage.RequestedFilterDrawer() }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ChangedSearch: ({ text }) => typed(model, text), + ClickedClearSearch: () => typed(model, ""), + ElapsedSearchDebounce: ({ version }) => { + if (version !== model.search.version) return { model } + const trimmed = model.search.text.trim() + return navigate({ ...model, search: { ...model.search, sent: trimmed } }, context, { q: trimmed === "" ? null : trimmed }) + }, + ToggledErrorsOnly: () => navigate(model, context, { errors: filtersOf(query).errorsOnly === true ? null : "1" }), + ToggledFacetOption: ({ param, name }) => navigate(model, context, { [param]: query.get(param) === name ? null : name }), + ClickedClearFilters: () => navigate(model, context, SIDEBAR_FILTERS), + ClickedClearAllFilters: () => navigate(model, context, { ...SIDEBAR_FILTERS, q: null }), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + GotFilterSectionsMessage: ({ message: child }) => { + const next = FilterSections.update(model.sections, child) + return { model: { ...model, sections: next.model }, commands: Command.mapMessages(next.commands ?? [], toSections) } + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) +} diff --git a/apps/local-ui-foldkit/src/page/sessions/view-card.ts b/apps/local-ui-foldkit/src/page/sessions/view-card.ts new file mode 100644 index 0000000000..b6ac670efc --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/view-card.ts @@ -0,0 +1,135 @@ +import { formatUtcTitle } from "@maple/local-ui/lib/time" +import { formatDateTime, type TimeFormat } from "../kit/local-time" +import { isLocalSessionLive, localSessionDurationMs } from "@maple/local-ui/lib/session-liveness" +import { shortId } from "@maple/ui/lib/ids" +import { initialsFrom } from "@maple/ui/lib/initials" +import { gradientFor, hostFromUrl, isMobileDevice } from "@maple/ui/lib/replay" +import { formatSessionDuration } from "@maple/ui/lib/replay-format" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon, type IconName } from "@maple/web-foldkit/ui/icon" +import { statusDot } from "@maple/web-foldkit/ui/status-dot" +import { DateTime } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { SessionRow } from "./model" + +// `SessionCard` (apps/local-ui/src/views/sessions-list-view.tsx). + +const stat = (h: HtmlBuilder, name: IconName, value: number, title: string): Html => + h.span( + [h.Class("inline-flex items-center gap-1 tabular-nums"), h.Title(title)], + [h.span([h.Class("opacity-60")], [icon(h, name, { className: "size-3.5" })]), String(value)], + ) + +const playGlyph = (h: HtmlBuilder): Html => + h.svg( + [h.Attribute("viewBox", "0 0 24 24"), h.Attribute("class", "size-3.5 translate-x-px fill-current"), h.Attribute("aria-hidden", "true")], + [h.path([h.Attribute("d", "M8 5v14l11-7z")])], + ) + +const identity = (h: HtmlBuilder, session: SessionRow, label: string, isActive: boolean): Html => + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.span([h.Class("max-w-[16rem] truncate text-sm font-medium")], [label]), + statusDot(h, { tone: isActive ? "ok" : "neutral", attributes: [h.Title(isActive ? "active" : "ended")] }), + h.span( + [h.Class("font-mono text-xs text-muted-foreground")], + [shortId(session.sessionId, "session", { length: 8 }), " ·", " ", formatSessionDuration(localSessionDurationMs(session))], + ), + ], + ), + h.div( + [h.Class("mt-1 flex items-center gap-3 text-xs text-muted-foreground")], + [ + h.span( + [h.Class("flex min-w-0 items-center gap-1.5")], + [ + icon(h, "GlobeIcon", { className: "size-3.5 shrink-0 opacity-60" }), + h.span([h.Class("max-w-[18rem] truncate")], [hostFromUrl(session.urlInitial)]), + ], + ), + h.span( + [h.Class("hidden items-center gap-1.5 sm:flex")], + [ + icon(h, isMobileDevice(session.deviceType) ? "MobileIcon" : "ComputerIcon", { className: "size-3.5 shrink-0 opacity-60" }), + h.span( + [h.Class("truncate")], + [session.browserName || "Unknown", ...(session.osName ? [` · ${session.osName}`] : [])], + ), + ], + ), + ], + ), + ], + ) + +const counts = (h: HtmlBuilder, session: SessionRow): Html => + h.div( + [h.Class("flex shrink-0 items-center gap-2.5 text-xs text-muted-foreground")], + [ + stat(h, "PulseIcon", session.clickCount, "clicks"), + stat(h, "EyeIcon", session.pageViews || 1, "page views"), + ...(session.traceCount > 0 + ? [ + badge(h, { pill: true, className: "bg-primary/10 tabular-nums text-primary" }, [ + String(session.traceCount), + " trace", + session.traceCount === 1 ? "" : "s", + ]), + ] + : []), + ...(session.errorCount > 0 + ? [ + badge(h, { pill: true, className: "bg-severity-error/10 tabular-nums text-severity-error" }, [ + icon(h, "CircleWarningIcon", { className: "size-3" }), + String(session.errorCount), + ]), + ] + : []), + ], + ) + +export const sessionCard = (h: HtmlBuilder, session: SessionRow, href: string, nowMs: number, timeFormat: TimeFormat): Html => { + const label = session.userName || session.userEmail || session.userId || "Anonymous" + const isActive = isLocalSessionLive(session, nowMs) + return h.a( + [ + h.Href(href), + h.Class( + "group flex w-full items-center gap-4 rounded-xl border border-border bg-card px-4 py-3 text-left transition-all hover:-translate-y-px hover:border-primary/40 hover:bg-accent/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + ), + ], + [ + h.div( + [ + h.Class( + `grid size-10 shrink-0 place-items-center rounded-full bg-gradient-to-br ${gradientFor(session.sessionId)} text-sm font-semibold text-white shadow-sm`, + ), + ], + [initialsFrom(label)], + ), + identity(h, session, label, isActive), + counts(h, session), + h.div( + [h.Class("flex shrink-0 items-center gap-3")], + [ + h.span( + [ + h.Class("inline-flex items-center gap-1.5 whitespace-nowrap text-sm text-muted-foreground"), + h.Title(`${formatDateTime(timeFormat, session.startTime)} (${formatUtcTitle(session.startTime)})`), + ], + [icon(h, "ClockIcon", { className: "size-3.5 opacity-60" }), formatRelativeFrom(DateTime.toEpochMillis(session.startTime), nowMs)], + ), + h.span( + [h.Class("grid size-7 place-items-center rounded-full bg-primary/10 text-primary opacity-0 transition-opacity group-hover:opacity-100")], + [playGlyph(h)], + ), + ], + ), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/sessions/view-sidebar.ts b/apps/local-ui-foldkit/src/page/sessions/view-sidebar.ts new file mode 100644 index 0000000000..e5037d11e1 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/view-sidebar.ts @@ -0,0 +1,72 @@ +import { getServiceColor } from "@maple/ui/lib/colors" +import { filterSection, singleCheckboxFilter } from "@maple/web-foldkit/ui/filters/filter-section" +import type * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { filterSidebarBody, filterSidebarFrame, filterSidebarHeader } from "@maple/web-foldkit/ui/filters/filter-sidebar" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { SessionFilters } from "./fetch" +import { type FacetParam, Message } from "./message" +import type { FacetOption, Model, SessionFacets } from "./model" + +// The filter rail of `SessionsListView` (apps/local-ui/src/views/sessions-list-view.tsx). + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +/** `placeholderData: previous ?? EMPTY_FACETS`: a first load counts zero, a failure shows nothing. */ +const EMPTY_FACETS: SessionFacets = { service: [], browser: [], device: [], errorCount: 0 } + +/** `withSelected`: re-inject a selected value the server-side facet branch excluded. */ +const withSelected = (options: ReadonlyArray, selected?: string): ReadonlyArray => + selected && !options.some((option) => option.name === selected) ? [{ name: selected, count: 0 }, ...options] : options + +const facet = ( + h: HtmlBuilder, + model: Model, + title: string, + param: FacetParam, + options: ReadonlyArray, + selected: string | undefined, + extra: Readonly<{ isSearchable?: boolean; colorOf?: (name: string) => string }> = {}, +): Html | null => + filterSection(h, { + title, + options, + selected: selected ? [selected] : [], + isSearchable: extra.isSearchable ?? false, + colorOf: extra.colorOf, + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param, name }), + }) + +export const sidebar = (h: HtmlBuilder, model: Model, filters: SessionFilters, hasActiveFilters: boolean): Html => { + const facets = AsyncData.isFailure(model.facets.data) + ? null + : Option.getOrElse(AsyncData.getData(model.facets.data), () => EMPTY_FACETS) + const isFetching = AsyncData.isLoading(model.facets.data) || AsyncData.isRefreshing(model.facets.data) + const sections = [ + singleCheckboxFilter(h, { + title: "Has errors", + isChecked: filters.errorsOnly === true, + onToggle: Message.ToggledErrorsOnly(), + count: facets?.errorCount, + }), + facet(h, model, "Service", "service", facets ? withSelected(facets.service, filters.service) : [], filters.service, { + isSearchable: true, + colorOf: getServiceColor, + }), + facet(h, model, "Browser", "browser", facets ? withSelected(facets.browser, filters.browser) : [], filters.browser, { + isSearchable: true, + }), + facet(h, model, "Device", "device", facets ? withSelected(facets.device, filters.device) : [], filters.device), + ] + return filterSidebarFrame(h, { className: "w-56 shrink-0 px-4", isWaiting: isFetching }, [ + filterSidebarHeader(h, hasActiveFilters ? { onClear: Message.ClickedClearFilters() } : {}), + ...filterSidebarBody( + h, + "sessions-filters", + sections.filter((section) => section !== null), + ), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/sessions/view.ts b/apps/local-ui-foldkit/src/page/sessions/view.ts new file mode 100644 index 0000000000..2abf02ebb4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/sessions/view.ts @@ -0,0 +1,100 @@ +import { isLocalSessionLive } from "@maple/local-ui/lib/session-liveness" +import { cn } from "@maple/ui/lib/utils" +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import { listFooter } from "@maple/web-foldkit/ui/list-footer" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { hrefFor, queryOf } from "../../route" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { pageShell } from "../kit/page-shell" +import { signalEmptyState } from "../kit/signal-empty-state" +import { refreshButton, timeRangeSelect, toolbar, toolbarSearch, toolbarStat, toolbarStats } from "../kit/toolbar" +import { errorState, listSkeleton } from "../kit/view-states" +import { filtersOf } from "./fetch" +import { Message } from "./message" +import type { Model } from "./model" +import { sessionCard } from "./view-card" +import { sidebar } from "./view-sidebar" + +// `SessionsListView` (apps/local-ui/src/views/sessions-list-view.tsx). + +const toCopy = (id: string, message: CopyButton.Message) => Message.GotCopyMessage({ id, message }) + +export const view = Submodel.defineView((model, inputs, h) => { + const { context } = inputs + const query = queryOf(context.location) + const filters = filtersOf(query) + const range = rangeOf(context) + const activeFilterCount = [filters.service, filters.browser, filters.device, filters.errorsOnly].filter(Boolean).length + const hasActiveFilters = activeFilterCount > 0 + const maybeData = AsyncData.getData(model.sessions.data) + const sessions = Option.match(maybeData, { onNone: () => [], onSome: (data) => data.pages.flat() }) + const hasNextPage = Option.exists(maybeData, (data) => data.hasNextPage) + const content = (): Html => { + if (isPending(model.sessions)) return listSkeleton(h, { variant: "card", rows: 6 }) + const failure = failureWithoutData(model.sessions) + if (Option.isSome(failure)) + return errorState(h, { label: "sessions", failure: failure.value, onRetry: Message.ClickedRefresh() }) + if (sessions.length === 0) + return signalEmptyState(h, { + signal: "sessions", + isFiltered: hasActiveFilters || filters.search !== undefined, + onClearFilters: Message.ClickedClearAllFilters(), + range, + onWidenRange: Message.ClickedWidenRange(), + maybePresence: inputs.presenceOf("sessions"), + nowMs: inputs.presenceNowMs, + guide: { id: "sessions-empty", endpoint: context.env.otlpEndpoint, copies: model.copies, toCopyMessage: toCopy }, + }) + const footer = listFooter(h, { + hasMore: hasNextPage, + isLoading: model.isFetchingNextPage, + onLoadMore: Message.ClickedLoadMore(), + className: "pb-0", + }) + return h.div( + [h.Class(cn("p-4", model.sessions.isPlaceholder && "opacity-60 transition-opacity"))], + [ + h.div( + [h.Class("space-y-2")], + sessions.map((session) => + sessionCard(h, session, hrefFor(`/sessions/${encodeURIComponent(session.sessionId)}`, query), model.nowMs, context.timeFormat), + ), + ), + ...(footer === null ? [] : [footer]), + ], + ) + } + const bar = toolbar(h, [ + toolbarSearch(h, { + id: "sessions-search", + value: model.search.text, + placeholder: "Search by URL…", + onInput: (text) => Message.ChangedSearch({ text }), + onClear: Message.ClickedClearSearch(), + className: "min-w-48 flex-1", + }), + toolbarStats( + h, + [ + toolbarStat(h, { value: sessions.length, label: hasNextPage ? "sessions+" : "sessions" }), + toolbarStat(h, { + value: sessions.filter((session) => isLocalSessionLive(session, model.nowMs)).length, + label: "active", + hasDot: true, + }), + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.sessionsUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + }), + timeRangeSelect(h, range, (next) => Message.ChangedRange({ range: next })), + ], + "shrink-0", + ), + ]) + return pageShell(h, { sidebar: sidebar(h, model, filters, hasActiveFilters), toolbar: bar, activeFilterCount, onOpenFilters: Message.ClickedOpenFilters() }, [content()]) +}) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/cache.ts b/apps/local-ui-foldkit/src/page/trace-detail/cache.ts new file mode 100644 index 0000000000..85e9addece --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/cache.ts @@ -0,0 +1,40 @@ +import { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import { Option, Schema } from "effect" +import type { CacheValue, QueryKey } from "../../data/cache-store" + +// `useLocalTraceDetail`'s key and data (`buildTraceDetail`'s `TraceDetail`) in the result cache. + +/** `["local", "trace", traceId]`. */ +export const traceCacheKey = (traceId: string): QueryKey => ["local", "trace", traceId] + +/** An unknown trace caches as an empty detail, as legacy's hook returns it. */ +export const traceToCached = (maybeTrace: Option.Option) => + Option.match(maybeTrace, { + onNone: () => ({ spans: [], rootSpans: [], totalDurationMs: 0, services: [], traceStartTime: "" }), + onSome: (trace) => ({ + spans: trace.spans, + rootSpans: trace.rootSpans, + totalDurationMs: trace.totalDurationMs, + services: [...new Set(trace.spans.map((span) => span.serviceName).filter(Boolean))], + traceStartTime: trace.traceStartTime ?? "", + }), + }) + +const decodeTrace = Schema.decodeUnknownOption(Schema.toType(TraceData)) +const CachedDetail = Schema.Struct({ + spans: Schema.Array(Schema.Unknown), + rootSpans: Schema.Array(Schema.Unknown), + totalDurationMs: Schema.Number, + traceStartTime: Schema.UndefinedOr(Schema.String), +}) +const isCachedDetail = Schema.is(CachedDetail) + +/** None when the value is not a detail; Some(None) for a cached empty (unknown) trace. */ +export const traceFromCached = + (traceId: string) => + (value: CacheValue): Option.Option> => { + if (!isCachedDetail(value)) return Option.none() + if (value.spans.length === 0) return Option.some(Option.none()) + const { spans, rootSpans, totalDurationMs, traceStartTime } = value + return Option.map(decodeTrace({ traceId, spans, rootSpans, totalDurationMs, traceStartTime }), Option.some) + } diff --git a/apps/local-ui-foldkit/src/page/trace-detail/command.ts b/apps/local-ui-foldkit/src/page/trace-detail/command.ts new file mode 100644 index 0000000000..a030261db0 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/command.ts @@ -0,0 +1,116 @@ +import type { CacheValue } from "../../data/cache-store" +import { LocalLog, logKey } from "../kit/log-sheet/model" +import { SpanAttrs, type SpanLog } from "./panel-model" +import { SpanDetailRequest, SpanLogsRequest } from "./requests" +import { readCached, recordCached, cachedAs, withCache } from "../../data/cached" +import { traceCacheKey, traceFromCached, traceToCached } from "./cache" +import { Clock, Duration, Effect, Option, Queue, Schema, Stream } from "effect" +import { Command, Mount } from "foldkit" +import { fetchSpanDetail, fetchSpanLogs, fetchTrace } from "./fetch" +import { Message } from "./message" + +export const FetchTrace = Command.define("FetchLocalTraceDetail", { + args: { key: Schema.String, traceId: Schema.String, apiBase: Schema.String, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTrace, Message.FailedFetchTrace, Message.FoundCachedTrace], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, traceId, apiBase, readsCache }) => + Effect.gen(function* () { + const cacheKey = traceCacheKey(traceId) + const maybeCached = readsCache ? yield* readCached(cacheKey, traceFromCached(traceId)) : Option.none() + if (Option.isSome(maybeCached)) return Message.FoundCachedTrace({ key, cached: maybeCached.value }) + const trace = yield* fetchTrace(traceId, apiBase) + const fetchedAtMs = yield* Clock.currentTimeMillis + yield* recordCached(cacheKey, traceToCached(trace), fetchedAtMs) + return Message.SucceededFetchTrace({ key, trace, fetchedAtMs }) + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTrace({ key, failure })))), +}) + +export const FetchSpanDetail = Command.define("FetchLocalSpanDetail", { + args: { ...SpanDetailRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchSpanDetail, Message.FailedFetchSpanDetail, Message.FoundCachedSpanDetail], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "span-detail", request.traceId, request.spanId], + readsCache, + fromCached: cachedAs(SpanAttrs), + toCached: (detail: SpanAttrs) => detail, + fetch: fetchSpanDetail(request.traceId, request.spanId, request.apiBase), + found: (cached) => Message.FoundCachedSpanDetail({ request, cached }), + succeeded: (detail, fetchedAtMs) => Message.SucceededFetchSpanDetail({ key: request.key, detail, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchSpanDetail({ key: request.key, failure })))), +}) + +/** Legacy caches the rows as `LocalLog`s; the panel keeps each with its `logKey`. */ +const spanLogsFromCached = (value: CacheValue): Option.Option> => + Option.map(cachedAs(Schema.Array(LocalLog))(value), (logs) => logs.map((log) => ({ ...log, key: logKey(log) }))) + +export const FetchSpanLogs = Command.define("FetchLocalSpanLogs", { + args: { ...SpanLogsRequest.fields, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchSpanLogs, Message.FailedFetchSpanLogs, Message.FoundCachedSpanLogs], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ readsCache, ...request }) => + withCache({ + key: ["local", "span-logs", request.traceId, request.spanId, request.spanStartTime, request.spanDurationMs], + readsCache, + fromCached: spanLogsFromCached, + toCached: (logs: ReadonlyArray) => logs.map(({ key: _key, ...log }) => log), + fetch: Effect.map( + fetchSpanLogs(request.traceId, request.spanId, request.spanStartTime, request.spanDurationMs, request.apiBase), + (logs): ReadonlyArray => logs, + ), + found: (cached) => Message.FoundCachedSpanLogs({ request, cached }), + succeeded: (logs, fetchedAtMs) => Message.SucceededFetchSpanLogs({ key: request.key, logs, fetchedAtMs }), + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchSpanLogs({ key: request.key, failure })))), +}) + +/** `useCopy`: the clipboard write behind every copy control of the span panel. */ +export const WriteClipboard = Command.define("WriteLocalSpanClipboard", { + args: { id: Schema.String, text: Schema.String, generation: Schema.Number }, + messages: [Message.CompletedWriteClipboard], + execute: ({ id, text, generation }) => + Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + Effect.map((isOk) => Message.CompletedWriteClipboard({ id, generation, isOk })), + ), +}) + +/** + * The pointer's viewport position over the page, at most once per frame (the timeline's + * `onMouseMove` writes the tooltip position from a rAF). + */ +export const TrackPointer = Mount.defineStream("TrackLocalTracePointer", { + messages: [Message.MovedPointer], + execute: ({ element }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const state = { frame: 0 } + const onMove = (event: Event) => { + if (!(event instanceof MouseEvent)) return + cancelAnimationFrame(state.frame) + const { clientX, clientY } = event + state.frame = requestAnimationFrame(() => + Queue.offerUnsafe(queue, Message.MovedPointer({ x: clientX, y: clientY })), + ) + } + element.addEventListener("mousemove", onMove) + return () => { + cancelAnimationFrame(state.frame) + element.removeEventListener("mousemove", onMove) + } + }), + (cleanup) => Effect.sync(cleanup), + ).pipe(Effect.flatMap(() => Effect.never)), + ), +}) + +/** The copied or failed state reverts to idle after this long. */ +const COPY_TIMEOUT = Duration.millis(2000) + +export const WaitCopyStatus = Command.define("WaitLocalSpanCopyStatus", { + args: { id: Schema.String, generation: Schema.Number }, + messages: [Message.ElapsedCopyStatus], + execute: ({ id, generation }) => Effect.as(Effect.sleep(COPY_TIMEOUT), Message.ElapsedCopyStatus({ id, generation })), +}) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/fetch.ts b/apps/local-ui-foldkit/src/page/trace-detail/fetch.ts new file mode 100644 index 0000000000..cdffba8336 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/fetch.ts @@ -0,0 +1,77 @@ +import { TraceId } from "@maple/domain" +import { CH } from "@maple/query-engine" +import { LOCAL_ORG_ID } from "@maple/query-engine/local" +import { logKey, normalizeLog } from "@maple/local-ui/lib/log-shape" +import { boundsForRange, parseClickHouseDateTime, toClickHouseDateTime, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import type { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import { buildTraceDetail, parseAttributes } from "@maple/ui/lib/span-tree" +import { DateTime, Effect, Option, Schema } from "effect" +import { localParams, queryFirstRow, queryRows } from "../../data/local-query" +import type { SpanAttrs, SpanLog } from "./panel-model" +import { LOG_LIMIT } from "./panel-model" + +// The queries of `useLocalTraceDetail`, `useLocalSpanDetail` and `useLocalSpanLogs`. + +const decodeTraceId = Schema.decodeUnknownOption(TraceId) + +/** + * The full span hierarchy, shaped like `TraceViewTabs` reads it; none for a trace with no spans. + * `narrowByTime` stays off: the trace-id lookup is cheaper than threading time bounds. + */ +export const fetchTrace = (traceId: string, apiBase: string) => + Effect.map( + queryRows(CH.compile(CH.spanHierarchyQuery({ traceId }), { orgId: LOCAL_ORG_ID }), apiBase), + (rows): Option.Option => { + const detail = buildTraceDetail(rows.map((row) => ({ ...row, startTime: DateTime.formatIso(row.startTime) }))) + if (detail.spans.length === 0) return Option.none() + return Option.map(decodeTraceId(traceId), (id) => ({ + traceId: id, + spans: detail.spans, + rootSpans: detail.rootSpans, + totalDurationMs: detail.totalDurationMs, + traceStartTime: detail.traceStartTime, + })) + }, + ) + +/** One span's full attribute maps (the hierarchy trims them to the keys the tree renders). */ +export const fetchSpanDetail = (traceId: string, spanId: string, apiBase: string) => + Effect.map( + queryFirstRow(CH.compile(CH.spanDetailQuery({ traceId, spanId }), { orgId: LOCAL_ORG_ID }), apiBase), + (maybeRow): SpanAttrs => + Option.match(maybeRow, { + onNone: () => null, + onSome: (row) => ({ + spanAttributes: parseAttributes(row.spanAttributes), + resourceAttributes: parseAttributes(row.resourceAttributes), + }), + }), + ) + +const HOUR_MS = 60 * 60 * 1000 + +/** Logs emitted within one span, newest first, bounded to the span's own window ±1h. */ +export const fetchSpanLogs = ( + traceId: string, + spanId: string, + spanStartTime: string, + spanDurationMs: number, + apiBase: string, +) => { + const startMs = parseClickHouseDateTime(spanStartTime) + const bounds = + startMs === null + ? boundsForRange(WIDEST_RANGE) + : { + startTime: toClickHouseDateTime(startMs - HOUR_MS), + endTime: toClickHouseDateTime(startMs + Math.max(0, spanDurationMs) + HOUR_MS), + } + return Effect.map( + queryRows(CH.compile(CH.logsListQuery({ traceId, spanId, limit: LOG_LIMIT }), localParams(bounds)), apiBase), + (rows) => + rows.map((row): SpanLog => { + const log = normalizeLog(row) + return { ...log, key: logKey(log) } + }), + ) +} diff --git a/apps/local-ui-foldkit/src/page/trace-detail/index.ts b/apps/local-ui-foldkit/src/page/trace-detail/index.ts new file mode 100644 index 0000000000..be9c8f8082 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/index.ts @@ -0,0 +1,7 @@ +// `TraceDetailView` (apps/local-ui): the trace's anatomy, its Waterfall / Timeline / Flow tabs and +// the span panel, all following the hash's `view` and `spanId`. +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/trace-detail/lanes.ts b/apps/local-ui-foldkit/src/page/trace-detail/lanes.ts new file mode 100644 index 0000000000..9ecf154113 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/lanes.ts @@ -0,0 +1,14 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchSpanDetail, FetchSpanLogs, FetchTrace } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`): the trace and the open span's. */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchTrace.name, model.trace, (key) => FetchTrace.Interrupt({ key }, outcomeOf)), + ...(model.panel === null + ? [] + : [ + ...slotLane(FetchSpanDetail.name, model.panel.detail, (key) => FetchSpanDetail.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchSpanLogs.name, model.panel.logs, (key) => FetchSpanLogs.Interrupt({ key }, outcomeOf)), + ]), +] diff --git a/apps/local-ui-foldkit/src/page/trace-detail/message.ts b/apps/local-ui-foldkit/src/page/trace-detail/message.ts new file mode 100644 index 0000000000..8a619b0356 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/message.ts @@ -0,0 +1,48 @@ +import * as Waterfall from "@maple/web-foldkit/page/traces/detail/waterfall" +import * as Timeline from "@maple/web-foldkit/page/traces/detail/timeline" +import * as SpanPanel from "@maple/web-foldkit/page/traces/detail/span-panel/message" +import { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import * as TraceFlow from "@maple/web-foldkit/service-map/trace-flow/trace-flow" +import * as AttributesPanel from "@maple/web-foldkit/ui/attributes/attributes-panel" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { Message as LogSheetMessage } from "../kit/log-sheet/message" +import { LocalLog } from "../kit/log-sheet/model" +import { SpanDetailRequest, SpanLogsRequest } from "./requests" +import { SpanAttrs, SpanLog } from "./panel-model" + +export const Message = defineMessageUnion({ + SucceededFetchTrace: { key: Schema.String, trace: Schema.Option(TraceData), fetchedAtMs: Schema.Number }, + /** The result cache held this trace before the fetch ran. */ + FoundCachedTrace: { key: Schema.String, cached: CachedOf(Schema.Option(TraceData)) }, + FailedFetchTrace: { key: Schema.String, failure: LoadFailure }, + ClickedBack: {}, + ClickedRefresh: {}, + PressedEscape: {}, + /** The pointer over the page, for the timeline's span tooltip (`handleHover`'s position). */ + MovedPointer: { x: Schema.Number, y: Schema.Number }, + GotTabsMessage: { message: Tabs.Message }, + GotTimelineMessage: { message: Timeline.Message }, + GotWaterfallMessage: { message: Waterfall.Message }, + GotFlowMessage: { message: TraceFlow.Message }, + SucceededFetchSpanDetail: { key: Schema.String, detail: SpanAttrs, fetchedAtMs: Schema.Number }, + FailedFetchSpanDetail: { key: Schema.String, failure: LoadFailure }, + /** The result cache answered first; `request` is the read the fetch then repeats. */ + FoundCachedSpanDetail: { request: SpanDetailRequest, cached: CachedOf(SpanAttrs) }, + SucceededFetchSpanLogs: { key: Schema.String, logs: Schema.Array(SpanLog), fetchedAtMs: Schema.Number }, + FailedFetchSpanLogs: { key: Schema.String, failure: LoadFailure }, + FoundCachedSpanLogs: { request: SpanLogsRequest, cached: CachedOf(Schema.Array(SpanLog)) }, + /** The panel's header, copy and error parts (the web panel's markup and Messages). */ + GotSpanPanelMessage: { message: SpanPanel.Message }, + GotPanelTabsMessage: { message: Tabs.Message }, + GotAttributesMessage: { message: AttributesPanel.Message }, + /** A row of the span's Logs tab: `LogDetailSheet` opens on it. */ + ClickedSpanLog: { log: LocalLog }, + GotLogSheetMessage: { message: LogSheetMessage }, + CompletedWriteClipboard: { id: Schema.String, generation: Schema.Number, isOk: Schema.Boolean }, + ElapsedCopyStatus: { id: Schema.String, generation: Schema.Number }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/trace-detail/model.ts b/apps/local-ui-foldkit/src/page/trace-detail/model.ts new file mode 100644 index 0000000000..c0a698e750 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/model.ts @@ -0,0 +1,36 @@ +import * as Waterfall from "@maple/web-foldkit/page/traces/detail/waterfall" +import * as Timeline from "@maple/web-foldkit/page/traces/detail/timeline" +import { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import type { FlowGraph } from "@maple/web-foldkit/service-map/trace-flow/trace-flow" +import * as TraceFlow from "@maple/web-foldkit/service-map/trace-flow/trace-flow" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { PanelModel } from "./panel-model" + +// `TraceDetailView` (apps/local-ui/src/views/trace-detail-view.tsx). + +/** The laid-out flow graph is derived from the trace once; it is plain data the canvas reads. */ +const FlowGraphData = Schema.declare( + (input: unknown): input is FlowGraph => typeof input === "object" && input !== null && "nodes" in input, +) + +export const Model = Schema.TaggedStruct("TraceDetailPage", { + traceId: Schema.String, + /** `useLocalTraceDetail`: none when the trace has no spans (or its id is not a trace id). */ + trace: SlotSchema(Schema.Option(TraceData)), + /** `dataUpdatedAt` of the trace, for the "New data" pill. */ + traceUpdatedAtMs: Schema.Number, + isRefreshing: Schema.Boolean, + /** `TraceViewTabs`: the tabs, the colour-by state and the mounted tab's Submodel. */ + tabs: Tabs.Model, + colorBy: Schema.String, + timeline: Schema.NullOr(Timeline.Model), + waterfall: Schema.NullOr(Waterfall.Model), + flow: Schema.NullOr(Schema.Struct({ graph: FlowGraphData, model: TraceFlow.Model })), + /** The span panel, mounted while the hash's `spanId` names a span of the trace. */ + panel: Schema.NullOr(PanelModel), + /** Where the span tooltip anchors (the last pointer move), none before the first. */ + pointer: Schema.NullOr(Schema.Struct({ x: Schema.Number, y: Schema.Number })), +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/trace-detail/panel-model.ts b/apps/local-ui-foldkit/src/page/trace-detail/panel-model.ts new file mode 100644 index 0000000000..8d58486084 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/panel-model.ts @@ -0,0 +1,42 @@ +import * as AttributesPanel from "@maple/web-foldkit/ui/attributes/attributes-panel" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { SpanNodeData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import { CopyState } from "@maple/web-foldkit/page/traces/detail/span-panel/model" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import * as LogSheet from "../kit/log-sheet/model" + +// apps/local-ui/src/components/span-detail-panel.tsx as Model data: the selected span, its lazily +// loaded attribute maps and logs, the Details / Logs tabs and the copy and disclosure state. + +/** `useLocalSpanDetail`: the full attribute maps; null when the point lookup found no row. */ +export const SpanAttrs = Schema.NullOr( + Schema.Struct({ + spanAttributes: Schema.Record(Schema.String, Schema.String), + resourceAttributes: Schema.Record(Schema.String, Schema.String), + }), +) +export type SpanAttrs = typeof SpanAttrs.Type + +/** A Logs-tab row: the whole `LocalLog` (the sheet opens on it) plus its `logKey`. */ +export const SpanLog = Schema.Struct({ ...LogSheet.LocalLog.fields, key: Schema.String }) +export type SpanLog = typeof SpanLog.Type + +/** Matches the query limit of `useLocalSpanLogs`; a full page means "at least". */ +export const LOG_LIMIT = 100 + +export const PanelModel = Schema.Struct({ + span: SpanNodeData, + detail: SlotSchema(SpanAttrs), + logs: SlotSchema(Schema.Array(SpanLog)), + tabs: Tabs.Model, + /** Copy status by target id (`span-name`, `duration`, `span-id`, ...). */ + copies: Schema.Record(Schema.String, CopyState), + isErrorExpanded: Schema.Boolean, + attributes: AttributesPanel.Model, + /** `SpanLogs`' own `LogDetailSheet`, once a log row was clicked. */ + maybeLogSheet: Schema.Option(LogSheet.Model), +}) +export type PanelModel = typeof PanelModel.Type + +export const PANEL_TABS = ["details", "logs"] as const diff --git a/apps/local-ui-foldkit/src/page/trace-detail/requests.ts b/apps/local-ui-foldkit/src/page/trace-detail/requests.ts new file mode 100644 index 0000000000..c0f0d32554 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/requests.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect" + +// What the span panel's reads ask for; a cached answer carries it back so the fetch can follow. + +export const SpanDetailRequest = Schema.Struct({ + key: Schema.String, + traceId: Schema.String, + spanId: Schema.String, + apiBase: Schema.String, +}) + +export const SpanLogsRequest = Schema.Struct({ + key: Schema.String, + traceId: Schema.String, + spanId: Schema.String, + spanStartTime: Schema.String, + spanDurationMs: Schema.Number, + apiBase: Schema.String, +}) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/tabs.ts b/apps/local-ui-foldkit/src/page/trace-detail/tabs.ts new file mode 100644 index 0000000000..197151e8a4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/tabs.ts @@ -0,0 +1,86 @@ +import { findSpan } from "@maple/web-foldkit/page/traces/detail/select" +import * as Timeline from "@maple/web-foldkit/page/traces/detail/timeline" +import { toSpanNode, type SpanNodeData, type TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import * as Waterfall from "@maple/web-foldkit/page/traces/detail/waterfall" +import * as TraceFlow from "@maple/web-foldkit/service-map/trace-flow/trace-flow" +import { Option } from "effect" +import { Command } from "foldkit" +import { queryOf } from "../../route" +import type { PageContext } from "../contract" +import { Message } from "./message" +import type { Model } from "./model" + +// `TraceViewTabs`: the hash's `view` picks the tab (timeline by default), `spanId` the selection. + +export type TraceView = "waterfall" | "timeline" | "flow" +export const TAB_VALUES: ReadonlyArray = ["waterfall", "timeline", "flow"] + +const isTraceView = (value: string | null): value is TraceView => TAB_VALUES.some((view) => view === value) + +export const viewOf = (context: PageContext): TraceView => { + const raw = queryOf(context.location).get("view") + return isTraceView(raw) ? raw : "timeline" +} + +/** `findSpanNode`: the hash's `spanId` in the rendered tree. */ +export const selectedSpanOf = (trace: TraceData, context: PageContext): SpanNodeData | undefined => { + const spanId = queryOf(context.location).get("spanId") || undefined + return spanId === undefined ? undefined : findSpan(trace.rootSpans, spanId) +} + +type TabStep = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +/** Base UI mounts only the active tab's panel: build its Submodel, drop the others. */ +export const enterTab = (model: Model, trace: TraceData, view: TraceView, selectedSpanId: string | null): TabStep => { + const cleared: Model = { ...model, timeline: null, waterfall: null, flow: null, tabs: { ...model.tabs, selected: view } } + if (view === "timeline") { + const next = Timeline.init({ + trace, + traceStartTime: trace.traceStartTime ?? "", + selectedSpanId, + colorBy: model.colorBy, + spanEvents: null, + }) + return { + model: { ...cleared, timeline: next.model }, + commands: Command.mapMessages(next.commands ?? [], (message) => Message.GotTimelineMessage({ message })), + } + } + if (view === "waterfall") { + const next = Waterfall.init({ trace, traceStartTime: trace.traceStartTime ?? "", selectedSpanId }) + return { + model: { ...cleared, waterfall: next.model }, + commands: Command.mapMessages(next.commands ?? [], (message) => Message.GotWaterfallMessage({ message })), + } + } + const graph = TraceFlow.buildFlowGraph({ + rootSpans: trace.rootSpans.map(toSpanNode), + totalDurationMs: trace.totalDurationMs, + services: [...new Set(trace.spans.map((span) => span.serviceName))], + }) + const flowModel = { ...TraceFlow.init(), selectedSpanId: Option.fromNullishOr(selectedSpanId) } + return { model: { ...cleared, flow: { graph, model: flowModel } } } +} + +/** The selection changed under an open tab: legacy re-renders it with the new `selectedSpanId`. */ +export const selectInTab = (model: Model, spanId: string | null): TabStep => { + if (model.timeline !== null) { + const next = Timeline.setSelectedSpanId(model.timeline, spanId) + return { + model: { ...model, timeline: next.model }, + commands: Command.mapMessages(next.commands ?? [], (message) => Message.GotTimelineMessage({ message })), + } + } + if (model.waterfall !== null) { + const next = Waterfall.setSelectedSpanId(model.waterfall, spanId) + return { + model: { ...model, waterfall: next.model }, + commands: Command.mapMessages(next.commands ?? [], (message) => Message.GotWaterfallMessage({ message })), + } + } + return model.flow === null + ? { model } + : { + model: { ...model, flow: { ...model.flow, model: { ...model.flow.model, selectedSpanId: Option.fromNullishOr(spanId) } } }, + } +} diff --git a/apps/local-ui-foldkit/src/page/trace-detail/trace-detail.scene.test.ts b/apps/local-ui-foldkit/src/page/trace-detail/trace-detail.scene.test.ts new file mode 100644 index 0000000000..49259c1b7f --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/trace-detail.scene.test.ts @@ -0,0 +1,141 @@ +import { SpanId, TraceId } from "@maple/domain" +import type { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import { Option, Schema } from "effect" +import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { FetchSpanDetail, FetchSpanLogs, FetchTrace, TrackPointer } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, locationChanged, update } from "./update" +import { view as pageView } from "./view" + +const TRACE_ID = "0eab694ff33721d5a836ce7444afadcc" +const SPAN_ID = "1111111111111111" +const NOW = Date.parse("2026-10-01T12:00:30Z") + +const contextAt = (search: string): PageContext => ({ + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + location: { path: `/traces/${TRACE_ID}`, search }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +}) +const context = contextAt("") +const inputs: PageViewInputs = { + context, + presenceOf: () => Option.none(), + presenceNowMs: NOW, + clockMs: 0, + hasNewDataSince: () => false, +} +const view = withViewInputs(pageView, inputs)() +const pageUpdate = (model: Model, message: Message) => update(model, message, context) + +const span = { + traceId: Schema.decodeSync(TraceId)(TRACE_ID), + spanId: Schema.decodeSync(SpanId)(SPAN_ID), + parentSpanId: "", + spanName: "GET /", + serviceName: "storefront", + spanKind: "Server", + durationMs: 12, + startTime: "2026-10-01T11:59:00.000Z", + statusCode: "Ok", + statusMessage: "", + spanAttributes: {}, + resourceAttributes: {}, +} + +const trace: TraceData = { + traceId: span.traceId, + spans: [span], + rootSpans: [{ ...span, children: [], depth: 0 }], + totalDurationMs: 12, + traceStartTime: span.startTime, +} + +const entered = init(context, TRACE_ID) +const answered = (model: Model, answer: Option.Option, ctx = context) => + update(model, Message.SucceededFetchTrace({ key: model.trace.key ?? "", trace: answer, fetchedAtMs: NOW }), ctx) + +describe("local trace detail", () => { + test("entry asks for the span hierarchy of the trace", () => { + expect(entered.commands.map(({ name }) => name)).toEqual([FetchTrace.name]) + }) + + test("an answer for a superseded key is dropped", () => { + const stale = pageUpdate(entered.model, Message.SucceededFetchTrace({ key: "stale", trace: Option.some(trace), fetchedAtMs: NOW })) + expect(stale.model.trace).toEqual(entered.model.trace) + expect(stale.model.timeline).toBeNull() + }) + + test("a trace without spans reads as not found", () => { + scene( + { update: pageUpdate, view }, + given(answered(entered.model, Option.none()).model), + Mount.resolve(TrackPointer, Message.MovedPointer({ x: 10, y: 10 })), + expectView(text("Trace not found")).toExist(), + ) + }) + + test("the trace mounts the timeline; a hash span opens the panel and loads its logs and attributes", () => { + const loaded = answered(entered.model, Option.some(trace)).model + expect(loaded.timeline).not.toBeNull() + expect(loaded.panel).toBeNull() + const selected = locationChanged(loaded, contextAt(`spanId=${SPAN_ID}`)) + expect(selected.model.panel?.span.spanId).toBe(SPAN_ID) + expect((selected.commands ?? []).map(({ name }) => name)).toEqual(expect.arrayContaining([FetchSpanLogs.name, FetchSpanDetail.name])) + }) + + test("Escape with a panel open drops the span from the hash", () => { + const ctx = contextAt(`spanId=${SPAN_ID}`) + const open = answered(init(ctx, TRACE_ID).model, Option.some(trace), ctx).model + const closed = update(open, Message.PressedEscape(), ctx) + expect(closed.outMessage).toEqual(PageOutMessage.RequestedNavigation({ hash: `#/traces/${TRACE_ID}`, isReplace: true })) + }) + + test("a row of the span's Logs tab opens the log sheet on that log", () => { + const ctx = contextAt(`spanId=${SPAN_ID}`) + const open = answered(init(ctx, TRACE_ID).model, Option.some(trace), ctx).model + const spanLog = { + key: "k1", + timestamp: "2026-10-01 11:59:00.004000000", + severityText: "ERROR", + severityNumber: 17, + serviceName: "storefront", + body: "card declined", + traceId: TRACE_ID, + spanId: SPAN_ID, + recordIdentity: "1", + logAttributes: {}, + resourceAttributes: {}, + } + const logsKey = open.panel?.logs.key ?? "" + const withLogs = update(open, Message.SucceededFetchSpanLogs({ key: logsKey, logs: [spanLog], fetchedAtMs: NOW }), ctx).model + const clicked = update(withLogs, Message.ClickedSpanLog({ log: spanLog }), ctx).model + const sheet = Option.getOrNull(clicked.panel?.maybeLogSheet ?? Option.none()) + expect([sheet?.dialog.isOpen, sheet?.log.body]).toEqual([true, "card declined"]) + }) + + test("a Refresh that finds the spans of a not-yet-landed trace mounts the tab and the hash's panel", () => { + const ctx = contextAt(`spanId=${SPAN_ID}`) + const empty = answered(init(ctx, TRACE_ID).model, Option.none(), ctx).model + const refreshed = update(empty, Message.ClickedRefresh(), ctx).model + const landed = answered(refreshed, Option.some(trace), ctx).model + expect([landed.timeline?.rootSpans.length, landed.panel?.span.spanId]).toEqual([1, SPAN_ID]) + }) + + test("a refetch that adds spans rebuilds the open tab on the new tree", () => { + const loaded = answered(entered.model, Option.some(trace)).model + const child = { ...span, spanId: Schema.decodeSync(SpanId)("2222222222222222"), parentSpanId: SPAN_ID, spanName: "db" } + const grown: TraceData = { + ...trace, + spans: [span, child], + rootSpans: [{ ...span, children: [{ ...child, children: [], depth: 1 }], depth: 0 }], + } + const refetched = answered(pageUpdate(loaded, Message.ClickedRefresh()).model, Option.some(grown)).model + expect(refetched.timeline?.rootSpans[0]?.children.map((node) => node.spanName)).toEqual(["db"]) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/update-panel.ts b/apps/local-ui-foldkit/src/page/trace-detail/update-panel.ts new file mode 100644 index 0000000000..22a7226f5e --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/update-panel.ts @@ -0,0 +1,159 @@ +import * as SpanPanelMessage from "@maple/web-foldkit/page/traces/detail/span-panel/message" +import type { SpanNodeData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import * as AttributesPanel from "@maple/web-foldkit/ui/attributes/attributes-panel" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { Match, Option, Record, Result } from "effect" +import { AsyncData, Command } from "foldkit" +import { accept, acceptCached, idle, keyOf } from "../../data/slot" +import * as LogSheet from "../kit/log-sheet/update" +import type { Resources } from "../../platform/resources" +import { FetchSpanDetail, FetchSpanLogs, WaitCopyStatus, WriteClipboard } from "./command" +import { Message } from "./message" +import { PANEL_TABS, type PanelModel } from "./panel-model" + +type Commands = ReadonlyArray> + +/** What a panel step asks of the page: nothing, close the panel, or select another span. */ +export type PanelOutcome = Readonly<{ _tag: "None" } | { _tag: "Close" } | { _tag: "Select"; spanId: string }> + +export type PanelReturn = Readonly<{ panel: PanelModel; commands?: Commands; outcome?: PanelOutcome }> + +const detailKey = (span: SpanNodeData) => keyOf("span-detail", { traceId: span.traceId, spanId: span.spanId }) +const logsKey = (span: SpanNodeData) => + keyOf("span-logs", { traceId: span.traceId, spanId: span.spanId, startTime: span.startTime, durationMs: span.durationMs }) + +/** The panel's reads: the logs always, the full attributes unless the span is a missing parent. */ +const fetches = (span: SpanNodeData, apiBase: string, readsCache: boolean): Commands => [ + FetchSpanLogs({ + readsCache, + key: logsKey(span), + traceId: span.traceId, + spanId: span.spanId, + spanStartTime: span.startTime, + spanDurationMs: span.durationMs, + apiBase, + }), + ...(span.isMissing === true + ? [] + : [FetchSpanDetail({ key: detailKey(span), traceId: span.traceId, spanId: span.spanId, apiBase, readsCache })]), +] + +/** `` mounting for a newly selected span. */ +export const initPanel = (span: SpanNodeData, apiBase: string): PanelReturn => ({ + panel: { + span, + detail: span.isMissing === true ? idle() : { ...idle(), key: detailKey(span), data: AsyncData.Loading() }, + logs: { ...idle(), key: logsKey(span), data: AsyncData.Loading() }, + tabs: Tabs.init({ idBase: 40, values: PANEL_TABS, selected: "details" }), + copies: {}, + isErrorExpanded: false, + attributes: AttributesPanel.init("span-attributes"), + maybeLogSheet: Option.none(), + }, + commands: fetches(span, apiBase, true), +}) + +/** A refresh (`invalidateQueries`) refetches the open panel's reads in place. */ +export const refetchPanel = (panel: PanelModel, apiBase: string): PanelReturn => ({ + panel: { + ...panel, + detail: panel.detail.key === null ? panel.detail : { ...panel.detail, data: refreshing(panel.detail.data) }, + logs: { ...panel.logs, data: refreshing(panel.logs.data) }, + }, + commands: fetches(panel.span, apiBase, false), +}) + +const refreshing = (data: AsyncData.AsyncData): AsyncData.AsyncData => + Option.getOrElse(AsyncData.revalidateOrLoad(data), () => data) + +/** Folds a log sheet step into the panel. */ +const liftSheet = (panel: PanelModel, step: LogSheet.UpdateReturn): PanelReturn => ({ + panel: { ...panel, maybeLogSheet: Option.some(step.model) }, + commands: Command.mapMessages(step.commands ?? [], (message) => Message.GotLogSheetMessage({ message })), +}) + +/** A copy's settled status, if it is still the latest copy of that target. */ +const settleCopy = (panel: PanelModel, id: string, generation: number, status: "copied" | "error" | "idle"): PanelReturn => + panel.copies[id]?.generation === generation + ? { + panel: { ...panel, copies: Record.set(panel.copies, id, { status, generation }) }, + commands: status === "idle" ? [] : [WaitCopyStatus({ id, generation })], + } + : { panel } + +/** The web panel's header, copy and error Messages, over the local panel. */ +const updateParts = (panel: PanelModel, message: SpanPanelMessage.Message): PanelReturn => + SpanPanelMessage.Message.match(message, { + ClickedClose: () => ({ panel, outcome: { _tag: "Close" } }), + ClickedParentSpan: ({ spanId }) => ({ panel, outcome: { _tag: "Select", spanId } }), + ClickedCopy: ({ id, text }) => { + const generation = (panel.copies[id]?.generation ?? 0) + 1 + const status = panel.copies[id]?.status ?? "idle" + const next = { ...panel, copies: Record.set(panel.copies, id, { status, generation }) } + return text === "" + ? settleCopy(next, id, generation, "error") + : { panel: next, commands: [WriteClipboard({ id, text, generation })] } + }, + ToggledErrorExpanded: () => ({ panel: { ...panel, isErrorExpanded: !panel.isErrorExpanded } }), + // Fetches, other copies' results, filters and the web tab strip are not the local panel's. + CompletedFetchDetail: () => ({ panel }), + CompletedFetchLogs: () => ({ panel }), + SucceededCopy: ({ id, generation }) => settleCopy(panel, id, generation, "copied"), + FailedCopy: ({ id, generation }) => settleCopy(panel, id, generation, "error"), + ElapsedCopyStatus: ({ id, generation }) => settleCopy(panel, id, generation, "idle"), + ToggledCollapsible: () => ({ panel }), + ClickedAttributeFilter: () => ({ panel }), + GotTabsMessage: () => ({ panel }), + GotInfraMessage: () => ({ panel }), + }) + +export const updatePanel = (panel: PanelModel, message: Message): PanelReturn => + Match.value(message).pipe( + Match.withReturnType(), + Match.tags({ + SucceededFetchSpanDetail: ({ key, detail, fetchedAtMs }) => + ({ panel: { ...panel, detail: accept(panel.detail, key, Result.succeed(detail), fetchedAtMs) } }), + FoundCachedSpanDetail: ({ request, cached }) => + panel.detail.key === request.key + ? { + panel: { ...panel, detail: acceptCached(panel.detail, request.key, cached, cached.isFresh) }, + commands: cached.isFresh ? [] : [FetchSpanDetail({ ...request, readsCache: false })], + } + : { panel }, + FoundCachedSpanLogs: ({ request, cached }) => + panel.logs.key === request.key + ? { + panel: { ...panel, logs: acceptCached(panel.logs, request.key, cached, cached.isFresh) }, + commands: cached.isFresh ? [] : [FetchSpanLogs({ ...request, readsCache: false })], + } + : { panel }, + FailedFetchSpanDetail: ({ key, failure }) => ({ panel: { ...panel, detail: accept(panel.detail, key, Result.fail(failure)) } }), + SucceededFetchSpanLogs: ({ key, logs, fetchedAtMs }) => + ({ panel: { ...panel, logs: accept(panel.logs, key, Result.succeed(logs), fetchedAtMs) } }), + FailedFetchSpanLogs: ({ key, failure }) => ({ panel: { ...panel, logs: accept(panel.logs, key, Result.fail(failure)) } }), + GotSpanPanelMessage: ({ message: child }) => updateParts(panel, child), + GotPanelTabsMessage: ({ message: child }) => { + const next = Tabs.update(panel.tabs, child) + return { + panel: { ...panel, tabs: next.model }, + commands: Command.mapMessages(next.commands ?? [], (tab) => Message.GotPanelTabsMessage({ message: tab })), + } + }, + GotAttributesMessage: ({ message: child }) => { + const next = AttributesPanel.update(panel.attributes, child) + return { + panel: { ...panel, attributes: next.model }, + commands: Command.mapMessages(next.commands ?? [], (inner) => Message.GotAttributesMessage({ message: inner })), + } + }, + ClickedSpanLog: ({ log }) => liftSheet(panel, LogSheet.openLog(panel.maybeLogSheet, log)), + GotLogSheetMessage: ({ message: child }) => + Option.match(panel.maybeLogSheet, { + onNone: () => ({ panel }), + onSome: (sheet) => liftSheet(panel, LogSheet.update(sheet, child)), + }), + CompletedWriteClipboard: ({ id, generation, isOk }) => settleCopy(panel, id, generation, isOk ? "copied" : "error"), + ElapsedCopyStatus: ({ id, generation }) => settleCopy(panel, id, generation, "idle"), + }), + Match.orElse(() => ({ panel })), + ) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/update.ts b/apps/local-ui-foldkit/src/page/trace-detail/update.ts new file mode 100644 index 0000000000..d5ea1b09d4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/update.ts @@ -0,0 +1,254 @@ +import * as Timeline from "@maple/web-foldkit/page/traces/detail/timeline" +import type { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import * as Waterfall from "@maple/web-foldkit/page/traces/detail/waterfall" +import * as TraceFlow from "@maple/web-foldkit/service-map/trace-flow/trace-flow" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import { Option, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import type { Cached } from "../../data/cached" +import { accept, acceptCached, idle, keyOf, request } from "../../data/slot" +import type { LoadFailure } from "../../data/local-query" +import type { Resources } from "../../platform/resources" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage, replaceParams } from "../contract" +import { FetchTrace } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { enterTab, selectedSpanOf, selectInTab, TAB_VALUES, viewOf } from "./tabs" +import { initPanel, refetchPanel, updatePanel } from "./update-panel" + +export type UpdateReturn = Update.ReturnWithOutMessage +type Commands = ReadonlyArray> + +/** `TraceViewTabs`' default colour-by. */ +const DEFAULT_COLOR_BY = "service" + +const traceKey = (traceId: string) => keyOf("trace", { traceId }) + +/** Runs `then` on the first step's Model, keeping both steps' Commands and the later OutMessage. */ +export const chain = (first: UpdateReturn, then: (model: Model) => UpdateReturn): UpdateReturn => { + const second = then(first.model) + return { + model: second.model, + commands: [...(first.commands ?? []), ...(second.commands ?? [])], + outMessage: second.outMessage ?? first.outMessage, + } +} + +export const loadedTrace = (model: Model) => Option.flatten(AsyncData.getData(model.trace.data)) + +/** A filter-style hash change: replace the entry with the new params. */ +export const setParams = (model: Model, context: PageContext, updates: Record): UpdateReturn => ({ + model, + outMessage: replaceParams(context.location, updates), +}) + +/** The span panel follows the hash's `spanId`; a new selection mounts a new panel. */ +const syncPanel = (model: Model, context: PageContext): UpdateReturn => + Option.match(loadedTrace(model), { + onNone: () => ({ model: { ...model, panel: null } }), + onSome: (trace) => { + const span = selectedSpanOf(trace, context) + if (span === undefined) return { model: { ...model, panel: null } } + if (model.panel !== null && model.panel.span.spanId === span.spanId) return { model } + const next = initPanel(span, context.env.apiBase) + // The panel component stays mounted across selections, so its tab does too. + const panel = model.panel === null ? next.panel : { ...next.panel, tabs: model.panel.tabs } + return { model: { ...model, panel }, commands: next.commands } + }, + }) + +/** A loaded trace mounts the hash's tab and, with a `spanId`, the span panel. */ +const onLoaded = (model: Model, context: PageContext): UpdateReturn => + Option.match(loadedTrace(model), { + onNone: () => ({ model: { ...model, timeline: null, waterfall: null, flow: null, panel: null } }), + onSome: (trace) => { + const tab = enterTab(model, trace, viewOf(context), selectedSpanOf(trace, context)?.spanId ?? null) + return chain(tab, (next) => syncPanel(next, context)) + }, + }) + +export const init = ( + context: PageContext, + traceId: string, +): Readonly<{ model: Model; commands: Commands }> => { + const key = traceKey(traceId) + return { + model: { + _tag: "TraceDetailPage", + traceId, + trace: { ...idle(), key, data: AsyncData.Loading() }, + traceUpdatedAtMs: 0, + isRefreshing: false, + tabs: Tabs.init({ idBase: 23, values: TAB_VALUES, selected: viewOf(context) }), + colorBy: DEFAULT_COLOR_BY, + timeline: null, + waterfall: null, + flow: null, + panel: null, + pointer: null, + }, + commands: [FetchTrace({ key, traceId, apiBase: context.env.apiBase, readsCache: true })], + } +} + +/** The hash changed: a new `view` swaps the tab, a new `spanId` the selection and the panel. */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => + Option.match(loadedTrace(model), { + onNone: () => ({ model: { ...model, tabs: { ...model.tabs, selected: viewOf(context) } } }), + onSome: (trace) => { + const view = viewOf(context) + const spanId = selectedSpanOf(trace, context)?.spanId ?? null + const previousSpanId = model.panel?.span.spanId ?? null + const tabbed = + model.tabs.selected !== view + ? enterTab(model, trace, view, spanId) + : previousSpanId !== spanId + ? selectInTab(model, spanId) + : { model } + return chain(tabbed, (next) => syncPanel(next, context)) + }, + }) + +/** `onSelectSpan`: the span goes in the hash; re-selecting the selected span changes nothing. */ +const selectSpan = (model: Model, context: PageContext, spanId: string): UpdateReturn => + queryOf(context.location).get("spanId") === spanId ? { model } : setParams(model, context, { spanId }) + +/** `RefreshButton` without `advance`: `invalidateQueries(["local"])` refetches the trace in place. */ +const refresh = (model: Model, context: PageContext): UpdateReturn => { + if (model.isRefreshing || model.trace.key === null) return { model } + const trace = Option.getOrElse(request(model.trace, model.trace.key, true), () => model.trace) + const panel = model.panel === null ? null : refetchPanel(model.panel, context.env.apiBase) + return { + model: { ...model, trace, isRefreshing: true, panel: panel === null ? null : panel.panel }, + commands: [ + FetchTrace({ key: model.trace.key, traceId: model.traceId, apiBase: context.env.apiBase, readsCache: false }), + ...(panel?.commands ?? []), + ], + outMessage: PageOutMessage.RefetchedInPlace(), + } +} + +/** `onBack`: history back, else the list with the detail-only params dropped. */ +const back = (model: Model, context: PageContext): UpdateReturn => { + const query = queryOf(context.location) + query.delete("spanId") + query.delete("view") + const search = query.toString() + return { model, outMessage: PageOutMessage.RequestedBack({ fallbackHash: `#/traces${search ? `?${search}` : ""}` }) } +} + +/** The cache answered first: show it (a first load mounts the tab and panel), then fetch unless fresh. */ +const settleCached = (model: Model, context: PageContext, key: string, cached: Cached>): UpdateReturn => { + if (model.trace.key !== key) return { model } + const next: Model = { ...model, trace: acceptCached(model.trace, key, cached, cached.isFresh), traceUpdatedAtMs: cached.updatedAtMs } + const fetch: Commands = cached.isFresh ? [] : [FetchTrace({ key, traceId: model.traceId, apiBase: context.env.apiBase, readsCache: false })] + return chain({ model: next, commands: fetch }, (loaded) => onLoaded(loaded, context)) +} + +/** Spans and duration identify a result; a refetch that changes them rebuilds the tab like legacy's re-render. */ +const traceSignature = (trace: Option.Option): string => + Option.match(trace, { + onNone: () => "none", + onSome: (t) => `${t.totalDurationMs}|${t.rootSpans.map((s) => s.spanId).join(",")}|${t.spans.map((s) => s.spanId).join(",")}`, + }) +const sameTrace = (a: Option.Option, b: Option.Option) => traceSignature(a) === traceSignature(b) + +/** The trace answered: a first load mounts the tab and panel; a refetch keeps them unless the trace changed. */ +const settleTrace = (model: Model, context: PageContext, key: string, result: Result.Result, LoadFailure>, fetchedAtMs: number): UpdateReturn => { + if (model.trace.key !== key) return { model } + const hadData = Option.isSome(AsyncData.getData(model.trace.data)) + const next: Model = { + ...model, + trace: accept(model.trace, key, result, Result.isSuccess(result) ? fetchedAtMs : null), + traceUpdatedAtMs: Result.isSuccess(result) ? fetchedAtMs : model.traceUpdatedAtMs, + isRefreshing: false, + } + const changed = Result.isSuccess(result) && !sameTrace(loadedTrace(model), loadedTrace(next)) + return hadData && !changed ? { model: next } : onLoaded(next, context) +} + +const panelStep = (model: Model, context: PageContext, message: Message): UpdateReturn => { + if (model.panel === null) return { model } + const next = updatePanel(model.panel, message) + const stepped: UpdateReturn = { model: { ...model, panel: next.panel }, commands: next.commands } + const outcome = next.outcome + if (outcome === undefined || outcome._tag === "None") return stepped + if (outcome._tag === "Close") return chain(stepped, (m) => setParams(m, context, { spanId: null })) + return chain(stepped, (m) => selectSpan(m, context, outcome.spanId)) +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => + Message.match(message, { + FoundCachedTrace: ({ key, cached }) => settleCached(model, context, key, cached), + SucceededFetchTrace: ({ key, trace, fetchedAtMs }) => settleTrace(model, context, key, Result.succeed(trace), fetchedAtMs), + FailedFetchTrace: ({ key, failure }) => settleTrace(model, context, key, Result.fail(failure), 0), + ClickedBack: () => back(model, context), + ClickedRefresh: () => refresh(model, context), + // Escape closes the span panel. + PressedEscape: () => (model.panel === null ? { model } : setParams(model, context, { spanId: null })), + MovedPointer: ({ x, y }) => ({ model: { ...model, pointer: { x, y } } }), + GotTabsMessage: ({ message: child }) => { + const next = Tabs.update(model.tabs, child) + const lifted: UpdateReturn = { + model: { ...model, tabs: next.model }, + commands: Command.mapMessages(next.commands ?? [], (tab) => Message.GotTabsMessage({ message: tab })), + } + const view = TAB_VALUES.find((value) => value === next.model.selected) + return view === undefined || view === model.tabs.selected + ? lifted + : chain(lifted, (m) => setParams({ ...m, tabs: model.tabs }, context, { view: view === "timeline" ? null : view })) + }, + GotTimelineMessage: ({ message: child }) => { + if (model.timeline === null) return { model } + const next = Timeline.update(model.timeline, child) + const lifted: UpdateReturn = { + model: { ...model, timeline: next.model }, + commands: Command.mapMessages(next.commands ?? [], (inner) => Message.GotTimelineMessage({ message: inner })), + } + const out = next.outMessage + if (out === undefined) return lifted + if (out._tag === "SelectedSpan") return chain(lifted, (m) => selectSpan(m, context, out.spanId)) + return chain(lifted, (m) => { + if (m.timeline === null) return { model: m } + const recolored = Timeline.setColorBy(m.timeline, out.colorBy) + return { + model: { ...m, colorBy: out.colorBy, timeline: recolored.model }, + commands: Command.mapMessages(recolored.commands ?? [], (inner) => Message.GotTimelineMessage({ message: inner })), + } + }) + }, + GotWaterfallMessage: ({ message: child }) => { + if (model.waterfall === null) return { model } + const next = Waterfall.update(model.waterfall, child) + const lifted: UpdateReturn = { + model: { ...model, waterfall: next.model }, + commands: Command.mapMessages(next.commands ?? [], (inner) => Message.GotWaterfallMessage({ message: inner })), + } + const out = next.outMessage + return out === undefined ? lifted : chain(lifted, (m) => selectSpan(m, context, out.spanId)) + }, + GotFlowMessage: ({ message: child }) => { + if (model.flow === null) return { model } + const stepped = TraceFlow.update(model.flow.graph)(model.flow.model, child) + // Handles stay measured at the unfitted zoom (exact positions): legacy's re-measure at the + // fitted zoom only adds sub-1e-3 px float residue, which made one edge label round differently. + const flowModel = { ...stepped, canvas: { ...stepped.canvas, measureEpoch: model.flow.model.canvas.measureEpoch } } + const next: Model = { ...model, flow: { ...model.flow, model: flowModel } } + const picked = Option.getOrUndefined(flowModel.selectedSpanId) + return child._tag === "ClickedSpanNode" && picked !== undefined ? selectSpan(next, context, picked) : { model: next } + }, + SucceededFetchSpanDetail: () => panelStep(model, context, message), + FailedFetchSpanDetail: () => panelStep(model, context, message), + SucceededFetchSpanLogs: () => panelStep(model, context, message), + FoundCachedSpanDetail: () => panelStep(model, context, message), + FoundCachedSpanLogs: () => panelStep(model, context, message), + FailedFetchSpanLogs: () => panelStep(model, context, message), + GotSpanPanelMessage: () => panelStep(model, context, message), + GotPanelTabsMessage: () => panelStep(model, context, message), + ClickedSpanLog: () => panelStep(model, context, message), + GotLogSheetMessage: () => panelStep(model, context, message), + GotAttributesMessage: () => panelStep(model, context, message), + CompletedWriteClipboard: () => panelStep(model, context, message), + ElapsedCopyStatus: () => panelStep(model, context, message), + }) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-anatomy.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-anatomy.ts new file mode 100644 index 0000000000..383931e6b9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-anatomy.ts @@ -0,0 +1,115 @@ +import { computeServiceShares, type ServiceShare } from "@maple/web-foldkit/page/traces/detail/derive" +import type { SpanNodeData, TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import { getServiceColor } from "@maple/ui/lib/colors" +import { countLabel, formatDuration, formatPercent } from "@maple/ui/lib/format" +import { getHttpInfo, httpStatusTone } from "@maple/ui/lib/http" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { statFigure } from "@maple/web-foldkit/ui/stat-figure" +import { statusDot } from "@maple/web-foldkit/ui/status-dot" +import { traceIdBadge } from "@maple/web-foldkit/ui/trace-badges" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message } from "./message" + +// The @maple/ui `TraceAnatomyStrip` as local mode renders it: no start time, no commit. +// copied: the spectrum bar of apps/web-foldkit/src/page/traces/detail/view-summary.ts. + +/** An Error status, or an HTTP status in the critical band, anywhere in the trace (as on web). */ +export const traceHasError = (trace: TraceData): boolean => + trace.spans.some((span) => { + if (span.statusCode === "Error") return true + const raw = span.spanAttributes["http.response.status_code"] || span.spanAttributes["http.status_code"] + const code = raw ? Number.parseInt(raw, 10) : Number.NaN + return Number.isFinite(code) && httpStatusTone(code) === "crit" + }) + +/** The first real root: an orphan's synthetic "missing" parent can sort ahead of it. */ +export const rootSpanOf = (trace: TraceData): SpanNodeData | undefined => + trace.rootSpans.find((span) => span.isMissing !== true) ?? trace.rootSpans[0] + +export const httpInfoOf = (span: SpanNodeData) => + getHttpInfo({ spanName: span.spanName, spanAttributes: { ...span.spanAttributes }, spanKind: span.spanKind }) + +export const httpStatusOf = (span: SpanNodeData): number | null | undefined => httpInfoOf(span)?.statusCode + +const httpStatusColor = (code: number): string => { + const tone = httpStatusTone(code) + if (tone !== "neutral") return TONE_TEXT[tone] + return code >= 300 ? "text-chart-p50" : TONE_TEXT.info +} + +const spectrumBar = (h: HtmlBuilder, shares: ReadonlyArray): Html | null => + shares.length === 0 + ? null + : h.div( + [h.Class("flex h-1.5 w-full gap-px overflow-hidden rounded-full")], + shares.map((share) => + h.span( + [ + h.Class("h-full"), + h.DataAttribute("base-ui-tooltip-trigger", ""), + h.DataAttribute("slot", "tooltip-trigger"), + h.Style({ + width: `${share.percent}%`, + "min-width": "4px", + "background-color": getServiceColor(share.serviceName), + }), + ], + [], + ), + ), + ) + +const shareChip = (h: HtmlBuilder, share: ServiceShare): Html => + h.span( + [h.Class("flex items-center gap-1.5 font-mono text-xs")], + [ + serviceDot(h, { serviceName: share.serviceName, size: "sm" }), + h.span([], [share.serviceName]), + h.span([h.Class("text-3xs text-muted-foreground tabular-nums")], [formatPercent(share.percent / 100)]), + ], + ) + +/** The trace's vital signs: total duration, the per-service share of wall-clock time, metadata. */ +export const anatomyStrip = (h: HtmlBuilder, trace: TraceData): Html => { + const root = rootSpanOf(trace) + const shares = computeServiceShares(trace.rootSpans) + const hasError = traceHasError(trace) + const httpStatusCode = root === undefined ? undefined : httpStatusOf(root) + const resource = root?.resourceAttributes ?? {} + const deploymentEnv = resource["deployment.environment.name"] || resource["deployment.environment"] + return h.div( + [h.Class("shrink-0 space-y-2")], + [ + h.div( + [h.Class("flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1")], + [ + statFigure(h, { + value: formatDuration(trace.totalDurationMs), + unit: `${countLabel(trace.spans.length, "span")} · ${countLabel(shares.length, "service")}`, + className: "min-w-0", + }), + h.div( + [h.Class("flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground")], + [ + h.span( + [h.Class(cn("flex items-center gap-1.5 font-medium", hasError ? "text-severity-error" : "text-severity-info"))], + [statusDot(h, { tone: hasError ? "crit" : "ok" }), hasError ? "Error" : "OK"], + ), + httpStatusCode === undefined || httpStatusCode === null + ? null + : h.span([h.Class(cn("font-mono font-medium", httpStatusColor(httpStatusCode)))], ["HTTP ", String(httpStatusCode)]), + deploymentEnv === undefined || deploymentEnv === "" + ? null + : h.span([h.Class(deploymentEnv === "production" ? "text-severity-warn" : "text-chart-p50")], [deploymentEnv]), + traceIdBadge(h, trace.traceId, { size: "default", className: "text-xs max-w-[10rem]" }), + ].filter((child) => child !== null), + ), + ], + ), + spectrumBar(h, shares), + h.div([h.Class("flex flex-wrap items-center gap-x-4 gap-y-1")], shares.map((share) => shareChip(h, share))), + ].filter((child) => child !== null), + ) +} diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-attributes.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-attributes.ts new file mode 100644 index 0000000000..c0c6fdcef7 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-attributes.ts @@ -0,0 +1,33 @@ +import { attributesSection } from "@maple/web-foldkit/ui/attributes/attributes-view" +import type { Message, Model } from "@maple/web-foldkit/ui/attributes/attributes-panel" +import { skeleton, skeletonList } from "@maple/web-foldkit/ui/skeleton" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" + +// The span panel's attribute blocks: `AttributesSection` / `ResourceAttributesSection`, grouped by +// namespace. Each block is its own view so they stay direct children of the Details body. + +export type SectionInputs = Readonly<{ attributes: Readonly>; title: string; section: string }> + +export const sectionView = Submodel.defineView((model, inputs, h) => + attributesSection( + h, + { model, source: "log", canFilter: false, idPrefix: inputs.section, hasCommitShaValues: false }, + { attributes: inputs.attributes, title: inputs.title, section: inputs.section, isGrouped: true }, + ), +) + +/** While the full attribute maps load. */ +export const attributesPending = (h: HtmlBuilder): Html => + skeletonList(h, { + rows: 4, + gap: "2", + renderRow: (index) => skeleton(h, index % 2 === 0 ? "h-4 w-32" : "h-24 w-full"), + }) + +/** The full-attribute lookup failed: the trimmed maps follow. */ +export const attributesFailedNote = (h: HtmlBuilder): Html => + h.p( + [h.Class("rounded-md border border-dashed px-2 py-1.5 text-[11px] text-muted-foreground")], + ["Couldn't load all attributes. Showing the ones loaded with the trace."], + ) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-panel-logs.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-panel-logs.ts new file mode 100644 index 0000000000..6325733b10 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-panel-logs.ts @@ -0,0 +1,82 @@ +import { formatUtcTitle } from "@maple/local-ui/lib/time" +import { formatTimestamp, type TimeFormat } from "../kit/local-time" +import { getSeverityColor } from "@maple/ui/lib/severity" +import { cn } from "@maple/ui/lib/utils" +import { badge } from "@maple/web-foldkit/ui/display" +import { skeleton, skeletonList } from "@maple/web-foldkit/ui/skeleton" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import * as LogSheetView from "../kit/log-sheet/view" +import { Message } from "./message" +import type { PanelModel, SpanLog } from "./panel-model" + +// `SpanLogs` (apps/local-ui/src/components/span-detail-panel.tsx): the span's logs, newest first. +// A row click opens `LogDetailSheet` on that log. + +type H = HtmlBuilder + +/** `SeverityBadge` from the text only (`severityLabel` with no number). */ +const severityBadge = (h: H, severityText: string): Html => { + const label = severityText.trim() === "" ? "UNSET" : severityText + const color = getSeverityColor(label) + return badge( + h, + { + variant: "secondary", + className: cn("font-mono text-3xs uppercase", "shrink-0"), + attributes: [h.Style({ color, "background-color": `color-mix(in oklch, ${color} 10%, transparent)` })], + }, + [label], + ) +} + +const logRow = (h: H, log: SpanLog, nowMs: number, timeFormat: TimeFormat): Html => + h.button( + [ + h.Key(log.key), + h.Type("button"), + h.Class("flex w-full cursor-pointer flex-col gap-1 p-2 text-left hover:bg-muted/30"), + h.OnClick(Message.ClickedSpanLog({ log })), + ], + [ + h.div( + [h.Class("flex items-center gap-2 text-[10px] text-muted-foreground")], + [ + h.span([h.Class("font-mono"), h.Title(formatUtcTitle(log.timestamp))], [formatTimestamp(timeFormat, log.timestamp, { nowMs })]), + severityBadge(h, log.severityText), + ], + ), + h.p([h.Class("line-clamp-3 whitespace-pre-wrap break-all font-mono text-xs")], [log.body]), + ], + ) + +/** The rows, then (like legacy's fragment) the sheet beside them. */ +export const spanLogs = (h: H, panel: PanelModel, nowMs: number, timeFormat: TimeFormat): ReadonlyArray => { + if (isPending(panel.logs)) + return [skeletonList(h, { + rows: 3, + gap: "2", + className: "p-2", + renderRow: () => h.div([h.Class("space-y-1")], [skeleton(h, "h-3 w-24"), skeleton(h, "h-4 w-full")]), + })] + if (Option.isSome(failureWithoutData(panel.logs))) + return [h.div([h.Class("p-4 text-center text-sm text-destructive")], ["Failed to load logs"])] + const logs = Option.getOrElse(AsyncData.getData(panel.logs.data), () => []) + if (logs.length === 0) + return [h.div([h.Class("p-4 text-center text-sm text-muted-foreground")], ["No logs found for this span"])] + const sheet = Option.match(panel.maybeLogSheet, { + onNone: () => [], + onSome: (model) => [ + h.submodel({ + slotId: "span-log-sheet", + model, + view: LogSheetView.view, + viewInputs: { timeFormat }, + toParentMessage: (message) => Message.GotLogSheetMessage({ message }), + }), + ], + }) + return [h.div([h.Class("divide-y")], logs.map((log) => logRow(h, log, nowMs, timeFormat))), ...sheet] +} diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-panel-parts.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-panel-parts.ts new file mode 100644 index 0000000000..d34aaba6c1 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-panel-parts.ts @@ -0,0 +1,153 @@ +import { formatUtcTitle } from "@maple/local-ui/lib/time" +import { formatDateTime, type TimeFormat } from "../kit/local-time" +import { cacheResultStyles, getCacheInfo } from "@maple/ui/lib/cache" +import { formatDuration } from "@maple/ui/lib/format" +import { getSpanStatusBadgeClass } from "@maple/ui/lib/span-kind" +import { copyableValue } from "@maple/web-foldkit/page/traces/detail/span-panel/copy" +import { errorSection } from "@maple/web-foldkit/page/traces/detail/span-panel/error-section" +import type { Message } from "@maple/web-foldkit/page/traces/detail/span-panel/message" +import type { Model } from "@maple/web-foldkit/page/traces/detail/span-panel/model" +import { panelHeader } from "@maple/web-foldkit/page/traces/detail/span-panel/view-header" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { keyValue, keyValueList } from "@maple/web-foldkit/ui/key-value" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" + +// The span panel's parts that speak the web span panel's Messages (copies, error disclosure, +// close): its header and error card as ported, the local summary row and Details body. + +type H = HtmlBuilder + +/** The panel header: service rail, span name, service and kind, close. */ +export const headerView = Submodel.defineView((model, h) => panelHeader(h, model)) + +const summary = (h: H, model: Model): Html => { + const { span } = model + const duration = formatDuration(span.durationMs) + const cacheInfo = getCacheInfo({ ...span.spanAttributes }) + return h.div( + [h.Class("flex shrink-0 items-center gap-4 border-b px-3 py-1.5 text-xs")], + [ + h.div( + [h.Class("flex items-center gap-1.5")], + [ + icon(h, "ClockIcon", { size: 12, className: "text-muted-foreground" }), + h.span([h.Class("font-mono")], [copyableValue(h, model, { id: "duration", value: duration }, [duration])]), + ], + ), + badge(h, { variant: "outline", size: "xs", className: getSpanStatusBadgeClass(span.statusCode) }, [ + span.statusCode || "Unset", + ]), + ...(cacheInfo?.result + ? [ + badge(h, { variant: "outline", size: "xs", className: cacheResultStyles[cacheInfo.result] }, [ + cacheInfo.result === "hit" ? "HIT" : "MISS", + ]), + ] + : []), + ], + ) +} + +export type SummaryInputs = Readonly<{ attributes: Readonly> }> + +/** The summary stats, then the error card for an errored span with a message. */ +export const summaryView = Submodel.defineView((model, inputs, h) => + h.div( + [], + [ + summary(h, model), + ...(model.span.statusCode === "Error" && model.span.statusMessage + ? [ + errorSection(h, model, { + message: model.span.statusMessage, + prompt: { serviceName: model.span.serviceName, operation: model.span.spanName, attributes: inputs.attributes }, + }), + ] + : []), + ], + ), +) + +/** `SpanPositionBar`: the span's window inside the trace's wall-clock track, clamped. */ +const positionBar = (h: H, model: Model, color: string): Html | null => { + const offsetMs = new Date(model.span.startTime).getTime() - new Date(model.traceStartTime).getTime() + const total = model.totalDurationMs + if (!Number.isFinite(offsetMs) || total <= 0) return null + const offsetPct = Math.min(Math.max((offsetMs / total) * 100, 0), 100) + const widthPct = Math.min(Math.max((model.span.durationMs / total) * 100, 0.75), 100 - offsetPct) + return h.div( + [h.Class("py-1")], + [ + h.div( + [h.Class("relative h-1.5 w-full overflow-hidden rounded-full bg-muted")], + [ + h.div( + [ + h.Class("absolute inset-y-0 rounded-full"), + h.Style({ left: `${offsetPct}%`, width: `${widthPct}%`, "background-color": color }), + ], + [], + ), + ], + ), + h.div( + [h.Class("mt-1 flex justify-between text-3xs text-muted-foreground tabular-nums")], + [ + offsetMs < 0 + ? h.span( + [h.Class("text-severity-warn"), h.Title("Starts before the trace root: the clocks disagree")], + ["−", formatDuration(Math.abs(offsetMs)), " (clock skew)"], + ) + : h.span([], ["+", formatDuration(offsetMs)]), + h.span([], [formatDuration(total), " total"]), + ], + ), + ], + ) +} + +const heading = (h: H, text: string): Html => h.h4([h.Class("text-xs font-medium text-muted-foreground")], [text]) + +const idRow = (h: H, model: Model, label: string, id: string, value: string): Html => + keyValue(h, { label, mono: true }, [copyableValue(h, model, { id, value }, [value])]) + +export type DetailsInputs = Readonly<{ color: string; attributes: () => ReadonlyArray; timeFormat: TimeFormat }> + +/** The Details tab: timing, identifiers, then the attribute sections the page builds. */ +export const detailsView = Submodel.defineView((model, inputs, h) => { + const { span } = model + return h.div( + [h.Class("space-y-3 p-3")], + [ + h.div( + [h.Class("space-y-1")], + [ + heading(h, "Timing"), + ...[positionBar(h, model, inputs.color)].filter((bar) => bar !== null), + keyValueList(h, { className: "gap-1 rounded-md border p-2" }, [ + keyValue(h, { label: "Start time", mono: true }, [ + h.span( + [h.Title(formatUtcTitle(span.startTime))], + [copyableValue(h, model, { id: "start-time", value: span.startTime }, [formatDateTime(inputs.timeFormat, span.startTime)])], + ), + ]), + ]), + ], + ), + h.div( + [h.Class("space-y-1")], + [ + heading(h, "Identifiers"), + keyValueList(h, { className: "gap-1 rounded-md border p-2" }, [ + idRow(h, model, "Span ID", "span-id", span.spanId), + idRow(h, model, "Trace ID", "trace-id", span.traceId), + ...(span.parentSpanId ? [idRow(h, model, "Parent span ID", "parent-span-id", span.parentSpanId)] : []), + ]), + ], + ), + ...inputs.attributes(), + ], + ) +}) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-panel.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-panel.ts new file mode 100644 index 0000000000..8ae9c693f2 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-panel.ts @@ -0,0 +1,139 @@ +import * as Keyed from "@maple/web-foldkit/data/keyed" +import * as InfraCorrelation from "@maple/web-foldkit/page/kit/infra-correlation/update" +import type { TimeFormat } from "../kit/local-time" +import type * as SpanPanelMessage from "@maple/web-foldkit/page/traces/detail/span-panel/message" +import type * as SpanPanel from "@maple/web-foldkit/page/traces/detail/span-panel/model" +import { getServiceColor } from "@maple/ui/lib/colors" +import { badge } from "@maple/web-foldkit/ui/display" +import { icon } from "@maple/web-foldkit/ui/icon" +import { scrollArea } from "@maple/web-foldkit/ui/scroll-area" +import * as TabsView from "@maple/web-foldkit/ui/tabs-view" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { Message } from "./message" +import { LOG_LIMIT, type PanelModel } from "./panel-model" +import { attributesFailedNote, attributesPending, sectionView } from "./view-attributes" +import { spanLogs } from "./view-panel-logs" +import { detailsView, headerView, summaryView } from "./view-panel-parts" + +// `SpanDetailPanel` (apps/local-ui/src/components/span-detail-panel.tsx). +// TODO(focus): legacy focuses the panel on mount when nothing has focus; it is not focused here. + +type H = HtmlBuilder + +/** The web panel Model its header, copy and error parts read; they only use span, copies and disclosure. */ +const partsModel = (panel: PanelModel, traceStartTime: string, totalDurationMs: number): SpanPanel.Model => ({ + span: panel.span, + traceStartTime, + totalDurationMs, + detail: Keyed.idle(), + logs: Keyed.idle(), + tabs: panel.tabs, + copies: panel.copies, + openIds: [], + isErrorExpanded: panel.isErrorExpanded, + // The local panel has no Infrastructure tab. + infra: InfraCorrelation.init(`local-span-${panel.span.spanId}-infra`, ""), +}) + +const toParts = (message: SpanPanelMessage.Message) => + Message.GotSpanPanelMessage({ message }) + +/** `logCount`: the query stops at the limit, so a full page reads "100+". */ +const logCountLabel = (panel: PanelModel): string | null => + Option.match(AsyncData.getData(panel.logs.data), { + onNone: () => null, + onSome: (logs) => (logs.length === 0 ? null : logs.length >= LOG_LIMIT ? `${LOG_LIMIT}+` : String(logs.length)), + }) + +/** The full maps once loaded, else the trimmed ones the tree carried. */ +const attributeBlocks = (h: H, panel: PanelModel): ReadonlyArray => { + const { span } = panel + const section = (attributes: Readonly>, title: string, id: string) => + h.submodel({ + slotId: `span-attributes:${id}`, + model: panel.attributes, + view: sectionView, + viewInputs: { attributes, title, section: id }, + toParentMessage: (message) => Message.GotAttributesMessage({ message }), + }) + if (span.isMissing === true) return [section(span.spanAttributes, "Span attributes", "span")] + if (isPending(panel.detail)) return [attributesPending(h)] + const detail = Option.flatMap(AsyncData.getData(panel.detail.data), Option.fromNullishOr) + return [ + ...(Option.isSome(failureWithoutData(panel.detail)) ? [attributesFailedNote(h)] : []), + section(Option.match(detail, { onNone: () => span.spanAttributes, onSome: (d) => d.spanAttributes }), "Span attributes", "span"), + section( + Option.match(detail, { onNone: () => span.resourceAttributes, onSome: (d) => d.resourceAttributes }), + "Resource attributes", + "resource", + ), + ] +} + +const TAB_ICONS = new Map([ + ["details", "CircleInfoIcon"], + ["logs", "FileIcon"], +] as const) + +export type PanelViewInput = Readonly<{ traceStartTime: string; totalDurationMs: number; nowMs: number; timeFormat: TimeFormat }> + +/** The span's identity header, then everything under it scrolling as one (summary, error, tabs). */ +export const spanPanel = (h: H, panel: PanelModel, input: PanelViewInput): Html => { + const parts = partsModel(panel, input.traceStartTime, input.totalDurationMs) + const color = getServiceColor(panel.span.serviceName) + const detail = Option.flatMap(AsyncData.getData(panel.detail.data), Option.fromNullishOr) + const logCount = logCountLabel(panel) + return h.aside( + [ + h.Tabindex(-1), + h.Class("flex h-full w-full shrink-0 flex-col overflow-hidden border-l bg-background focus:outline-none sm:w-[28rem]"), + ], + [ + h.submodel({ slotId: "span-panel-header", model: parts, view: headerView, toParentMessage: toParts }), + scrollArea(h, { className: "min-h-0 flex-1", idPrefix: "span-panel-scroll" }, [ + h.submodel({ + slotId: "span-panel-summary", + model: parts, + view: summaryView, + viewInputs: { attributes: Option.match(detail, { onNone: () => panel.span.spanAttributes, onSome: (d) => d.spanAttributes }) }, + toParentMessage: toParts, + }), + h.submodel({ + slotId: "span-panel-tabs", + model: panel.tabs, + view: TabsView.view, + viewInputs: { + variant: "underline", + labels: ["Details", "Logs"], + listClassName: + "sticky top-0 z-10 w-full justify-start bg-background px-4 *:data-[slot=tabs-tab]:grow-0", + panelClassName: "mt-0", + tabContent: (value: string, label: string) => [ + icon(h, TAB_ICONS.get(value === "logs" ? "logs" : "details") ?? "CircleInfoIcon", { size: 14 }), + ` ${label}`, + ...(value === "logs" && logCount !== null + ? [badge(h, { variant: "secondary", size: "xs", className: "ml-1" }, [logCount])] + : []), + ], + panel: (value: string) => + value === "logs" + ? spanLogs(h, panel, input.nowMs, input.timeFormat) + : [ + h.submodel({ + slotId: "span-panel-details", + model: parts, + view: detailsView, + viewInputs: { color, attributes: () => attributeBlocks(h, panel), timeFormat: input.timeFormat }, + toParentMessage: toParts, + }), + ], + }, + toParentMessage: (message) => Message.GotPanelTabsMessage({ message }), + }), + ]), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-span-tooltip.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-span-tooltip.ts new file mode 100644 index 0000000000..7ca62b4450 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-span-tooltip.ts @@ -0,0 +1,130 @@ +import type { SpanNodeData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import { middleTruncate } from "@maple/web-foldkit/ui/middle-truncate" +import { calculateSelfTime, getServiceColor } from "@maple/ui/lib/colors" +import { formatDuration, formatPercent } from "@maple/ui/lib/format" +import { getHttpInfo, httpStatusTone } from "@maple/ui/lib/http" +import { getSpanKindLabel } from "@maple/ui/lib/span-kind" +import { spanStartMs } from "@maple/ui/lib/span-tree" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message } from "./message" + +// `SpanTooltipContent` (packages/ui/src/components/traces/span-tooltip.tsx) and the timeline's +// hover tooltip around it: fixed above the pointer, never in the way of it. + +type H = HtmlBuilder + +const muted = (h: H, text: string): Html => h.span([h.Class("text-muted-foreground")], [text]) + +const statusClass = (statusCode: string): string => { + if (statusCode === "Error") return "text-severity-error" + if (statusCode === "Ok") return "text-severity-info" + return "" +} + +const startOffsetCell = (h: H, startOffset: number): Html => + startOffset < 0 + ? h.span([h.Class("text-severity-warn")], ["−", formatDuration(Math.abs(startOffset)), " (clock skew)"]) + : h.span([], ["+", formatDuration(startOffset)]) + +const httpBlock = (h: H, span: SpanNodeData): ReadonlyArray => { + const info = getHttpInfo({ spanName: span.spanName, spanAttributes: { ...span.spanAttributes }, spanKind: span.spanKind }) + if (info === null || info === undefined) return [] + const code = info.statusCode + const codeClass = (value: number) => + httpStatusTone(value) === "neutral" ? "text-severity-info" : TONE_TEXT[httpStatusTone(value)] + return [ + h.div( + [h.Class("border-t border-border pt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-3xs")], + [ + muted(h, "Method"), + h.span([h.Class("font-medium")], [info.method]), + ...(code === null || code === undefined ? [] : [muted(h, "HTTP"), h.span([h.Class(codeClass(code))], [String(code)])]), + ...(info.route ? [muted(h, "Route"), middleTruncate(h, { text: info.route, tail: 14, className: "max-w-[180px]" })] : []), + ], + ), + ] +} + +const spanTooltipContent = (h: H, span: SpanNodeData, totalDurationMs: number, traceStartTime: string): Html => { + const serviceColor = getServiceColor(span.serviceName) + const selfTime = calculateSelfTime(span, [...span.children]) + const selfTimePercent = span.durationMs > 0 ? (selfTime / span.durationMs) * 100 : 0 + const durationPercent = totalDurationMs ? (span.durationMs / totalDurationMs) * 100 : null + const startOffset = traceStartTime ? spanStartMs(span) - new Date(traceStartTime).getTime() : null + return h.div( + [h.Class("space-y-2 font-mono text-xs")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.div([h.Class("h-2.5 w-2.5 shrink-0"), h.Style({ "background-color": serviceColor })], []), + h.span([h.Class("font-medium truncate")], [span.spanName]), + ], + ), + ...(durationPercent === null + ? [] + : [ + h.div( + [h.Class("space-y-1")], + [ + h.div( + [h.Class("flex items-center justify-between text-3xs")], + [ + muted(h, "Duration"), + h.span([], [formatDuration(span.durationMs), " (", formatPercent(durationPercent / 100), ")"]), + ], + ), + h.div( + [h.Class("h-1.5 w-full bg-muted overflow-hidden")], + [h.div([h.Class("h-full bg-primary/70"), h.Style({ width: `${Math.max(durationPercent, 1)}%` })], [])], + ), + ], + ), + ]), + ...(span.children.length > 0 + ? [ + h.div( + [h.Class("flex items-center justify-between text-3xs")], + [muted(h, "Self time"), h.span([], [formatDuration(selfTime), " (", formatPercent(selfTimePercent / 100), ")"])], + ), + ] + : []), + h.div( + [h.Class("grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-3xs")], + [ + muted(h, "Service"), + h.span([], [span.serviceName]), + muted(h, "Kind"), + h.span([], [getSpanKindLabel(span.spanKind)]), + ...(startOffset === null ? [] : [muted(h, "Start offset"), startOffsetCell(h, startOffset)]), + ...(durationPercent === null ? [muted(h, "Duration"), h.span([], [formatDuration(span.durationMs)])] : []), + muted(h, "Status"), + h.span([h.Class(statusClass(span.statusCode))], [span.statusCode || "Unset"]), + ], + ), + ...httpBlock(h, span), + ...(span.statusMessage + ? [h.div([h.Class("border-t border-border pt-1.5 text-3xs")], [muted(h, "Message: "), span.statusMessage])] + : []), + ], + ) +} + +/** The hover tooltip: fixed at the pointer, lifted 8px and centred above it. */ +export const spanTooltip = ( + h: H, + input: Readonly<{ span: SpanNodeData; pointer: Readonly<{ x: number; y: number }>; totalDurationMs: number; traceStartTime: string }>, +): Html => + h.div( + [ + h.Class("fixed left-0 top-0 z-[9999] pointer-events-none"), + h.Style({ transform: `translate3d(${input.pointer.x}px, ${input.pointer.y - 8}px, 0) translate(-50%, -100%)` }), + ], + [ + h.div( + [h.Class("bg-popover text-popover-foreground border border-border shadow-lg p-2.5 max-w-sm")], + [spanTooltipContent(h, input.span, input.totalDurationMs, input.traceStartTime)], + ), + ], + ) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view-states.ts b/apps/local-ui-foldkit/src/page/trace-detail/view-states.ts new file mode 100644 index 0000000000..f5c0200aae --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view-states.ts @@ -0,0 +1,46 @@ +import { skeleton, skeletonList } from "@maple/web-foldkit/ui/skeleton" +import { traceIdBadge } from "@maple/web-foldkit/ui/trace-badges" +import type { Html, HtmlBuilder } from "foldkit/html" +import { emptyState } from "../kit/view-states" +import type { Message } from "./message" + +/** `TraceDetailLoading`: shaped like the loaded page (strip, spectrum bar, waterfall rows). */ +export const traceDetailLoading = (h: HtmlBuilder): Html => + h.div( + [h.Class("space-y-4 p-4")], + [ + h.div( + [h.Class("space-y-2")], + [ + skeleton(h, "h-8 w-32"), + skeleton(h, "h-1.5 w-full rounded-full"), + h.div([h.Class("flex gap-4")], [skeleton(h, "h-4 w-24"), skeleton(h, "h-4 w-24"), skeleton(h, "h-4 w-24")]), + ], + ), + skeletonList(h, { + rows: 8, + gap: "px", + className: "gap-0 rounded-md border", + renderRow: () => + h.div( + [h.Class("flex items-center gap-2 border-b p-3")], + [ + skeleton(h, "size-4"), + skeleton(h, "h-4 w-20"), + skeleton(h, "h-4 w-16"), + skeleton(h, "h-4 flex-1"), + skeleton(h, "h-2 w-32"), + skeleton(h, "h-4 w-16"), + ], + ), + }), + ], + ) + +/** No spans for this id: outside retention, or not arrived yet. */ +export const traceNotFound = (h: HtmlBuilder, traceId: string): Html => + emptyState(h, { + title: "Trace not found", + hint: ["It may be outside the store's retention, or its spans have not arrived yet."], + action: traceIdBadge(h, traceId, { size: "default", className: "text-xs" }), + }) diff --git a/apps/local-ui-foldkit/src/page/trace-detail/view.ts b/apps/local-ui-foldkit/src/page/trace-detail/view.ts new file mode 100644 index 0000000000..f5f3e4fd9e --- /dev/null +++ b/apps/local-ui-foldkit/src/page/trace-detail/view.ts @@ -0,0 +1,175 @@ +import type { TimeFormat } from "../kit/local-time" +import * as Timeline from "@maple/web-foldkit/page/traces/detail/timeline" +import type { TraceData } from "@maple/web-foldkit/page/traces/detail/trace-data" +import * as Waterfall from "@maple/web-foldkit/page/traces/detail/waterfall" +import { view as traceFlowView } from "@maple/web-foldkit/service-map/trace-flow/view" +import { button } from "@maple/web-foldkit/ui/button" +import { httpSpanLabel } from "@maple/web-foldkit/ui/http-span-label" +import { icon } from "@maple/web-foldkit/ui/icon" +import * as TabsView from "@maple/web-foldkit/ui/tabs-view" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import type { PageViewInputs } from "../kit/inputs" +import { refreshButton } from "../kit/toolbar" +import { errorState } from "../kit/view-states" +import { Message } from "./message" +import type { Model } from "./model" +import { loadedTrace } from "./update" +import { anatomyStrip, httpInfoOf, rootSpanOf } from "./view-anatomy" +import { spanPanel } from "./view-panel" +import { spanTooltip } from "./view-span-tooltip" +import { TrackPointer } from "./command" +import { findSpan } from "@maple/web-foldkit/page/traces/detail/select" +import { traceDetailLoading, traceNotFound } from "./view-states" + +// `TraceDetailView` (apps/local-ui/src/views/trace-detail-view.tsx). + +type H = HtmlBuilder + +const TAB_ICONS = new Map([ + ["waterfall", "MenuIcon"], + ["timeline", "FireIcon"], + ["flow", "NetworkNodesIcon"], +] as const) + +/** The page title: the root span, as an HTTP label when it is one. */ +const title = (h: H, trace: TraceData | undefined): ReadonlyArray => { + const root = trace === undefined ? undefined : rootSpanOf(trace) + if (root === undefined) return [] + return httpInfoOf(root) !== null && httpInfoOf(root) !== undefined + ? [httpSpanLabel(h, { spanName: root.spanName, spanAttributes: root.spanAttributes, spanKind: root.spanKind, className: "min-w-0 gap-3" })] + : [h.span([h.Class("block truncate"), h.Title(root.spanName)], [root.spanName])] +} + +const tabPanel = (h: H, model: Model, value: string): ReadonlyArray | undefined => { + if (value !== model.tabs.selected) return undefined + if (value === "timeline" && model.timeline !== null) + return [ + h.submodel({ + slotId: "trace-timeline", + model: model.timeline, + view: Timeline.view, + viewInputs: { idPrefix: "trace-timeline" }, + toParentMessage: (message: Timeline.Message) => Message.GotTimelineMessage({ message }), + }), + ] + if (value === "waterfall" && model.waterfall !== null) + return [ + h.submodel({ + slotId: "trace-waterfall", + model: model.waterfall, + view: Waterfall.view, + viewInputs: {}, + toParentMessage: (message: Waterfall.Message) => Message.GotWaterfallMessage({ message }), + }), + ] + if (value === "flow" && model.flow !== null) + return [traceFlowView(h, model.flow.graph, model.flow.model, (message) => Message.GotFlowMessage({ message }))] + return undefined +} + +/** `TraceViewTabs`: Waterfall, Timeline and Flow over the trace (timeline by default). */ +const traceViewTabs = (h: H, model: Model): Html => + h.submodel({ + slotId: "trace-view-tabs", + model: model.tabs, + view: TabsView.view, + viewInputs: { + variant: "underline", + labels: ["Waterfall", "Timeline", "Flow"], + className: "flex flex-col h-full", + listClassName: "shrink-0", + panelClassName: "flex-1 min-h-0", + tabContent: (value: string, label: string) => [ + icon(h, TAB_ICONS.get(value === "waterfall" || value === "flow" ? value : "timeline") ?? "FireIcon", { size: 14 }), + label, + ], + panel: (value: string) => tabPanel(h, model, value), + }, + toParentMessage: (message) => Message.GotTabsMessage({ message }), + }) + +const loadedBody = (h: H, model: Model, trace: TraceData, timeFormat: TimeFormat): Html => + h.div( + [h.Class("flex h-full min-h-0")], + [ + h.div( + [h.Class("flex min-w-0 flex-1 flex-col")], + [ + h.div([h.Class("shrink-0 border-b px-4 py-3")], [anatomyStrip(h, trace)]), + h.div([h.Class("min-h-0 flex-1")], [traceViewTabs(h, model)]), + ], + ), + ...(model.panel === null + ? [] + : [ + spanPanel(h, model.panel, { + traceStartTime: trace.traceStartTime ?? "", + totalDurationMs: trace.totalDurationMs, + nowMs: model.traceUpdatedAtMs, + timeFormat, + }), + ]), + ], + ) + +/** The timeline's span tooltip, while a row is hovered and the pointer has moved over the page. */ +const hoverTooltip = (h: H, model: Model, trace: TraceData | undefined): ReadonlyArray => { + const hoveredId = model.timeline?.hoveredSpanId ?? null + if (trace === undefined || hoveredId === null || model.pointer === null) return [] + const span = findSpan(trace.rootSpans, hoveredId) + return span === undefined + ? [] + : [ + spanTooltip(h, { + span, + pointer: model.pointer, + totalDurationMs: trace.totalDurationMs, + traceStartTime: trace.traceStartTime ?? "", + }), + ] +} + +const body = (h: H, model: Model, timeFormat: TimeFormat): Html => { + if (isPending(model.trace)) return traceDetailLoading(h) + const failure = failureWithoutData(model.trace) + if (Option.isSome(failure)) return errorState(h, { label: "trace", failure: failure.value, onRetry: Message.ClickedRefresh() }) + return Option.match(loadedTrace(model), { + onNone: () => traceNotFound(h, model.traceId), + onSome: (trace) => loadedBody(h, model, trace, timeFormat), + }) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const trace = Option.getOrUndefined(loadedTrace(model)) + return h.div( + [ + h.Class("flex h-full flex-col"), + h.OnMount(TrackPointer()), + h.OnKeyDownPreventDefault((key) => (key === "Escape" && model.panel !== null ? Option.some(Message.PressedEscape()) : Option.none())), + ], + [ + h.div( + [h.Class("flex shrink-0 items-center gap-3 border-b px-4 py-2")], + [ + button(h, { variant: "ghost", size: "sm", className: "shrink-0 gap-1.5", attributes: [h.OnClick(Message.ClickedBack())] }, [ + icon(h, "ArrowLeftIcon"), + inputs.context.canGoBack ? "Back" : "Traces", + ]), + h.div([h.Class("min-w-0 flex-1 text-sm font-semibold")], title(h, trace)), + refreshButton(h, { + isSpinning: model.isRefreshing, + hasNewData: inputs.hasNewDataSince(model.traceUpdatedAtMs), + onRefresh: Message.ClickedRefresh(), + className: "shrink-0", + }), + ], + ), + h.div([h.Class("min-h-0 flex-1")], [body(h, model, inputs.context.timeFormat)]), + ...hoverTooltip(h, model, trace), + ], + ) +}) + diff --git a/apps/local-ui-foldkit/src/page/traces/cache.ts b/apps/local-ui-foldkit/src/page/traces/cache.ts new file mode 100644 index 0000000000..d39a31596a --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/cache.ts @@ -0,0 +1,63 @@ +import { Option, Schema } from "effect" +import type { CacheValue, QueryKey } from "../../data/cache-store" +import { cachedAs } from "../../data/cached" +import { nextCursor } from "./fetch-traces" +import { filtersOf } from "./filters" +import { DurationStats, FacetOption, type TraceFacets, type TracePages, TraceRow } from "./model" + +// The list's and the facets' React Query keys and data, as the result cache keeps them. + +type Window = Readonly<{ search: string; startTime: string; endTime: string }> + +/** `["local", "traces", filters, boundsKey(bounds)]`. */ +export const tracesKey = (window: Window): QueryKey => [ + "local", + "traces", + filtersOf(new URLSearchParams(window.search)), + `${window.startTime}/${window.endTime}`, +] + +/** `["local", "trace-facets", filters, boundsKey(bounds)]`. */ +export const facetsKey = (window: Window): QueryKey => [ + "local", + "trace-facets", + filtersOf(new URLSearchParams(window.search)), + `${window.startTime}/${window.endTime}`, +] + +const CachedPages = Schema.Struct({ pages: Schema.Array(Schema.Array(TraceRow)) }) + +/** `useInfiniteQuery`'s data: the pages and the cursor each was fetched from. */ +export const pagesToCached = (data: Pick) => ({ + pages: data.pages, + pageParams: [undefined, ...data.pages.slice(0, -1).map(nextCursor)], +}) + +export const pagesFromCached = (value: CacheValue): Option.Option => + Option.map(cachedAs(CachedPages)(value), ({ pages }) => ({ + pages, + hasNextPage: nextCursor(pages[pages.length - 1] ?? []) !== undefined, + })) + +const CachedFacets = Schema.Struct({ + services: Schema.Array(FacetOption), + spanNames: Schema.Array(FacetOption), + httpMethods: Schema.Array(FacetOption), + httpStatusCodes: Schema.Array(FacetOption), + deploymentEnvs: Schema.Array(FacetOption), + namespaces: Schema.Array(FacetOption), + errorCount: Schema.Number, + durationStats: Schema.optionalKey(Schema.UndefinedOr(DurationStats)), +}) + +/** Legacy's `durationStats?: DurationStats` is the Model's Option. */ +export const facetsToCached = ({ maybeDurationStats, ...rest }: TraceFacets) => ({ + ...rest, + durationStats: Option.getOrUndefined(maybeDurationStats), +}) + +export const facetsFromCached = (value: CacheValue): Option.Option => + Option.map(cachedAs(CachedFacets)(value), ({ durationStats, ...rest }) => ({ + ...rest, + maybeDurationStats: Option.fromUndefinedOr(durationStats), + })) diff --git a/apps/local-ui-foldkit/src/page/traces/command.ts b/apps/local-ui-foldkit/src/page/traces/command.ts new file mode 100644 index 0000000000..c3516d3862 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/command.ts @@ -0,0 +1,129 @@ +import { Clock, Duration, Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { readCached, recordCached } from "../../data/cached" +import type { LoadFailure } from "../../data/local-query" +import { facetsFromCached, facetsKey, facetsToCached, pagesFromCached, pagesToCached, tracesKey } from "./cache" +import { fetchTraceFacets } from "./fetch-facets" +import { fetchTracePage, nextCursor, type TraceCursor } from "./fetch-traces" +import { filtersOf } from "./filters" +import { Message } from "./message" +import { TraceRow } from "./model" + +/** What one list or facets request reads: the hash query (filters), the window and the server. */ +const RequestArgs = { + key: Schema.String, + search: Schema.String, + startTime: Schema.String, + endTime: Schema.String, + apiBase: Schema.String, +} + +/** `useTimeWindow`'s initial anchor and the clock for relative times. */ +export const ReadClock = Command.define("ReadTracesClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +export const ReadRefreshClock = Command.define("ReadTracesRefreshClock", { + messages: [Message.CompletedReadRefreshClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadRefreshClock({ nowMs })), +}) + +/** React Query refetches an infinite query page by page, each cursor from the page before. */ +const fetchPages = ( + run: (cursor: TraceCursor | undefined) => Effect.Effect, LoadFailure, R>, + remaining: number, + cursor: TraceCursor | undefined, + loaded: ReadonlyArray>, +): Effect.Effect>, LoadFailure, R> => + Effect.flatMap(run(cursor), (page) => { + const pages = [...loaded, page] + const next = nextCursor(page) + return remaining <= 1 || next === undefined ? Effect.succeed(pages) : fetchPages(run, remaining - 1, next, pages) + }) + +/** `readsCache`: a key the page has no rows for yet opens on its cached result first. */ +export const FetchTraces = Command.define("FetchLocalTraces", { + args: { ...RequestArgs, pageCount: Schema.Number, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchTraces, Message.FailedFetchTraces, Message.FoundCachedTraces], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, pageCount, readsCache }) => + Effect.gen(function* () { + const cacheKey = tracesKey({ search, startTime, endTime }) + const maybeCached = readsCache ? yield* readCached(cacheKey, pagesFromCached) : Option.none() + if (Option.isSome(maybeCached)) return Message.FoundCachedTraces({ key, cached: maybeCached.value }) + const filters = filtersOf(new URLSearchParams(search)) + const run = (cursor: TraceCursor | undefined) => + fetchTracePage(filters, { startTime, endTime }, cursor, apiBase) + const pages = yield* fetchPages(run, pageCount, undefined, []) + const last = pages[pages.length - 1] ?? [] + const fetchedAtMs = yield* Clock.currentTimeMillis + const data = { pages, hasNextPage: nextCursor(last) !== undefined } + yield* recordCached(cacheKey, pagesToCached(data), fetchedAtMs) + return Message.SucceededFetchTraces({ key, data, fetchedAtMs }) + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchTraces({ key, failure })))), +}) + +export const FetchNextPage = Command.define("FetchNextLocalTracesPage", { + args: { + ...RequestArgs, + /** The list generation the page extends; a reload retires it. */ + generation: Schema.Number, + cursorTimestamp: Schema.DateTimeUtc, + cursorTraceId: Schema.String, + /** The pages loaded so far; the cache keeps them with the new one. */ + pages: Schema.Array(Schema.Array(TraceRow)), + }, + messages: [Message.SucceededFetchNextPage, Message.FailedFetchNextPage], + interrupt: { keyFields: ["key", "generation"], toKey: ({ key, generation }) => `${key}#${generation}` }, + execute: ({ key, generation, search, startTime, endTime, apiBase, cursorTimestamp, cursorTraceId, pages }) => + fetchTracePage( + filtersOf(new URLSearchParams(search)), + { startTime, endTime }, + { timestamp: cursorTimestamp, traceId: cursorTraceId }, + apiBase, + ).pipe( + // `fetchNextPage` updates the cached query too, so a reload opens on every page loaded. + Effect.tap((page) => + Effect.flatMap(Clock.currentTimeMillis, (atMs) => + recordCached(tracesKey({ search, startTime, endTime }), pagesToCached({ pages: [...pages, page] }), atMs), + ), + ), + Effect.map((page) => Message.SucceededFetchNextPage({ key, generation, page })), + Effect.catch((failure) => Effect.succeed(Message.FailedFetchNextPage({ key, generation, failure }))), + ), +}) + +/** `useLocalTraceFacets`' own `staleTime`. */ +const FACETS_STALE_MS = 15_000 + +export const FetchFacets = Command.define("FetchLocalTraceFacets", { + args: { ...RequestArgs, readsCache: Schema.Boolean }, + messages: [Message.SucceededFetchFacets, Message.FailedFetchFacets, Message.FoundCachedFacets], + interrupt: { keyFields: ["key"], toKey: ({ key }) => key }, + execute: ({ key, search, startTime, endTime, apiBase, readsCache }) => + Effect.gen(function* () { + const cacheKey = facetsKey({ search, startTime, endTime }) + const maybeCached = readsCache ? yield* readCached(cacheKey, facetsFromCached, FACETS_STALE_MS) : Option.none() + if (Option.isSome(maybeCached)) return Message.FoundCachedFacets({ key, cached: maybeCached.value }) + const facets = yield* fetchTraceFacets(filtersOf(new URLSearchParams(search)), { startTime, endTime }, apiBase) + const fetchedAtMs = yield* Clock.currentTimeMillis + yield* recordCached(cacheKey, facetsToCached(facets), fetchedAtMs) + return Message.SucceededFetchFacets({ key, facets, fetchedAtMs }) + }).pipe(Effect.catch((failure) => Effect.succeed(Message.FailedFetchFacets({ key, failure })))), +}) + +/** `ToolbarSearch`'s and `RangeFilterSection`'s 300 ms pause before a typed value commits. */ +export const DEBOUNCE = Duration.millis(300) + +export const WaitSearchDebounce = Command.define("WaitTracesSearchDebounce", { + args: { version: Schema.Number }, + messages: [Message.ElapsedSearchDebounce], + execute: ({ version }) => Effect.as(Effect.sleep(DEBOUNCE), Message.ElapsedSearchDebounce({ version })), +}) + +export const WaitDurationDebounce = Command.define("WaitTracesDurationDebounce", { + args: { version: Schema.Number }, + messages: [Message.ElapsedDurationDebounce], + execute: ({ version }) => Effect.as(Effect.sleep(DEBOUNCE), Message.ElapsedDurationDebounce({ version })), +}) diff --git a/apps/local-ui-foldkit/src/page/traces/fetch-facets.ts b/apps/local-ui-foldkit/src/page/traces/fetch-facets.ts new file mode 100644 index 0000000000..3afdd078c7 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/fetch-facets.ts @@ -0,0 +1,104 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { Effect, Option } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import type { TraceFilters } from "./filters" +import type { DurationStats, FacetOption, TraceFacets } from "./model" + +// apps/local-ui/src/hooks/use-local-trace-facets.ts: every section counts under all the other +// filters but not its own: one union for the rest, one single-branch query per picked dimension. + +type FacetDimension = NonNullable + +const DIMENSIONS: ReadonlyArray<{ + readonly dimension: FacetDimension + readonly facetType: string + readonly isActive: (filters: TraceFilters) => boolean + readonly without: (opts: CH.TracesFacetsOpts, filters: TraceFilters) => CH.TracesFacetsOpts +}> = [ + { dimension: "service", facetType: "service", isActive: (f) => !!f.service, without: (opts) => ({ ...opts, serviceName: undefined }) }, + { + dimension: "spanName", + facetType: "spanName", + isActive: (f) => !!f.span, + // Dropping the facet pick falls back to the toolbar search, if any. + without: (opts, f) => ({ ...opts, spanName: f.search, matchModes: f.search ? { spanName: "contains" } : undefined }), + }, + { dimension: "httpMethod", facetType: "httpMethod", isActive: (f) => !!f.method, without: (opts) => ({ ...opts, httpMethod: undefined }) }, + { dimension: "httpStatus", facetType: "httpStatus", isActive: (f) => !!f.status, without: (opts) => ({ ...opts, httpStatusCode: undefined }) }, + { dimension: "deploymentEnv", facetType: "deploymentEnv", isActive: (f) => !!f.env, without: (opts) => ({ ...opts, deploymentEnv: undefined }) }, + { dimension: "serviceNamespace", facetType: "serviceNamespace", isActive: (f) => !!f.ns, without: (opts) => ({ ...opts, namespace: undefined }) }, +] + +const facetOpts = (filters: TraceFilters): CH.TracesFacetsOpts => ({ + serviceName: filters.service, + spanName: filters.span ?? filters.search, + matchModes: !filters.span && filters.search ? { spanName: "contains" } : undefined, + hasError: filters.errorsOnly || undefined, + httpMethod: filters.method, + httpStatusCode: filters.status, + deploymentEnv: filters.env, + namespace: filters.ns, + minDurationMs: filters.minDurationMs, + maxDurationMs: filters.maxDurationMs, +}) + +/** An empty window yields a zero/NaN aggregate row; drop the stats instead of bogus hints. */ +const toDurationStats = (row: CH.TracesDurationStatsOutput | undefined): Option.Option => { + if (!row) return Option.none() + const stats = { + minDurationMs: row.minDurationMs, + maxDurationMs: row.maxDurationMs, + p50DurationMs: row.p50DurationMs, + p95DurationMs: row.p95DurationMs, + } + const values = Object.values(stats) + return values.some((v) => !Number.isFinite(v)) || stats.maxDurationMs <= 0 ? Option.none() : Option.some(stats) +} + +export const fetchTraceFacets = (filters: TraceFilters, bounds: TimeBounds, apiBase: string) => + Effect.gen(function* () { + const params = localParams(bounds) + const opts = facetOpts(filters) + const active = DIMENSIONS.filter((d) => d.isActive(filters)) + const [mainRows, statsRows, ownRows] = yield* Effect.all( + [ + queryRows(CH.compileUnion(CH.tracesFacetsQuery(opts), params), apiBase), + queryRows( + CH.compile( + CH.tracesDurationStatsQuery({ ...opts, minDurationMs: undefined, maxDurationMs: undefined }), + params, + ), + apiBase, + ), + Effect.forEach( + active, + (d) => + queryRows( + CH.compileUnion(CH.tracesFacetsQuery({ ...d.without(opts, filters), facet: d.dimension }), params), + apiBase, + ), + { concurrency: "unbounded" }, + ), + ], + { concurrency: "unbounded" }, + ) + const rowsFor = (facetType: string): Array => { + const index = active.findIndex((d) => d.facetType === facetType) + const rows = index === -1 ? mainRows : (ownRows[index] ?? []) + return rows + .filter((row) => row.facetType === facetType && row.name) + .map((row) => ({ name: row.name, count: row.count })) + } + const facets: TraceFacets = { + services: rowsFor("service"), + spanNames: rowsFor("spanName"), + httpMethods: rowsFor("httpMethod"), + httpStatusCodes: rowsFor("httpStatus"), + deploymentEnvs: rowsFor("deploymentEnv"), + namespaces: rowsFor("serviceNamespace"), + errorCount: mainRows.find((row) => row.facetType === "errorCount")?.count ?? 0, + maybeDurationStats: toDurationStats(statsRows[0]), + } + return facets + }) diff --git a/apps/local-ui-foldkit/src/page/traces/fetch-traces.ts b/apps/local-ui-foldkit/src/page/traces/fetch-traces.ts new file mode 100644 index 0000000000..2950fe56f9 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/fetch-traces.ts @@ -0,0 +1,126 @@ +import { CH } from "@maple/query-engine" +import type { TimeBounds } from "@maple/local-ui/lib/time" +import { parseAttributes } from "@maple/ui/lib/span-tree" +import { type DateTime, Effect } from "effect" +import { localParams, queryRows } from "../../data/local-query" +import type { TraceFilters } from "./filters" +import type { TraceRow } from "./model" + +// apps/local-ui/src/hooks/use-local-traces.ts: keyset-paged on (root timestamp, TraceId). + +export const PAGE_SIZE = 25 + +export interface TraceCursor { + readonly timestamp: DateTime.Utc + readonly traceId: string +} + +const rootScopedPage = (filters: TraceFilters, bounds: TimeBounds, cursor: TraceCursor | undefined, apiBase: string) => { + // HTTP method/status have no first-class list opts; the web app filters them as attributes too. + const attributeFilters = [ + ...(filters.method ? [{ key: "http.method", value: filters.method, mode: "equals" as const }] : []), + ...(filters.status ? [{ key: "http.status_code", value: filters.status, mode: "equals" as const }] : []), + ] + const compiled = CH.compile( + CH.traceListQuery({ + limit: PAGE_SIZE, + cursor, + serviceName: filters.service, + spanName: filters.span ?? filters.search, + matchModes: !filters.span && filters.search ? { spanName: "contains" } : undefined, + // `errorsOnly: false` means "only non-errored" in the engine; off means "no filter". + errorsOnly: filters.errorsOnly || undefined, + environments: filters.env ? [filters.env] : undefined, + namespaces: filters.ns ? [filters.ns] : undefined, + minDurationMs: filters.minDurationMs, + maxDurationMs: filters.maxDurationMs, + attributeFilters: attributeFilters.length > 0 ? attributeFilters : undefined, + }), + localParams(bounds), + ) + return Effect.map(queryRows(compiled, apiBase), (rows) => + rows.map( + (row): TraceRow => ({ + traceId: row.traceId, + startTime: row.startTime, + durationMs: row.durationMicros / 1000, + spanCount: row.spanCount, + services: row.services, + rootSpanName: row.rootSpanName, + rootSpanKind: row.rootSpanKind, + rootSpanAttributes: parseAttributes(row.rootSpanAttributes), + hasError: row.hasError === 1, + }), + ), + ) +} + +const httpAttributes = (row: CH.TraceSummaryOutput): Record => { + const attrs: Record = {} + if (row.httpMethod) attrs["http.method"] = row.httpMethod + if (row.httpRoute) attrs["http.route"] = row.httpRoute + if (row.httpStatusCode) attrs["http.status_code"] = row.httpStatusCode + return attrs +} + +/** Span mode: the summaries read the roots-only MV, so counts and services come from a follow-up. */ +const spanScopedPage = (filters: TraceFilters, bounds: TimeBounds, cursor: TraceCursor | undefined, apiBase: string) => + Effect.gen(function* () { + const params = localParams(bounds) + const summaries = yield* queryRows( + CH.compile( + CH.traceSummariesQuery({ + limit: PAGE_SIZE, + cursor, + serviceName: filters.service, + spanName: filters.span ?? filters.search, + matchModes: !filters.span && filters.search ? { spanName: "contains" } : undefined, + hasError: filters.errorsOnly || undefined, + httpMethod: filters.method, + httpStatusCode: filters.status, + deploymentEnv: filters.env, + namespace: filters.ns, + minDurationMs: filters.minDurationMs, + maxDurationMs: filters.maxDurationMs, + }), + params, + ), + apiBase, + ) + const traceIds = summaries.map((row) => row.traceId) + const stats = + traceIds.length === 0 + ? [] + : yield* queryRows(CH.compile(CH.traceSpanStatsByTraceIdsQuery({ traceIds }), params), apiBase) + const statsByTrace = new Map(stats.map((row) => [row.traceId, row])) + return summaries.map( + (row): TraceRow => ({ + traceId: row.traceId, + startTime: row.startTime, + durationMs: row.durationMs, + spanCount: statsByTrace.get(row.traceId)?.spanCount ?? null, + services: statsByTrace.get(row.traceId)?.services ?? [row.rootServiceName], + rootSpanName: row.rootSpanName, + rootSpanKind: row.rootSpanKind, + rootSpanAttributes: httpAttributes(row), + hasError: row.hasError > 0, + }), + ) + }) + +/** One page of either scope. */ +export const fetchTracePage = ( + filters: TraceFilters, + bounds: TimeBounds, + cursor: TraceCursor | undefined, + apiBase: string, +) => + filters.scope === "spans" + ? spanScopedPage(filters, bounds, cursor, apiBase) + : rootScopedPage(filters, bounds, cursor, apiBase) + +/** `getNextPageParam`: only a full page asks for another. */ +export const nextCursor = (page: ReadonlyArray): TraceCursor | undefined => { + const last = page.length === PAGE_SIZE ? page[page.length - 1] : undefined + return last ? { timestamp: last.startTime, traceId: last.traceId } : undefined +} diff --git a/apps/local-ui-foldkit/src/page/traces/filters.ts b/apps/local-ui-foldkit/src/page/traces/filters.ts new file mode 100644 index 0000000000..da31c566c2 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/filters.ts @@ -0,0 +1,47 @@ +// `TraceListView`'s filters from the hash query (apps/local-ui/src/views/trace-list-view.tsx). + +export interface TraceFilters { + readonly search?: string + readonly service?: string + readonly span?: string + readonly errorsOnly?: boolean + readonly method?: string + readonly status?: string + readonly env?: string + readonly ns?: string + readonly minDurationMs?: number + readonly maxDurationMs?: number + readonly scope?: "root" | "spans" +} + +/** Parse a URL param as a non-negative integer; anything else means "unset". */ +export const parseNonNegativeInt = (raw: string | null): number | undefined => { + if (!raw) return undefined + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined +} + +export const FILTER_KEYS = ["service", "span", "errors", "method", "status", "env", "ns", "minDur", "maxDur"] as const + +export const filtersOf = (query: URLSearchParams): TraceFilters => ({ + search: query.get("q") || undefined, + service: query.get("service") || undefined, + span: query.get("span") || undefined, + errorsOnly: query.get("errors") === "1", + method: query.get("method") || undefined, + status: query.get("status") || undefined, + env: query.get("env") || undefined, + ns: query.get("ns") || undefined, + minDurationMs: parseNonNegativeInt(query.get("minDur")), + maxDurationMs: parseNonNegativeInt(query.get("maxDur")), + scope: query.get("scope") === "spans" ? "spans" : "root", +}) + +export const activeFilterCount = (query: URLSearchParams): number => FILTER_KEYS.filter((key) => query.get(key)).length + +/** `clearFilters`: every sidebar filter, the search and the span scope. */ +export const clearedFilters = (): Readonly> => ({ + ...Object.fromEntries(FILTER_KEYS.map((key) => [key, null])), + q: null, + scope: null, +}) diff --git a/apps/local-ui-foldkit/src/page/traces/index.ts b/apps/local-ui-foldkit/src/page/traces/index.ts new file mode 100644 index 0000000000..d3256e6eda --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/index.ts @@ -0,0 +1,5 @@ +export { init, locationChanged, update } from "./update" +export { Message } from "./message" +export { Model } from "./model" +export { view } from "./view" +export { lanes } from "./lanes" diff --git a/apps/local-ui-foldkit/src/page/traces/lanes.ts b/apps/local-ui-foldkit/src/page/traces/lanes.ts new file mode 100644 index 0000000000..abb533d39c --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/lanes.ts @@ -0,0 +1,18 @@ +import { type Lane, outcomeOf, slotLane } from "../../data/lanes" +import { FetchFacets, FetchNextPage, FetchTraces } from "./command" +import type { Model } from "./model" + +/** The requests this page may have in flight (see `data/lanes.ts`). */ +export const lanes = (model: Model): ReadonlyArray => [ + ...slotLane(FetchTraces.name, model.traces, (key) => FetchTraces.Interrupt({ key }, outcomeOf)), + ...slotLane(FetchFacets.name, model.facets, (key) => FetchFacets.Interrupt({ key }, outcomeOf)), + ...(model.traces.key === null ? [] : [nextPageLane(model.traces.key, model.pageGeneration, model.isFetchingNextPage)]), +] + +/** The next page in flight: one per list generation, retired by a reload or a new key. */ +const nextPageLane = (key: string, generation: number, isInFlight: boolean): Lane => ({ + scope: FetchNextPage.name, + key: `${key}#${generation}`, + isInFlight, + stop: () => FetchNextPage.Interrupt({ key, generation }, outcomeOf), +}) diff --git a/apps/local-ui-foldkit/src/page/traces/load.ts b/apps/local-ui-foldkit/src/page/traces/load.ts new file mode 100644 index 0000000000..33d1d17fd7 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/load.ts @@ -0,0 +1,75 @@ +import type { Resources } from "../../platform/resources" +import { boundsForRange, type TimeBounds } from "@maple/local-ui/lib/time" +import { Array, Option } from "effect" +import { AsyncData, type Command } from "foldkit" +import { keyOf, request } from "../../data/slot" +import { type PageContext, rangeOf } from "../contract" +import { queryOf } from "../../route" +import { FetchFacets, FetchTraces } from "./command" +import { filtersOf } from "./filters" +import type { Message } from "./message" +import type { Model } from "./model" + +/** The window the list, facets and refresh share: the range resolved at the anchor. */ +export const boundsOf = (model: Model, context: PageContext): Option.Option => + Option.map(model.maybeAnchorMs, (anchorMs) => boundsForRange(rangeOf(context), anchorMs)) + +/** React Query's key: `["local", name, filters, boundsKey(bounds)]`. */ +const keyFor = (name: string, context: PageContext, bounds: TimeBounds): string => + keyOf(name, { filters: filtersOf(queryOf(context.location)), bounds: `${bounds.startTime}/${bounds.endTime}` }) + +/** A cached result is on screen: fetch the real one (`readsCache: false`), unless it is fresh. */ +export const fetchAfterCache = ( + model: Model, + context: PageContext, + which: "traces" | "facets", + isFresh: boolean, +): ReadonlyArray> => + isFresh + ? [] + : Option.match(boundsOf(model, context), { + onNone: () => [], + onSome: (bounds) => { + const args = { search: context.location.search, ...bounds, apiBase: context.env.apiBase, readsCache: false } + const key = model[which].key ?? "" + return [which === "traces" ? FetchTraces({ key, ...args, pageCount: 1 }) : FetchFacets({ key, ...args })] + }, + }) + +/** + * Requests whatever the current filters and window need: the list (and the facets, root scope + * only). `force` refetches keys already held, as `invalidateQueries` does. + */ +export const load = ( + model: Model, + context: PageContext, + force = false, +): Readonly<{ model: Model; commands?: ReadonlyArray> }> => + Option.match(boundsOf(model, context), { + onNone: () => ({ model }), + onSome: (bounds) => { + const args = { search: context.location.search, ...bounds, apiBase: context.env.apiBase } + const spanScope = filtersOf(queryOf(context.location)).scope === "spans" + const tracesKey = keyFor("traces", context, bounds) + const pageCount = Option.match(AsyncData.getData(model.traces.data), { + onNone: () => 1, + onSome: (data) => (force && model.traces.key === tracesKey ? Math.max(1, data.pages.length) : 1), + }) + const traces = request(model.traces, tracesKey, force) + const facetsKey = keyFor("trace-facets", context, bounds) + const facets = spanScope ? Option.none() : request(model.facets, facetsKey, force) + return { + model: { + ...model, + traces: Option.getOrElse(traces, () => model.traces), + facets: Option.getOrElse(facets, () => model.facets), + isFetchingNextPage: Option.isSome(traces) ? false : model.isFetchingNextPage, + pageGeneration: Option.isSome(traces) ? model.pageGeneration + 1 : model.pageGeneration, + }, + commands: Array.getSomes([ + Option.map(traces, () => FetchTraces({ key: tracesKey, ...args, pageCount, readsCache: model.traces.key !== tracesKey })), + Option.map(facets, () => FetchFacets({ key: facetsKey, ...args, readsCache: model.facets.key !== facetsKey })), + ]), + } + }, + }) diff --git a/apps/local-ui-foldkit/src/page/traces/message.ts b/apps/local-ui-foldkit/src/page/traces/message.ts new file mode 100644 index 0000000000..5e0b0e4a63 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/message.ts @@ -0,0 +1,44 @@ +import * as CopyButton from "@maple/web-foldkit/ui/copy-button" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CachedOf } from "../../data/cached" +import { LoadFailure } from "../../data/local-query" +import { TraceFacets, TracePages, TraceRow } from "./model" + +/** A sidebar facet the list takes one value of: its URL param. */ +export const FacetParam = Schema.Literals(["env", "ns", "service", "span", "method", "status"]) +export type FacetParam = typeof FacetParam.Type + +export const Message = defineMessageUnion({ + CompletedReadClock: { nowMs: Schema.Number }, + SucceededFetchTraces: { key: Schema.String, data: TracePages, fetchedAtMs: Schema.Number }, + FailedFetchTraces: { key: Schema.String, failure: LoadFailure }, + /** The result cache held this key (or an older window of its view) before the fetch ran. */ + FoundCachedTraces: { key: Schema.String, cached: CachedOf(TracePages) }, + ClickedLoadMore: {}, + SucceededFetchNextPage: { key: Schema.String, generation: Schema.Number, page: Schema.Array(TraceRow) }, + FailedFetchNextPage: { key: Schema.String, generation: Schema.Number, failure: LoadFailure }, + SucceededFetchFacets: { key: Schema.String, facets: TraceFacets, fetchedAtMs: Schema.Number }, + FailedFetchFacets: { key: Schema.String, failure: LoadFailure }, + FoundCachedFacets: { key: Schema.String, cached: CachedOf(TraceFacets) }, + ClickedRefresh: {}, + ClickedOpenFilters: {}, + CompletedReadRefreshClock: { nowMs: Schema.Number }, + ChangedSearch: { text: Schema.String }, + ClickedClearSearch: {}, + ElapsedSearchDebounce: { version: Schema.Number }, + ToggledErrorsOnly: {}, + ToggledFacetOption: { param: FacetParam, name: Schema.String }, + ChangedDurationDraft: { min: Schema.String, max: Schema.String }, + CommittedDurationDraft: {}, + ElapsedDurationDebounce: { version: Schema.Number }, + AppliedDuration: { min: Schema.NullOr(Schema.Number), max: Schema.NullOr(Schema.Number) }, + ClickedClearFilters: {}, + ClickedExitSpanScope: {}, + ChangedRange: { range: Schema.String }, + ClickedWidenRange: {}, + GotFilterSectionsMessage: { message: FilterSections.Message }, + GotCopyMessage: { id: Schema.String, message: CopyButton.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/local-ui-foldkit/src/page/traces/model.ts b/apps/local-ui-foldkit/src/page/traces/model.ts new file mode 100644 index 0000000000..96b785ef95 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/model.ts @@ -0,0 +1,79 @@ +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { Schema } from "effect" +import { SlotSchema } from "../../data/slot" +import { Copies } from "../kit/copies" + +// apps/local-ui/src/hooks/use-local-traces.ts and use-local-trace-facets.ts, as Schemas. + +export const TraceRow = Schema.Struct({ + traceId: Schema.String, + /** Root span timestamp: the keyset cursor field, paired with `traceId`. */ + startTime: Schema.DateTimeUtc, + durationMs: Schema.Number, + /** Null only when a span-mode trace's spans could not be counted. */ + spanCount: Schema.NullOr(Schema.Number), + services: Schema.Array(Schema.String), + rootSpanName: Schema.String, + rootSpanKind: Schema.String, + rootSpanAttributes: Schema.Record(Schema.String, Schema.String), + hasError: Schema.Boolean, +}) +export type TraceRow = typeof TraceRow.Type + +/** `useInfiniteQuery`'s data: every loaded page, and whether a full last page asks for more. */ +export const TracePages = Schema.Struct({ pages: Schema.Array(Schema.Array(TraceRow)), hasNextPage: Schema.Boolean }) +export type TracePages = typeof TracePages.Type + +export const FacetOption = Schema.Struct({ name: Schema.String, count: Schema.Number }) +export type FacetOption = typeof FacetOption.Type + +export const DurationStats = Schema.Struct({ + minDurationMs: Schema.Number, + maxDurationMs: Schema.Number, + p50DurationMs: Schema.Number, + p95DurationMs: Schema.Number, +}) +export type DurationStats = typeof DurationStats.Type + +export const TraceFacets = Schema.Struct({ + services: Schema.Array(FacetOption), + spanNames: Schema.Array(FacetOption), + httpMethods: Schema.Array(FacetOption), + httpStatusCodes: Schema.Array(FacetOption), + deploymentEnvs: Schema.Array(FacetOption), + namespaces: Schema.Array(FacetOption), + errorCount: Schema.Number, + maybeDurationStats: Schema.Option(DurationStats), +}) +export type TraceFacets = typeof TraceFacets.Type + +/** A debounced text input: what is typed, and a version that retires superseded timers. */ +export const Draft = Schema.Struct({ text: Schema.String, version: Schema.Number, sent: Schema.String }) +export type Draft = typeof Draft.Type + +export const Model = Schema.TaggedStruct("TracesPage", { + /** `useTimeWindow`'s anchor, snapped to the minute; none until the clock is read. */ + maybeAnchorMs: Schema.Option(Schema.Number), + /** The clock at the last read, for relative times. */ + nowMs: Schema.Number, + traces: SlotSchema(TracePages), + facets: SlotSchema(TraceFacets), + isFetchingNextPage: Schema.Boolean, + /** Bumped when the list is refetched or rekeyed; a next page from an older generation is dropped. */ + pageGeneration: Schema.Number, + /** `dataUpdatedAt` of the list, for the "New data" pill. */ + tracesUpdatedAtMs: Schema.Number, + search: Draft, + /** `RangeFilterSection`'s draft and the committed values it was last formatted from. */ + duration: Schema.Struct({ + min: Schema.String, + max: Schema.String, + version: Schema.Number, + minValue: Schema.NullOr(Schema.Number), + maxValue: Schema.NullOr(Schema.Number), + }), + sections: FilterSections.Model, + isRefreshing: Schema.Boolean, + copies: Copies, +}) +export type Model = typeof Model.Type diff --git a/apps/local-ui-foldkit/src/page/traces/traces.scene.test.ts b/apps/local-ui-foldkit/src/page/traces/traces.scene.test.ts new file mode 100644 index 0000000000..2738e113a0 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/traces.scene.test.ts @@ -0,0 +1,141 @@ +import { DateTime, Option } from "effect" +import { click, expect as expectView, expectOutMessage, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { type PageContext, PageOutMessage } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { outcomeOf, stopSuperseded } from "../../data/lanes" +import { FetchFacets, FetchTraces } from "./command" +import { lanes } from "./lanes" +import { Message } from "./message" +import type { Model, TraceRow } from "./model" +import { PAGE_SIZE } from "./fetch-traces" +import { init, locationChanged, update } from "./update" +import { view as pageView } from "./view" + +const NOW = Date.parse("2026-10-01T12:00:30Z") + +const context: PageContext = { + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + location: { path: "/traces", search: "" }, + rememberedRange: "1h", + timeFormat: { locale: "en-US", timeZone: "UTC" }, + canGoBack: false, +} +const inputs: PageViewInputs = { + context, + presenceOf: () => Option.some({ signal: "traces", status: "absent", lastSeenMs: null }), + presenceNowMs: NOW, + clockMs: 0, + hasNewDataSince: () => false, +} +const view = withViewInputs(pageView, inputs)() +const pageUpdate = (model: Model, message: Message) => update(model, message, context) + +const row = (traceId: string): TraceRow => ({ + traceId, + startTime: DateTime.makeUnsafe(NOW - 60_000), + durationMs: 12.5, + spanCount: 7, + services: ["storefront"], + rootSpanName: "GET /", + rootSpanKind: "Server", + rootSpanAttributes: {}, + hasError: false, +}) + +const anchored = () => pageUpdate(init(context).model, Message.CompletedReadClock({ nowMs: NOW })) + +const loaded = (rows: ReadonlyArray) => { + const base = anchored().model + return pageUpdate( + base, + Message.SucceededFetchTraces({ key: base.traces.key ?? "", data: { pages: [rows], hasNextPage: false }, fetchedAtMs: NOW }), + ) +} + +describe("local traces list", () => { + test("entry reads the clock, then asks for the list and the facets", () => { + expect(init(context).commands.map(({ name }) => name)).toContain("ReadTracesClock") + expect((anchored().commands ?? []).map(({ name }) => name)).toEqual([FetchTraces.name, FetchFacets.name]) + }) + + test("a new key opens on the cached older window, then fetches its own rows", () => { + const base = anchored() + const args = (base.commands ?? []).map(({ args }) => args) + expect(args[0]).toEqual(expect.objectContaining({ readsCache: true })) + const cached = { data: { pages: [[row("old")]], hasNextPage: false }, updatedAtMs: NOW - 3_600_000, isStandIn: true, isFresh: false } + const found = pageUpdate(base.model, Message.FoundCachedTraces({ key: base.model.traces.key ?? "", cached })) + expect([found.model.traces.isStandIn, found.model.traces.isPlaceholder, found.model.traces.updatedAtMs]).toEqual([true, false, NOW - 3_600_000]) + expect((found.commands ?? []).map(({ name, args }) => [name, args])).toEqual([ + [FetchTraces.name, expect.objectContaining({ readsCache: false, pageCount: 1 })], + ]) + const fetched = pageUpdate( + found.model, + Message.SucceededFetchTraces({ key: found.model.traces.key ?? "", data: { pages: [[row("new")]], hasNextPage: false }, fetchedAtMs: NOW }), + ) + expect([fetched.model.traces.isStandIn, fetched.model.traces.updatedAtMs]).toEqual([false, NOW]) + }) + + test("a fresh exact result is shown without a fetch", () => { + const base = anchored().model + const cached = { data: { pages: [[row("t")]], hasNextPage: false }, updatedAtMs: NOW - 5_000, isStandIn: false, isFresh: true } + expect(pageUpdate(base, Message.FoundCachedTraces({ key: base.traces.key ?? "", cached })).commands).toEqual([]) + }) + + test("an answer for a superseded key is dropped", () => { + const base = anchored().model + const stale = pageUpdate( + base, + Message.SucceededFetchTraces({ key: "stale", data: { pages: [[row("a")]], hasNextPage: false }, fetchedAtMs: NOW }), + ) + expect(stale.model.traces).toEqual(base.traces) + }) + + test("rows render and Has error filters the hash", () => { + scene( + { update: pageUpdate, view }, + given(loaded([row("t1")]).model), + expectView(text("1")).toExist(), + click(role("checkbox", { name: "Has error" })), + expectOutMessage(PageOutMessage.RequestedNavigation({ hash: "#/traces?errors=1", isReplace: true })), + ) + }) + + test("an empty list asks for signal presence and renders the on-ramp", () => { + const result = loaded([]) + expect(result.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + scene({ update: pageUpdate, view }, given(result.model), expectView(text("No traces yet")).toExist()) + }) + + const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + const fullList = () => { + const base = anchored().model + const rows = Array.from({ length: PAGE_SIZE }, (_, index) => row(`t${index}`)) + const data = { pages: [rows], hasNextPage: true } + return pageUpdate(base, Message.SucceededFetchTraces({ key: base.traces.key ?? "", data, fetchedAtMs: NOW })).model + } + + test("Load more on the previous filter's rows waits for the new filter's list", () => { + const filtered = { ...context, location: { path: "/traces", search: "errors=1" } } + const pending = locationChanged(fullList(), filtered).model + expect(pending.traces.isPlaceholder).toBe(true) + expect(names(update(pending, Message.ClickedLoadMore(), filtered).commands)).toEqual([]) + }) + + test("a next page asked for before a same-minute Reload is dropped when it lands", () => { + const more = pageUpdate(fullList(), Message.ClickedLoadMore()) + expect(names(more.commands)).toEqual(["FetchNextLocalTracesPage"]) + const reloaded = pageUpdate(more.model, Message.CompletedReadRefreshClock({ nowMs: NOW })).model + const late = Message.SucceededFetchNextPage({ key: reloaded.traces.key ?? "", generation: more.model.pageGeneration, page: [row("late")] }) + expect(pageUpdate(reloaded, late).model.traces).toEqual(reloaded.traces) + // It is stopped too, so it never writes its stale pages into the cache. + expect(names(stopSuperseded(lanes(more.model), lanes(reloaded), outcomeOf))).toContain("FetchNextLocalTracesPage.Interrupt") + }) + + test("a fresh cached empty list asks for signal presence like a fetched one", () => { + const base = anchored().model + const cached = { data: { pages: [[]], hasNextPage: false }, updatedAtMs: NOW - 5_000, isStandIn: false, isFresh: true } + const found = pageUpdate(base, Message.FoundCachedTraces({ key: base.traces.key ?? "", cached })) + expect(found.outMessage).toEqual(PageOutMessage.RequestedSignalPresence()) + }) +}) diff --git a/apps/local-ui-foldkit/src/page/traces/update.ts b/apps/local-ui-foldkit/src/page/traces/update.ts new file mode 100644 index 0000000000..e22fda02c4 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/update.ts @@ -0,0 +1,245 @@ +import type { Resources } from "../../platform/resources" +import { snapToMinute, WIDEST_RANGE } from "@maple/local-ui/lib/time" +import * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { formatCompact, parseRange } from "@maple/web-foldkit/ui/filters/range-format" +import { Option, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { accept, acceptCached, canExtend, idle } from "../../data/slot" +import { queryOf } from "../../route" +import { type PageContext, PageOutMessage, replaceParams, withParams } from "../contract" +import { updateCopy } from "../kit/copies" +import { FetchNextPage, ReadClock, ReadRefreshClock, WaitDurationDebounce, WaitSearchDebounce } from "./command" +import { nextCursor } from "./fetch-traces" +import { clearedFilters, filtersOf } from "./filters" +import { boundsOf, fetchAfterCache, load } from "./load" +import { Message } from "./message" +import type { Model, TraceRow } from "./model" + +export type UpdateReturn = Update.ReturnWithOutMessage + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +/** The draft formatted from the committed range, as `RangeFilterSection` seeds and re-syncs it. */ +const durationDraftOf = (query: URLSearchParams, version: number): Model["duration"] => { + const { minDurationMs, maxDurationMs } = filtersOf(query) + return { + min: formatCompact(minDurationMs, "ms"), + max: formatCompact(maxDurationMs, "ms"), + version, + minValue: minDurationMs ?? null, + maxValue: maxDurationMs ?? null, + } +} + +const syncDuration = (duration: Model["duration"], query: URLSearchParams): Model["duration"] => { + const { minDurationMs, maxDurationMs } = filtersOf(query) + return (minDurationMs ?? null) === duration.minValue && (maxDurationMs ?? null) === duration.maxValue + ? duration + : durationDraftOf(query, duration.version) +} + +export const init = (context: PageContext): Readonly<{ model: Model; commands: ReadonlyArray> }> => { + const query = queryOf(context.location) + const q = query.get("q") ?? "" + return { + model: { + _tag: "TracesPage", + maybeAnchorMs: Option.none(), + nowMs: 0, + traces: idle(), + facets: idle(), + isFetchingNextPage: false, + pageGeneration: 0, + tracesUpdatedAtMs: 0, + search: { text: q, version: 0, sent: q }, + duration: durationDraftOf(query, 0), + sections: FilterSections.init(), + isRefreshing: false, + copies: {}, + }, + commands: [ReadClock(), Command.mapMessage(FilterSections.ReadRemembered(), toSections)], + } +} + +/** + * The hash changed under the page: keep the search input in sync unless the change is the page's + * own debounced search echoing back, then request what the new filters need. + */ +export const locationChanged = (model: Model, context: PageContext): UpdateReturn => { + const query = queryOf(context.location) + const q = query.get("q") ?? "" + const search = q === model.search.sent ? model.search : { ...model.search, text: q, sent: q } + return load({ ...model, search, duration: syncDuration(model.duration, query) }, context) +} + +const navigate = (model: Model, context: PageContext, updates: Record): UpdateReturn => ({ + model, + outMessage: replaceParams(context.location, updates), +}) + +/** `setRange`: remember it for pages opened without one, then put it in the hash. */ +const setRange = (model: Model, context: PageContext, range: string): UpdateReturn => ({ + model, + outMessage: PageOutMessage.RememberedRange({ range, hash: withParams(context.location, { range }) }), +}) + +/** `emit` + `onRangeChange`: a reversed pair is a typo, so it is swapped; values are rounded. */ +const commitDuration = (model: Model, context: PageContext, min: number | undefined, max: number | undefined) => { + const [low, high] = min !== undefined && max !== undefined && min > max ? [max, min] : [min, max] + return navigate(model, context, { + minDur: low === undefined ? null : String(Math.round(low)), + maxDur: high === undefined ? null : String(Math.round(high)), + }) +} + +/** The refresh spinner stops once nothing the refresh started is still loading. */ +const settleRefresh = (model: Model): Model => { + const isLoading = (data: AsyncData.AsyncData) => + AsyncData.isLoading(data) || AsyncData.isRefreshing(data) + return model.isRefreshing && !isLoading(model.traces.data) && !isLoading(model.facets.data) + ? { ...model, isRefreshing: false } + : model +} + +/** `fetchNextPage`: the cursor after the last loaded page. */ +const loadMore = (model: Model, context: PageContext): UpdateReturn => { + const maybeNext = Option.flatMap(AsyncData.getData(model.traces.data), (data) => + Option.fromNullishOr(data.hasNextPage ? nextCursor(data.pages[data.pages.length - 1] ?? []) : undefined), + ) + const maybeBounds = boundsOf(model, context) + if (model.isFetchingNextPage || !canExtend(model.traces) || model.traces.key === null || Option.isNone(maybeNext) || Option.isNone(maybeBounds)) + return { model } + return { + model: { ...model, isFetchingNextPage: true }, + commands: [ + FetchNextPage({ + key: model.traces.key, + generation: model.pageGeneration, + search: context.location.search, + ...maybeBounds.value, + apiBase: context.env.apiBase, + cursorTimestamp: maybeNext.value.timestamp, + cursorTraceId: maybeNext.value.traceId, + pages: Option.match(AsyncData.getData(model.traces.data), { onNone: () => [], onSome: (data) => data.pages }), + }), + ], + } +} + +/** `fetchNextPage` landed: the page joins the list if it is still the same query. */ +const appendPage = (model: Model, key: string, page: ReadonlyArray): Model => { + if (model.traces.key !== key) return model + const data = Option.map(AsyncData.getData(model.traces.data), (current) => ({ + pages: [...current.pages, page], + hasNextPage: nextCursor(page) !== undefined, + })) + return Option.match(data, { + onNone: () => ({ ...model, isFetchingNextPage: false }), + onSome: (next) => ({ ...model, isFetchingNextPage: false, traces: { ...model.traces, key, data: AsyncData.Success({ data: next }), isPlaceholder: false } }), + }) +} + +export const update = (model: Model, message: Message, context: PageContext): UpdateReturn => { + const query = queryOf(context.location) + const filters = filtersOf(query) + return Message.match(message, { + CompletedReadClock: ({ nowMs }) => + load({ ...model, nowMs, maybeAnchorMs: Option.some(snapToMinute(nowMs)) }, context), + SucceededFetchTraces: ({ key, data, fetchedAtMs }) => { + const isCurrent = model.traces.key === key + const traces = accept(model.traces, key, Result.succeed(data), fetchedAtMs) + const tracesUpdatedAtMs = isCurrent ? fetchedAtMs : model.tracesUpdatedAtMs + const next = { model: settleRefresh({ ...model, traces, tracesUpdatedAtMs }) } + // An empty list renders `SignalEmptyState`, which reads the shared signal presence. + return isCurrent && data.pages.every((page) => page.length === 0) + ? { ...next, outMessage: PageOutMessage.RequestedSignalPresence() } + : next + }, + FoundCachedTraces: ({ key, cached }) => { + if (model.traces.key !== key) return { model } + const next = { ...model, traces: acceptCached(model.traces, key, cached, cached.isFresh), tracesUpdatedAtMs: cached.updatedAtMs } + const step = { model: settleRefresh(next), commands: fetchAfterCache(next, context, "traces", cached.isFresh) } + // A cached empty list renders `SignalEmptyState` as a fetched one does. + const isEmpty = Option.exists(AsyncData.getData(next.traces.data), (data) => data.pages.every((page) => page.length === 0)) + return isEmpty ? { ...step, outMessage: PageOutMessage.RequestedSignalPresence() } : step + }, + FoundCachedFacets: ({ key, cached }) => { + if (model.facets.key !== key) return { model } + const next = { ...model, facets: acceptCached(model.facets, key, cached, cached.isFresh) } + return { model: settleRefresh(next), commands: fetchAfterCache(next, context, "facets", cached.isFresh) } + }, + FailedFetchTraces: ({ key, failure }) => + ({ model: settleRefresh({ ...model, traces: accept(model.traces, key, Result.fail(failure)) }) }), + ClickedLoadMore: () => loadMore(model, context), + SucceededFetchNextPage: ({ key, generation, page }) => + generation === model.pageGeneration ? { model: appendPage(model, key, page) } : { model }, + FailedFetchNextPage: ({ key, generation, failure }) => + generation !== model.pageGeneration + ? { model } + : { model: { ...model, isFetchingNextPage: false, traces: accept(model.traces, key, Result.fail(failure)) } }, + SucceededFetchFacets: ({ key, facets, fetchedAtMs }) => + ({ model: settleRefresh({ ...model, facets: accept(model.facets, key, Result.succeed(facets), fetchedAtMs) }) }), + FailedFetchFacets: ({ key, failure }) => + ({ model: settleRefresh({ ...model, facets: accept(model.facets, key, Result.fail(failure)) }) }), + ClickedOpenFilters: () => ({ model, outMessage: PageOutMessage.RequestedFilterDrawer() }), + ClickedRefresh: () => + model.isRefreshing ? { model } : { model: { ...model, isRefreshing: true }, commands: [ReadRefreshClock()] }, + CompletedReadRefreshClock: ({ nowMs }) => { + const next = snapToMinute(nowMs) + // `advance()`: a new minute re-anchors the window (new keys); else refetch in place. + if (Option.getOrNull(model.maybeAnchorMs) !== next) + return load({ ...model, nowMs, maybeAnchorMs: Option.some(next), isRefreshing: false }, context) + const refetch = load({ ...model, nowMs }, context, true) + return { model: settleRefresh(refetch.model), commands: refetch.commands, outMessage: PageOutMessage.RefetchedInPlace() } + }, + ChangedSearch: ({ text }) => { + const version = model.search.version + 1 + return { model: { ...model, search: { ...model.search, text, version } }, commands: [WaitSearchDebounce({ version })] } + }, + ClickedClearSearch: () => { + const version = model.search.version + 1 + return { model: { ...model, search: { ...model.search, text: "", version } }, commands: [WaitSearchDebounce({ version })] } + }, + ElapsedSearchDebounce: ({ version }) => { + if (version !== model.search.version) return { model } + const trimmed = model.search.text.trim() + return navigate({ ...model, search: { ...model.search, sent: trimmed } }, context, { q: trimmed === "" ? null : trimmed }) + }, + ToggledErrorsOnly: () => navigate(model, context, { errors: filters.errorsOnly === true ? null : "1" }), + ToggledFacetOption: ({ param, name }) => navigate(model, context, { [param]: query.get(param) === name ? null : name }), + ChangedDurationDraft: ({ min, max }) => { + const version = model.duration.version + 1 + return { + model: { ...model, duration: { ...model.duration, min, max, version } }, + commands: [WaitDurationDebounce({ version })], + } + }, + ElapsedDurationDebounce: ({ version }) => + version !== model.duration.version + ? { model } + : commitDuration(model, context, parseRange(model.duration.min, "ms"), parseRange(model.duration.max, "ms")), + CommittedDurationDraft: () => { + const min = parseRange(model.duration.min, "ms") + const max = parseRange(model.duration.max, "ms") + const duration = { ...model.duration, version: model.duration.version + 1, min: formatCompact(min, "ms"), max: formatCompact(max, "ms") } + return commitDuration({ ...model, duration }, context, min, max) + }, + AppliedDuration: ({ min, max }) => { + const duration = { ...model.duration, version: model.duration.version + 1, min: formatCompact(min ?? undefined, "ms"), max: formatCompact(max ?? undefined, "ms") } + return commitDuration({ ...model, duration }, context, min ?? undefined, max ?? undefined) + }, + ClickedClearFilters: () => navigate(model, context, clearedFilters()), + ClickedExitSpanScope: () => navigate(model, context, { scope: null }), + ChangedRange: ({ range }) => setRange(model, context, range), + ClickedWidenRange: () => setRange(model, context, WIDEST_RANGE), + GotFilterSectionsMessage: ({ message: child }) => { + const next = FilterSections.update(model.sections, child) + return { model: { ...model, sections: next.model }, commands: Command.mapMessages(next.commands ?? [], toSections) } + }, + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + }) +} + diff --git a/apps/local-ui-foldkit/src/page/traces/view-row.ts b/apps/local-ui-foldkit/src/page/traces/view-row.ts new file mode 100644 index 0000000000..ae2be08e18 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/view-row.ts @@ -0,0 +1,66 @@ +import { formatUtcTitle } from "@maple/local-ui/lib/time" +import { formatDateTime, type TimeFormat } from "../kit/local-time" +import { EMPTY_VALUE, formatDuration, formatNumber } from "@maple/ui/lib/format" +import { getHttpInfo } from "@maple/ui/lib/http" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { httpSpanLabel } from "@maple/web-foldkit/ui/http-span-label" +import { tableCell } from "@maple/web-foldkit/ui/table" +import { servicePills, traceStatusBadge } from "@maple/web-foldkit/ui/trace-badges" +import { DateTime } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { hrefFor } from "../../route" +import { linkRow, rowLink } from "../kit/row-link" +import type { Message } from "./message" +import type { TraceRow } from "./model" + +// `TraceListRow` (apps/local-ui/src/views/trace-list-view.tsx). + +export const traceRow = (h: HtmlBuilder, row: TraceRow, query: URLSearchParams, nowMs: number, timeFormat: TimeFormat): Html => { + const href = hrefFor(`/traces/${encodeURIComponent(row.traceId)}`, query) + const httpStatusCode = getHttpInfo({ + spanName: row.rootSpanName, + spanKind: row.rootSpanKind, + spanAttributes: { ...row.rootSpanAttributes }, + })?.statusCode + const pills = servicePills(h, row.services) + return linkRow(h, cn(row.hasError && "bg-severity-error/5"), [ + tableCell( + h, + [ + rowLink(h, { href, label: `Trace ${row.rootSpanName || row.traceId}`, className: "flex items-center gap-2" }, [ + ...(row.hasError + ? [ + h.span( + [h.Class("size-1.5 shrink-0 rounded-full bg-severity-error")], + [h.span([h.Class("sr-only")], ["Errored"])], + ), + ] + : []), + httpSpanLabel(h, { + spanName: row.rootSpanName, + spanKind: row.rootSpanKind, + spanAttributes: row.rootSpanAttributes, + className: "min-w-0", + textClassName: "text-xs", + }), + ]), + ], + "min-w-0", + ), + tableCell(h, pills === null ? [] : [pills]), + tableCell(h, [formatDuration(row.durationMs)], "text-right font-mono tabular-nums"), + tableCell( + h, + [row.spanCount === null ? EMPTY_VALUE : formatNumber(row.spanCount)], + "text-right font-mono tabular-nums text-muted-foreground", + ), + tableCell(h, [traceStatusBadge(h, { hasError: row.hasError, httpStatusCode })]), + tableCell( + h, + [formatRelativeFrom(DateTime.toEpochMillis(row.startTime), nowMs)], + "text-right text-xs whitespace-nowrap text-muted-foreground", + [h.Title(`${formatDateTime(timeFormat, row.startTime)} (${formatUtcTitle(row.startTime)})`)], + ), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/traces/view-sidebar.ts b/apps/local-ui-foldkit/src/page/traces/view-sidebar.ts new file mode 100644 index 0000000000..10e1c8ed46 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/view-sidebar.ts @@ -0,0 +1,119 @@ +import { getServiceColor } from "@maple/ui/lib/colors" +import { button } from "@maple/web-foldkit/ui/button" +import { filterSection, singleCheckboxFilter } from "@maple/web-foldkit/ui/filters/filter-section" +import type * as FilterSections from "@maple/web-foldkit/ui/filters/filter-sections" +import { filterSidebarBody, filterSidebarFrame, filterSidebarHeader } from "@maple/web-foldkit/ui/filters/filter-sidebar" +import { formatValue } from "@maple/web-foldkit/ui/filters/range-format" +import { type RangePreset, rangeFilterSection } from "@maple/web-foldkit/ui/filters/range-filter-section" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { TraceFilters } from "./filters" +import { type FacetParam, Message } from "./message" +import type { DurationStats, FacetOption, Model } from "./model" + +// The filter rail of `TraceListView` (apps/local-ui/src/views/trace-list-view.tsx). + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +/** `DurationRangeFilter`: p50 and p95 presets from the facet stats, then "> 1s". */ +const durationPresets = (maybeStats: Option.Option): ReadonlyArray => { + const stats = Option.getOrNull(maybeStats) + const p50 = Math.round(stats?.p50DurationMs ?? 0) + const p95 = Math.round(stats?.p95DurationMs ?? 0) + return [ + ...(stats !== null && stats.p50DurationMs > 0 ? [{ key: "p50", label: "> p50", value: formatValue(p50, "ms"), min: p50 }] : []), + ...(stats !== null && stats.p95DurationMs > 0 ? [{ key: "p95", label: "> p95", value: formatValue(p95, "ms"), min: p95 }] : []), + { key: "1s", label: "> 1s", min: 1000 }, + ] +} + +/** Single-select facet: the list query takes one value per dimension. */ +const facet = ( + h: HtmlBuilder, + model: Model, + title: string, + param: FacetParam, + options: ReadonlyArray, + selected: string | undefined, + extra: Readonly<{ isSearchable?: boolean; colorOf?: (name: string) => string }> = {}, +): Html | null => + filterSection(h, { + title, + options, + selected: selected ? [selected] : [], + isSearchable: extra.isSearchable ?? false, + colorOf: extra.colorOf, + sections: model.sections, + toSectionsMessage: toSections, + onToggle: (name) => Message.ToggledFacetOption({ param, name }), + }) + +const durationSection = (h: HtmlBuilder, model: Model, filters: TraceFilters, maybeStats: Option.Option) => + rangeFilterSection(h, { + title: "Duration", + unit: "ms", + minValue: filters.minDurationMs, + maxValue: filters.maxDurationMs, + draft: { min: model.duration.min, max: model.duration.max }, + presets: durationPresets(maybeStats), + ids: { panel: "traces-duration-panel", min: "traces-duration-min", max: "traces-duration-max" }, + sections: model.sections, + toSectionsMessage: toSections, + onDraftChange: (min, max) => Message.ChangedDurationDraft({ min, max }), + onCommitDraft: Message.CommittedDurationDraft(), + onApply: (min, max) => Message.AppliedDuration({ min: min ?? null, max: max ?? null }), + }) + +/** Span scope: facets are off, the only filter is "Errored spans only". */ +const spanScopeSidebar = (h: HtmlBuilder, filters: TraceFilters): Html => + filterSidebarFrame(h, { className: "w-56 shrink-0 px-4" }, [ + filterSidebarHeader(h, { title: "Span search", onClear: Message.ClickedClearFilters() }), + ...filterSidebarBody(h, "traces-span-filters", [ + h.p( + [h.Class("py-1 text-xs text-muted-foreground")], + ["Matching any span in a trace, not only its root. Root-span facets are off in this mode."], + ), + singleCheckboxFilter(h, { + title: "Errored spans only", + isChecked: filters.errorsOnly === true, + onToggle: Message.ToggledErrorsOnly(), + }), + button( + h, + { variant: "outline", size: "sm", className: "mt-2 w-full", attributes: [h.OnClick(Message.ClickedExitSpanScope())] }, + ["Filter root spans instead"], + ), + ]), + ]) + +export const sidebar = (h: HtmlBuilder, model: Model, filters: TraceFilters, activeFilterCount: number): Html => { + if (filters.scope === "spans") return spanScopeSidebar(h, filters) + const maybeFacets = AsyncData.getData(model.facets.data) + const facets = Option.getOrNull(maybeFacets) + const services = facets?.services ?? [] + const sections = [ + singleCheckboxFilter(h, { + title: "Has error", + isChecked: filters.errorsOnly === true, + onToggle: Message.ToggledErrorsOnly(), + count: facets?.errorCount, + }), + facet(h, model, "Environment", "env", facets?.deploymentEnvs ?? [], filters.env), + facet(h, model, "Namespace", "ns", facets?.namespaces ?? [], filters.ns, { isSearchable: true }), + facet(h, model, "Service", "service", services, filters.service, { isSearchable: true, colorOf: getServiceColor }), + facet(h, model, "Root span", "span", facets?.spanNames ?? [], filters.span, { isSearchable: true }), + durationSection(h, model, filters, Option.flatMap(maybeFacets, (loaded) => loaded.maybeDurationStats)), + facet(h, model, "HTTP method", "method", facets?.httpMethods ?? [], filters.method), + facet(h, model, "Status code", "status", facets?.httpStatusCodes ?? [], filters.status), + ] + const isFetching = AsyncData.isLoading(model.facets.data) || AsyncData.isRefreshing(model.facets.data) + return filterSidebarFrame(h, { className: "w-56 shrink-0 px-4", isWaiting: isFetching }, [ + filterSidebarHeader(h, activeFilterCount > 0 ? { onClear: Message.ClickedClearFilters() } : {}), + ...filterSidebarBody( + h, + "traces-filters", + sections.filter((section) => section !== null), + ), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/traces/view-toolbar.ts b/apps/local-ui-foldkit/src/page/traces/view-toolbar.ts new file mode 100644 index 0000000000..2301f769a8 --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/view-toolbar.ts @@ -0,0 +1,68 @@ +import { cn } from "@maple/ui/lib/utils" +import { badge } from "@maple/web-foldkit/ui/display" +import { button } from "@maple/web-foldkit/ui/button" +import { serviceDot } from "@maple/web-foldkit/ui/values" +import type { Html, HtmlBuilder } from "foldkit/html" +import { refreshButton, timeRangeSelect, toolbar, toolbarSearch, toolbarStat, toolbarStats } from "../kit/toolbar" +import type { TraceFilters } from "./filters" +import { Message } from "./message" +import type { Model } from "./model" + +// The toolbar of `TraceListView` and its `SpanScopeBanner`. + +const spanScopeBanner = (h: HtmlBuilder, filters: TraceFilters): Html => + h.div( + [h.Class("flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-sm")], + [ + h.span([h.Class("text-muted-foreground")], ["Traces containing a span"]), + ...(filters.service + ? [ + h.span([h.Class("text-muted-foreground")], ["from"]), + badge(h, { variant: "outline", className: "font-mono text-xs" }, [ + serviceDot(h, { serviceName: filters.service, size: "sm" }), + filters.service, + ]), + ] + : []), + ...(filters.span + ? [ + h.span([h.Class("text-muted-foreground")], ["named"]), + badge(h, { variant: "outline", className: "max-w-64 truncate font-mono text-xs", attributes: [h.Title(filters.span)] }, [ + filters.span, + ]), + ] + : []), + button(h, { variant: "ghost", size: "xs", attributes: [h.OnClick(Message.ClickedExitSpanScope())] }, ["Root spans only"]), + ], + ) + +export const tracesToolbar = ( + h: HtmlBuilder, + model: Model, + filters: TraceFilters, + options: Readonly<{ rowCount: number; hasNextPage: boolean; range: string; hasNewData: boolean }>, +): Html => { + const spanScope = filters.scope === "spans" + return toolbar(h, [ + spanScope ? spanScopeBanner(h, filters) : null, + spanScope && filters.span + ? null + : toolbarSearch(h, { + id: "traces-search", + value: model.search.text, + placeholder: spanScope ? "Filter by any span name…" : "Filter by span name…", + onInput: (text) => Message.ChangedSearch({ text }), + onClear: Message.ClickedClearSearch(), + className: "min-w-48 flex-1", + }), + toolbarStats( + h, + [ + toolbarStat(h, { value: options.rowCount, label: options.hasNextPage ? "traces+" : "traces" }), + refreshButton(h, { isSpinning: model.isRefreshing, hasNewData: options.hasNewData, onRefresh: Message.ClickedRefresh() }), + timeRangeSelect(h, options.range, (range) => Message.ChangedRange({ range })), + ], + cn("shrink-0"), + ), + ]) +} diff --git a/apps/local-ui-foldkit/src/page/traces/view.ts b/apps/local-ui-foldkit/src/page/traces/view.ts new file mode 100644 index 0000000000..d4d044465b --- /dev/null +++ b/apps/local-ui-foldkit/src/page/traces/view.ts @@ -0,0 +1,95 @@ +import { cn } from "@maple/ui/lib/utils" +import { listFooter } from "@maple/web-foldkit/ui/list-footer" +import { table, tableBody, tableHead, tableHeader, tableRow } from "@maple/web-foldkit/ui/table" +import { tableSkeleton } from "@maple/web-foldkit/ui/table-skeleton" +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html } from "foldkit/html" +import { failureWithoutData, isPending } from "../../data/slot" +import { queryOf } from "../../route" +import { rangeOf } from "../contract" +import type { PageViewInputs } from "../kit/inputs" +import { pageShell } from "../kit/page-shell" +import { signalEmptyState } from "../kit/signal-empty-state" +import { errorState } from "../kit/view-states" +import { activeFilterCount, filtersOf } from "./filters" +import { Message } from "./message" +import type { Model } from "./model" +import { traceRow } from "./view-row" +import { sidebar } from "./view-sidebar" +import { tracesToolbar } from "./view-toolbar" + +const SKELETON_COLUMNS = [ + { header: "Trace", headClassName: "w-[40%]", skeleton: "w-56" }, + { header: "Services", skeleton: "w-40" }, + { header: "Duration", headClassName: "text-right", skeleton: "ml-auto w-14" }, + { header: "Spans", headClassName: "text-right", skeleton: "ml-auto w-8" }, + { header: "Status", skeleton: "w-10" }, + { header: "Started", headClassName: "text-right", skeleton: "ml-auto w-16" }, +] + +const toCopy = (id: string, message: Parameters[0]["message"]) => + Message.GotCopyMessage({ id, message }) + +export const view = Submodel.defineView((model, inputs, h) => { + const { context } = inputs + const query = queryOf(context.location) + const filters = filtersOf(query) + const range = rangeOf(context) + const count = activeFilterCount(query) + const maybeData = AsyncData.getData(model.traces.data) + const rows = Option.match(maybeData, { onNone: () => [], onSome: (data) => data.pages.flat() }) + const hasNextPage = Option.exists(maybeData, (data) => data.hasNextPage) + const content = (): Html => { + if (isPending(model.traces)) + return tableSkeleton(h, { className: "rounded-none border-0", rows: 12, columns: SKELETON_COLUMNS }) + const failure = failureWithoutData(model.traces) + if (Option.isSome(failure)) + return errorState(h, { label: "traces", failure: failure.value, onRetry: Message.ClickedRefresh() }) + if (rows.length === 0) + return signalEmptyState(h, { + signal: "traces", + isFiltered: count > 0 || filters.search !== undefined, + onClearFilters: Message.ClickedClearFilters(), + range, + onWidenRange: Message.ClickedWidenRange(), + maybePresence: inputs.presenceOf("traces"), + nowMs: inputs.presenceNowMs, + guide: { id: "traces-empty", endpoint: context.env.otlpEndpoint, copies: model.copies, toCopyMessage: toCopy }, + }) + return h.div( + [h.Class(cn(model.traces.isPlaceholder && "opacity-60 transition-opacity"))], + [ + table(h, {}, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Trace"], "w-[40%]"), + tableHead(h, ["Services"]), + tableHead(h, ["Duration"], "text-right"), + tableHead(h, ["Spans"], "text-right"), + tableHead(h, ["Status"]), + tableHead(h, ["Started"], "text-right"), + ]), + ]), + tableBody(h, rows.map((row) => traceRow(h, row, query, model.nowMs, context.timeFormat))), + ]), + listFooter(h, { hasMore: hasNextPage, isLoading: model.isFetchingNextPage, onLoadMore: Message.ClickedLoadMore() }), + ].filter((child) => child !== null), + ) + } + return pageShell( + h, + { + onOpenFilters: Message.ClickedOpenFilters(), + sidebar: sidebar(h, model, filters, count), + toolbar: tracesToolbar(h, model, filters, { + rowCount: rows.length, + hasNextPage, + range, + hasNewData: inputs.hasNewDataSince(model.tracesUpdatedAtMs), + }), + activeFilterCount: count, + }, + [content()], + ) +}) diff --git a/apps/local-ui-foldkit/src/platform/resources.ts b/apps/local-ui-foldkit/src/platform/resources.ts new file mode 100644 index 0000000000..91c041f642 --- /dev/null +++ b/apps/local-ui-foldkit/src/platform/resources.ts @@ -0,0 +1,7 @@ +import { Layer } from "effect" +import { FetchHttpClient } from "effect/http" +import { LocalQueryCache } from "../data/query-cache" + +/** The runtime's `resources`: the HTTP client every `/local/*` Command runs on, and the result cache. */ +export const ResourcesLive = Layer.mergeAll(FetchHttpClient.layer, LocalQueryCache.layer) +export type Resources = Layer.Success diff --git a/apps/local-ui-foldkit/src/route.ts b/apps/local-ui-foldkit/src/route.ts new file mode 100644 index 0000000000..73df2cf292 --- /dev/null +++ b/apps/local-ui-foldkit/src/route.ts @@ -0,0 +1,70 @@ +import { trySync } from "@maple/ui/lib/try-sync" +import { Option, Schema } from "effect" +import { defineRouteUnion } from "foldkit/route" + +// apps/local-ui/src/App.tsx `parseRoute` and lib/router.ts: routes live in the fragment, +// `#/?`, so every view is reload-safe and shareable. + +export const AppRoute = defineRouteUnion({ + Traces: {}, + TraceDetail: { traceId: Schema.String }, + Logs: {}, + Metrics: {}, + MetricDetail: { metricName: Schema.String }, + Services: {}, + ServiceDetail: { serviceName: Schema.String }, + ServiceMap: {}, + Errors: {}, + Sessions: {}, + SessionDetail: { sessionId: Schema.String }, + Query: {}, +}) +export type AppRoute = typeof AppRoute.Type + +/** The parsed fragment: its path (always starting with `/`) and its query string. */ +export const Location = Schema.Struct({ path: Schema.String, search: Schema.String }) +export type Location = typeof Location.Type + +/** `parseLocation`: `#/sessions?range=1h` without its `#`. */ +export const parseLocation = (hash: string): Location => { + const [rawPath = "", rawSearch = ""] = hash.split("?") + return { path: rawPath.startsWith("/") ? rawPath : "/traces", search: rawSearch } +} + +export const queryOf = (location: Location): URLSearchParams => new URLSearchParams(location.search) + +/** A `#/path?query` href, for real links (keyboard focus, cmd-click, copy link). */ +export const hrefFor = (path: string, query?: URLSearchParams): string => { + const search = query?.toString() + return `#${path}${search ? `?${search}` : ""}` +} + +/** `decodeURIComponent` that keeps a malformed segment as is instead of throwing. */ +export const decodeSegment = (segment: string): string => + Option.getOrElse( + trySync(() => decodeURIComponent(segment)), + () => segment, + ) + +const detail = (path: string, prefix: string): Option.Option => + Option.map(Option.fromNullishOr(path.match(new RegExp(`^/${prefix}/(.+)$`))?.[1]), decodeSegment) + +/** `parseRoute`: details first, then section prefixes; anything else is the traces list. */ +export const parseRoute = (path: string): AppRoute => { + const traceId = detail(path, "traces") + if (Option.isSome(traceId)) return AppRoute.TraceDetail({ traceId: traceId.value }) + const metricName = detail(path, "metrics") + if (Option.isSome(metricName)) return AppRoute.MetricDetail({ metricName: metricName.value }) + const serviceName = detail(path, "services") + if (Option.isSome(serviceName)) return AppRoute.ServiceDetail({ serviceName: serviceName.value }) + const sessionId = detail(path, "sessions") + if (Option.isSome(sessionId)) return AppRoute.SessionDetail({ sessionId: sessionId.value }) + if (path.startsWith("/errors")) return AppRoute.Errors() + if (path.startsWith("/logs")) return AppRoute.Logs() + if (path.startsWith("/metrics")) return AppRoute.Metrics() + if (path.startsWith("/service-map")) return AppRoute.ServiceMap() + if (path.startsWith("/services")) return AppRoute.Services() + if (path.startsWith("/sessions")) return AppRoute.Sessions() + if (path.startsWith("/query")) return AppRoute.Query() + return AppRoute.Traces() +} diff --git a/apps/local-ui-foldkit/src/server/derive.test.ts b/apps/local-ui-foldkit/src/server/derive.test.ts new file mode 100644 index 0000000000..f2c08f6a3a --- /dev/null +++ b/apps/local-ui-foldkit/src/server/derive.test.ts @@ -0,0 +1,43 @@ +import { Option } from "effect" +import { describe, expect, test } from "vitest" +import { canGoBack, trackVisit } from "../navigation" +import { describeStatus } from "../shell/ingest-status" +import { connectionOf, nextProbeDelayMs, nextServerState } from "./derive" +import { ProbeOutcome } from "./state" + +const NOW = 1_800_000_000_000 + +describe("server status", () => { + test("one refusal rechecks quickly and still reads as connecting; two is down", () => { + const first = nextServerState(Option.none(), ProbeOutcome.Refused(), NOW) + expect(nextProbeDelayMs(first)).toBe(1_000) + expect(connectionOf(Option.some(first)).status).toBe("connecting") + const second = nextServerState(Option.some(first), ProbeOutcome.Refused(), NOW + 1_000) + expect(connectionOf(Option.some(second)).status).toBe("disconnected") + expect(nextProbeDelayMs(second)).toBe(5_000) + }) + + test("a 4xx is this page being refused, a 5xx keeps the views", () => { + const rejected = nextServerState(Option.none(), ProbeOutcome.Rejected({ status: 403, detail: "no" }), NOW) + expect(connectionOf(Option.some(rejected)).status).toBe("rejected") + const failing = nextServerState(Option.none(), ProbeOutcome.Rejected({ status: 500, detail: "x" }), NOW) + expect(connectionOf(Option.some(failing)).status).toBe("connected") + expect(describeStatus(Option.some(failing), "4318").label).toBe("Server error") + }) + + test("recent ingest reads as receiving, older as the last data age", () => { + const live = nextServerState(Option.none(), ProbeOutcome.Ok({ lastIngestAtMs: NOW - 20_000 }), NOW) + expect(describeStatus(Option.some(live), "4318").label).toBe("Receiving") + const idle = nextServerState(Option.none(), ProbeOutcome.Ok({ lastIngestAtMs: NOW - 3 * 60_000 }), NOW) + expect(describeStatus(Option.some(idle), "4318").label).toBe("Last data 3m ago") + }) +}) + +describe("in-app history", () => { + test("a push adds, a return pops, a replace rewrites the last entry", () => { + const pushed = trackVisit(["/traces"], "/traces/a", false) + expect(canGoBack(pushed)).toBe(true) + expect(trackVisit(pushed, "/traces", false)).toEqual(["/traces"]) + expect(trackVisit(["/traces"], "/traces?errors=1", true)).toEqual(["/traces?errors=1"]) + }) +}) diff --git a/apps/local-ui-foldkit/src/server/derive.ts b/apps/local-ui-foldkit/src/server/derive.ts new file mode 100644 index 0000000000..a6d5ccc3ef --- /dev/null +++ b/apps/local-ui-foldkit/src/server/derive.ts @@ -0,0 +1,71 @@ +import { Option } from "effect" +import { MISSES_BEFORE_DOWN, POLL_MS, ProbeOutcome, RECHECK_MS, type Rejection, type ServerState } from "./state" + +/** `probe` in use-local-server-status.ts: the next state from the previous one and an outcome. */ +export const nextServerState = ( + maybePrevious: Option.Option, + outcome: ProbeOutcome, + checkedAtMs: number, +): ServerState => { + const previous = Option.getOrNull(maybePrevious) + const base = { + misses: 0, + lastIngestAtMs: previous?.lastIngestAtMs ?? null, + legacy: previous?.legacy ?? false, + rejection: null, + checkedAtMs, + hasConnected: previous?.hasConnected ?? false, + } + return ProbeOutcome.match(outcome, { + Ok: ({ lastIngestAtMs }): ServerState => ({ + ...base, + reachability: "connected", + lastIngestAtMs, + legacy: false, + hasConnected: true, + }), + Legacy: ({ maybeLastSeen }): ServerState => + Option.match(maybeLastSeen, { + onNone: (): ServerState => ({ ...base, reachability: "busy", legacy: true, hasConnected: true }), + onSome: (lastIngestAtMs): ServerState => ({ + ...base, + reachability: "connected", + lastIngestAtMs, + legacy: true, + hasConnected: true, + }), + }), + Busy: (): ServerState => ({ ...base, reachability: "busy", hasConnected: true }), + Refused: (): ServerState => ({ ...base, reachability: "refused", misses: (previous?.misses ?? 0) + 1 }), + Rejected: ({ status, detail }): ServerState => ({ + ...base, + // A 4xx is this page being turned away; a 5xx is the server failing a request. + reachability: status < 500 ? "rejected" : "failing", + rejection: { status, detail }, + hasConnected: true, + }), + }) +} + +/** `refetchInterval`: recheck quickly after one refusal, else the regular poll. */ +export const nextProbeDelayMs = (state: ServerState): number => + state.reachability === "refused" && state.misses < MISSES_BEFORE_DOWN ? RECHECK_MS : POLL_MS + +export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "rejected" + +/** `useLocalConnection`: only a server that is really gone swaps the views out. */ +export const connectionOf = ( + maybeState: Option.Option, +): { readonly status: ConnectionStatus; readonly rejection: Rejection | null } => + Option.match(maybeState, { + onNone: () => ({ status: "connecting", rejection: null }), + onSome: (state) => { + if (state.reachability === "refused") { + // One refused probe can be a restart in progress; two in a row is down. + if (state.misses >= MISSES_BEFORE_DOWN) return { status: "disconnected", rejection: null } + return { status: state.hasConnected ? "connected" : "connecting", rejection: null } + } + if (state.reachability === "rejected") return { status: "rejected", rejection: state.rejection } + return { status: "connected", rejection: null } + }, + }) diff --git a/apps/local-ui-foldkit/src/server/probe.ts b/apps/local-ui-foldkit/src/server/probe.ts new file mode 100644 index 0000000000..9441bbf026 --- /dev/null +++ b/apps/local-ui-foldkit/src/server/probe.ts @@ -0,0 +1,39 @@ +import { CH } from "@maple/query-engine" +import { probeLocalStatus } from "@maple/local-ui/lib/local-status" +import { parseClickHouseDateTime, toClickHouseDateTime } from "@maple/local-ui/lib/time" +import { Clock, Effect, Option } from "effect" +import type { HttpClient } from "effect/http" +import { localParams, queryRows } from "../data/local-query" +import { LEGACY_WINDOW_MS, PROBE_TIMEOUT_MS, ProbeOutcome } from "./state" + +// The probe of apps/local-ui/src/hooks/use-local-server-status.ts as one Effect. + +/** Legacy binaries (no `/local/status`): the newest span or log in the last ten minutes. */ +const legacyLastSeen = (apiBase: string) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const compiled = CH.compileUnion( + CH.orgTelemetryPulseQuery(), + localParams({ startTime: toClickHouseDateTime(now - LEGACY_WINDOW_MS), endTime: toClickHouseDateTime(now + 60 * 1000) }), + ) + const rows = yield* queryRows(compiled, apiBase) + return rows + .filter((row) => row.count > 0) + .reduce((latest, row) => { + const ms = parseClickHouseDateTime(row.lastSeen) + return ms !== null && (latest === null || ms > latest) ? ms : latest + }, null) + }).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.orElseSucceed(() => Option.none()), + ) + +export const probeServer = (apiBase: string): Effect.Effect => + Effect.flatMap(probeLocalStatus(apiBase, PROBE_TIMEOUT_MS), (probe): Effect.Effect => { + if (probe._tag === "Ok") return Effect.succeed(ProbeOutcome.Ok({ lastIngestAtMs: probe.status.lastIngestAtMs })) + if (probe._tag === "Legacy") + return Effect.map(legacyLastSeen(apiBase), (maybeLastSeen) => ProbeOutcome.Legacy({ maybeLastSeen })) + if (probe._tag === "Busy") return Effect.succeed(ProbeOutcome.Busy()) + if (probe._tag === "Refused") return Effect.succeed(ProbeOutcome.Refused()) + return Effect.succeed(ProbeOutcome.Rejected({ status: probe.status, detail: probe.detail })) + }) diff --git a/apps/local-ui-foldkit/src/server/state.ts b/apps/local-ui-foldkit/src/server/state.ts new file mode 100644 index 0000000000..b97dbca409 --- /dev/null +++ b/apps/local-ui-foldkit/src/server/state.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" + +// apps/local-ui/src/hooks/use-local-server-status.ts and use-local-connection.ts, as data. + +/** Consecutive refused probes before the app swaps to the "can't reach" screen. */ +export const MISSES_BEFORE_DOWN = 2 +export const POLL_MS = 5_000 +/** After one refused probe, look again quickly: two misses in a row mean "down". */ +export const RECHECK_MS = 1_000 +/** chDB answers one request at a time, so a slow query holds the probe; past this it reads as busy. */ +export const PROBE_TIMEOUT_MS = 3_000 +/** Legacy binaries (no `/local/status`): newest span/log in this window stands in for arrival time. */ +export const LEGACY_WINDOW_MS = 10 * 60 * 1000 + +export const Reachability = Schema.Literals(["connected", "busy", "refused", "rejected", "failing"]) +export type Reachability = typeof Reachability.Type + +export const Rejection = Schema.Struct({ status: Schema.Number, detail: Schema.String }) +export type Rejection = typeof Rejection.Type + +export const ServerState = Schema.Struct({ + reachability: Reachability, + /** Consecutive probes that found nothing listening. */ + misses: Schema.Number, + /** When the server last accepted a non-empty OTLP batch of any signal (epoch ms). */ + lastIngestAtMs: Schema.NullOr(Schema.Number), + /** True for a binary without `/local/status`, where the time above is the newest event time. */ + legacy: Schema.Boolean, + rejection: Schema.NullOr(Rejection), + /** Epoch ms of this probe; changes every poll so relative labels stay fresh. */ + checkedAtMs: Schema.Number, + /** Whether any probe in this page's life reached the server. */ + hasConnected: Schema.Boolean, +}) +export type ServerState = typeof ServerState.Type + +/** One probe's outcome (`StatusProbe`), with the legacy fallback's newest event already read. */ +export const ProbeOutcome = defineTaggedUnion({ + Ok: { lastIngestAtMs: Schema.NullOr(Schema.Number) }, + /** 404: an older binary; `lastSeen` is none when its fallback query failed or timed out. */ + Legacy: { maybeLastSeen: Schema.Option(Schema.NullOr(Schema.Number)) }, + Busy: {}, + Refused: {}, + Rejected: { status: Schema.Number, detail: Schema.String }, +}) +export type ProbeOutcome = typeof ProbeOutcome.Type diff --git a/apps/local-ui-foldkit/src/shell/connect.ts b/apps/local-ui-foldkit/src/shell/connect.ts new file mode 100644 index 0000000000..10242d0df7 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/connect.ts @@ -0,0 +1,78 @@ +import { button } from "@maple/web-foldkit/ui/button" +import { icon } from "@maple/web-foldkit/ui/icon" +import * as Popover from "@maple/web-foldkit/ui/popover" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "../message" +import type { Model } from "../model" +import { connectGuide } from "../page/kit/connect-guide" + +// apps/local-ui/src/components/connect-button.tsx: a popover with the local ingest endpoint and +// exporter env. Loud until the first telemetry lands, a quiet outline after. + +export const CONNECT_POPOVER_ID = "connect" + +const note = (h: HtmlBuilder): Html => + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + "Recording browser sessions? Point", + " ", + h.code([h.Class("rounded bg-muted px-1")], ["@maple-dev/browser"]), + " at the same endpoint.", + ], + ) + +export const connectButton = (h: HtmlBuilder, model: Model): Html => + h.submodel({ + slotId: "connect-popover", + model: model.connect, + view: Popover.view, + toParentMessage: (message) => Message.GotConnectMessage({ message }), + viewInputs: { + className: "w-[min(26rem,calc(100vw-2rem))]", + toView: ({ trigger, withTrigger }) => + h.div( + [h.Class("contents")], + withTrigger( + button( + h, + { + variant: model.hasIngested ? "outline" : "default", + size: "sm", + className: + "flex-1 justify-start gap-2 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:flex-none group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0", + slot: "popover-trigger", + attributes: [h.AriaLabel("Connect your app"), ...trigger], + }, + [icon(h, "ConnectionIcon"), h.span([h.Class("group-data-[collapsible=icon]:hidden")], ["Connect"])], + ), + ), + ), + toContent: () => [ + h.div( + [h.Class("space-y-4")], + [ + h.div( + [h.Class("space-y-1")], + [ + Popover.popoverTitle(h, CONNECT_POPOVER_ID, "Connect your app", "text-base"), + Popover.popoverDescription( + h, + CONNECT_POPOVER_ID, + "Point your OpenTelemetry SDK at your local Maple to stream traces, logs and metrics.", + "text-xs", + ), + ], + ), + connectGuide(h, { + id: "connect", + endpoint: model.env.otlpEndpoint, + copies: model.copies, + toCopyMessage: (id, message) => Message.GotCopyMessage({ id, message }), + note: note(h), + }), + ], + ), + ], + }, + }) diff --git a/apps/local-ui-foldkit/src/shell/connection-states.ts b/apps/local-ui-foldkit/src/shell/connection-states.ts new file mode 100644 index 0000000000..630a0f7202 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/connection-states.ts @@ -0,0 +1,160 @@ +import { DEFAULT_LOCAL_PORT } from "@maple/local-ui/lib/constants" +import { DOCS_CLI_REFERENCE, DOCS_LOCAL_MODE_INSTALL, INSTALL_METHODS } from "@maple/local-ui/lib/links" +import { copyableField } from "@maple/web-foldkit/ui/copyable-field" +import { separator } from "@maple/web-foldkit/ui/display" +import * as TabsView from "@maple/web-foldkit/ui/tabs-view" +import { copyOf } from "../page/kit/copies" +import { button } from "@maple/web-foldkit/ui/button" +import { empty, emptyContent, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/web-foldkit/ui/empty" +import { icon } from "@maple/web-foldkit/ui/icon" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "../message" +import type { Model } from "../model" +import type { Rejection } from "../server/state" + +// `DisconnectedState` and `RejectedState` (apps/local-ui/src/components/view-states.tsx). + +/** `startCommand`: the command that starts a server on the port this page expects. */ +const startCommand = (port: string): string => + port === DEFAULT_LOCAL_PORT ? "maple start" : `maple start --port ${port}` + +const inlineCode = (h: HtmlBuilder, text: string) => h.code([h.Class("rounded bg-muted px-1")], [text]) + +/** A hosted page (local.maple.dev) reaches the binary across the local-network gate. */ +const hostedHint = (h: HtmlBuilder): Html => + h.p( + [h.Class("text-left text-xs text-muted-foreground")], + [ + "This page runs on local.maple.dev and talks to your machine directly, so your browser may ask to allow access to devices on your local network: allow it. Running on another port? Add ", + inlineCode(h, "?port="), + " to the URL. To skip the prompt, run", + " ", + inlineCode(h, "maple start --offline"), + " and open the URL it prints.", + ], + ) + +const retry = (h: HtmlBuilder, className?: string): Html => + button(h, { variant: "outline", size: "sm", className, attributes: [h.OnClick(Message.ClickedRetryConnection())] }, [ + "Try again", + ]) + +/** The server answered but turned this page away (403 origin rejected, 400, ...). */ +export const rejectedState = (h: HtmlBuilder, rejection: Rejection): Html => + empty( + h, + [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CircleWarningIcon", { className: "text-destructive" })]), + emptyHeader(h, [ + emptyTitle(h, ["Maple Local refused this page (", String(rejection.status), ")"]), + emptyDescription(h, [ + rejection.status === 403 + ? "The server only accepts the dashboard it was started for. Open the URL that maple start printed, or restart it without a custom UI origin." + : "The server rejected the status request.", + ]), + ]), + emptyContent( + h, + [ + ...(rejection.detail + ? [ + h.pre( + [ + h.Class( + "whitespace-pre-wrap break-all rounded-md border bg-muted/40 px-3 py-2 text-left font-mono text-[11px]", + ), + ], + [rejection.detail], + ), + ] + : []), + retry(h, "self-center"), + ], + "w-full max-w-md items-stretch gap-3", + ), + ], + "h-full", + ) + +const field = (h: HtmlBuilder, model: Model, id: string, label: string, value: string, copyLabel?: string) => + copyableField(h, { + id, + label: label === "" ? undefined : label, + copyLabel, + value, + copy: copyOf(model.copies, id), + toCopyMessage: (message) => Message.GotCopyMessage({ id, message }), + }) + +const docsLink = (h: HtmlBuilder, href: string, label: string) => + h.a( + [h.Href(href), h.Target("_blank"), h.Rel("noopener noreferrer"), h.Class("underline underline-offset-2 hover:no-underline")], + [label], + ) + +/** `InstallCommands`: Homebrew or the install script, for a machine without the CLI. */ +const installCommands = (h: HtmlBuilder, model: Model): Html => + h.submodel({ + slotId: "install-tabs", + model: model.installTabs, + view: TabsView.view, + toParentMessage: (message) => Message.GotInstallTabsMessage({ message }), + viewInputs: { + variant: "underline", + labels: INSTALL_METHODS.map((method) => method.label), + className: "gap-2", + listClassName: "justify-start", + panelClassName: "mt-0", + panel: (value) => { + const method = INSTALL_METHODS.find((entry) => entry.id === value) + return method === undefined ? undefined : [field(h, model, `install-${method.id}`, "", method.command, "Command")] + }, + }, + }) + +/** Shown in place of the views when nothing answers on the expected port. */ +export const disconnectedState = (h: HtmlBuilder, model: Model): Html => + empty( + h, + [ + emptyMedia(h, { variant: "icon" }, [icon(h, "ConnectionIcon", { className: "text-muted-foreground" })]), + emptyHeader(h, [ + emptyTitle(h, ["Can’t reach Maple Local"]), + emptyDescription(h, [ + "Nothing is answering on port ", + model.env.port, + ". Start the local server and this page connects on its own.", + ]), + ]), + emptyContent( + h, + [ + installCommands(h, model), + separator(h, "horizontal", undefined), + h.span( + [h.Class("text-left text-[11px] font-semibold uppercase tracking-widest text-muted-foreground")], + ["Already installed?"], + ), + field(h, model, "start-maple", "Start Maple", startCommand(model.env.port)), + field(h, model, "expecting", "Expecting", model.env.otlpEndpoint), + ...(model.env.isHosted ? [hostedHint(h)] : []), + h.div( + [h.Class("flex items-center justify-between gap-2")], + [ + retry(h), + h.span( + [h.Class("flex items-center gap-2 text-xs text-muted-foreground")], + [ + docsLink(h, DOCS_LOCAL_MODE_INSTALL, "Local mode"), + h.span([h.AriaHidden(true)], ["·"]), + docsLink(h, DOCS_CLI_REFERENCE, "CLI reference"), + ], + ), + ], + ), + ], + "w-full max-w-md items-stretch gap-3 text-left", + ), + ], + "h-full overflow-auto", + ) diff --git a/apps/local-ui-foldkit/src/shell/filter-drawer.ts b/apps/local-ui-foldkit/src/shell/filter-drawer.ts new file mode 100644 index 0000000000..22badb4207 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/filter-drawer.ts @@ -0,0 +1,47 @@ +import { type LiftedRail, splitPageRails } from "@maple/web-foldkit/shell/page-rails" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as DialogView from "@maple/web-foldkit/ui/dialog-view" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "../message" +import type { Model } from "../model" + +// `PageShell`'s drawer (apps/local-ui/src/components/page-shell.tsx): `SheetPopup side="left"` +// around a copy of the page's own rail, which stays (hidden below `md`) in the page body. + +type VNode = NonNullable + +/** A fresh copy of a built subtree (no `elm`), so the same nodes can patch a second place. */ +const cloneNode = (node: VNode): VNode => ({ + ...node, + elm: undefined, + data: node.data === undefined ? undefined : { ...node.data }, + children: node.children?.map((child) => (typeof child === "string" ? child : cloneNode(child))), +}) + +/** Legacy renders the sidebar twice while the drawer is open: hidden inline, and in the drawer. */ +export const filterRailOf = (model: Model, page: Html): Option.Option => + model.filterDrawer.isOpen + ? Option.map(splitPageRails(page).maybeFilters, (rail) => ({ + children: rail.children.map((child) => (child === null ? child : cloneNode(child))), + })) + : Option.none() + +/** `{drawerOpen ?

: null}` after the sr-only title. */ +export const filterDrawer = (h: HtmlBuilder, model: Model, maybeRail: Option.Option): Html => + h.submodel({ + slotId: `${model.filterDrawer.id}-dialog`, + model: model.filterDrawer, + view: DialogView.view, + viewInputs: { + className: "py-3", + side: "left", + hasDescription: false, + toView: ({ portal }) => portal, + toContent: () => [ + DialogView.dialogTitle(h, "sheet", model.filterDrawer.id, "Filters", "sr-only"), + ...Option.toArray(Option.map(maybeRail, (rail) => h.div([h.Class("h-full min-h-0")], rail.children))), + ], + }, + toParentMessage: (message: Dialog.Message) => Message.GotFilterDrawerMessage({ message }), + }) diff --git a/apps/local-ui-foldkit/src/shell/ingest-status.ts b/apps/local-ui-foldkit/src/shell/ingest-status.ts new file mode 100644 index 0000000000..f8486ad8cb --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/ingest-status.ts @@ -0,0 +1,84 @@ +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { TONE_BORDER, TONE_SOFT, type Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { statusDot } from "@maple/web-foldkit/ui/status-dot" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { MISSES_BEFORE_DOWN, type ServerState } from "../server/state" + +// apps/local-ui/src/components/ingest-status.tsx: is the server up, and is telemetry arriving? + +/** Newer than this reads as live: wider than the default metric export interval (60 s). */ +const FRESH_MS = 90_000 + +type IngestTone = "live" | "idle" | "warn" | "down" + +interface PillState { + readonly tone: IngestTone + readonly label: string + readonly title: string +} + +const STATUS_TONE = { live: "ok", idle: "neutral", warn: "warn", down: "crit" } satisfies Record + +/** copied: `describeStatus` (the React module also holds the component). */ +export const describeStatus = (maybeState: Option.Option, port: string): PillState => { + if (Option.isNone(maybeState)) return { tone: "idle", label: "Connecting", title: `Looking for Maple Local on port ${port}` } + const data = maybeState.value + const last = data.lastIngestAtMs + const lastLabel = last === null ? null : formatRelativeFrom(last, data.checkedAtMs) + if (data.reachability === "refused") + return data.misses >= MISSES_BEFORE_DOWN || !data.hasConnected + ? { tone: "down", label: "Offline", title: `Nothing is answering on port ${port}. Is maple start running?` } + : { tone: "warn", label: "Reconnecting", title: `Maple Local on port ${port} stopped answering` } + if (data.reachability === "rejected") + return { + tone: "down", + label: `Refused (${data.rejection?.status ?? "4xx"})`, + title: data.rejection?.detail || "Maple Local refused this page", + } + if (data.reachability === "failing") + return { + tone: "warn", + label: "Server error", + title: data.rejection?.detail || `Maple Local answered ${data.rejection?.status ?? "5xx"}`, + } + if (data.reachability === "busy") + return { tone: "warn", label: "Busy", title: "Maple Local is running a slow query and will answer when it finishes" } + if (last !== null && data.checkedAtMs - last < FRESH_MS) + return { tone: "live", label: "Receiving", title: `Last telemetry arrived ${lastLabel}` } + if (lastLabel !== null) + return { + tone: "idle", + label: `Last data ${lastLabel}`, + title: `Connected on port ${port}. Nothing new has arrived since ${lastLabel}.`, + } + return { + tone: "idle", + label: "Waiting for data", + title: data.legacy + ? `Connected on port ${port}. No spans or logs in the last 10 minutes.` + : `Connected on port ${port}. Nothing has been sent since the server started.`, + } +} + +export const ingestStatus = (h: HtmlBuilder, maybeState: Option.Option, port: string): Html => { + const state = describeStatus(maybeState, port) + return h.span( + [ + h.Title(state.title), + h.Class( + cn( + "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium tabular-nums group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", + TONE_SOFT[STATUS_TONE[state.tone]], + TONE_BORDER[STATUS_TONE[state.tone]], + ), + ), + ], + [ + statusDot(h, { tone: STATUS_TONE[state.tone] }), + h.span([h.Class("truncate group-data-[collapsible=icon]:sr-only")], [state.label]), + h.span([h.Class("sr-only")], [". ", state.title]), + ], + ) +} diff --git a/apps/local-ui-foldkit/src/shell/lockup.ts b/apps/local-ui-foldkit/src/shell/lockup.ts new file mode 100644 index 0000000000..d973bfda45 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/lockup.ts @@ -0,0 +1,23 @@ +import { icon } from "@maple/web-foldkit/ui/icon" +import type { Html, HtmlBuilder } from "foldkit/html" + +/** apps/local-ui/src/components/local-lockup.tsx: `maple` in the display face, `local` in mono. */ +export const localLockup = (h: HtmlBuilder): Html => + h.span( + [h.Class("flex shrink-0 items-center gap-1.5")], + [ + icon(h, "MapleMark", { size: 16, className: "shrink-0 text-primary" }), + h.span( + [h.Class("font-display text-sm font-semibold tracking-[-0.01em] group-data-[collapsible=icon]:hidden")], + ["maple"], + ), + h.span( + [ + h.Class( + "font-mono text-[11px] font-medium tracking-[0.08em] text-muted-foreground group-data-[collapsible=icon]:hidden", + ), + ], + ["local"], + ), + ], + ) diff --git a/apps/local-ui-foldkit/src/shell/mobile.test.ts b/apps/local-ui-foldkit/src/shell/mobile.test.ts new file mode 100644 index 0000000000..7e6cfdf219 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/mobile.test.ts @@ -0,0 +1,60 @@ +import { Option } from "effect" +import { fromString } from "foldkit/url" +import { describe, expect, test } from "vitest" +import { init } from "../main" +import { Message } from "../message" +import type { Model } from "../model" +import { PageOutMessage } from "../page/contract" +import { PageMessage } from "../page/registry" +import * as Traces from "../page/traces" +import { update } from "../update" +import { foldPageOut } from "../update-page" + +const flags = { + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + rememberedRange: "1h", + isSidebarOpen: true, + isMobile: true, + theme: "dark" as const, + timeFormat: { locale: "en-US", timeZone: "UTC" }, + hasSavedResults: false, +} +const urlOf = (hash: string) => Option.getOrThrow(fromString(`http://localhost/#${hash}`)) +const mobile = (): Model => init(flags, urlOf("/traces")).model +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) + +describe("mobile sidebar", () => { + test("the trigger opens and closes the sheet without touching the saved rail state", () => { + const opened = update(mobile(), Message.ClickedToggleSidebar()) + expect(opened.model.isSidebarOpenMobile).toBe(true) + expect(opened.model.isSidebarOpen).toBe(true) + expect(names(opened.commands)).toEqual([]) + expect(update(opened.model, Message.ClickedToggleSidebar()).model.isSidebarOpenMobile).toBe(false) + }) + + test("the backdrop and growing past md both close the sheet", () => { + const opened = update(mobile(), Message.ClickedToggleSidebar()).model + expect(update(opened, Message.DismissedMobileNav()).model.isSidebarOpenMobile).toBe(false) + const desktop = update(opened, Message.ChangedViewport({ isMobile: false })).model + expect(desktop.isSidebarOpenMobile).toBe(false) + expect(update(desktop, Message.ClickedToggleSidebar()).model.isSidebarOpen).toBe(false) + }) +}) + +describe("filters drawer", () => { + test("the page's Filters button opens it through the root", () => { + const step = update( + mobile(), + Message.GotPageMessage({ message: PageMessage.GotTracesMessage({ message: Traces.Message.ClickedOpenFilters() }) }), + ) + expect(step.model.filterDrawer.isOpen).toBe(true) + }) + + test("a filter change on the same page keeps it open; another page starts it closed", () => { + const opened = foldPageOut(mobile(), PageOutMessage.RequestedFilterDrawer()).model + const refiltered = update(opened, Message.ChangedUrl({ url: urlOf("/traces?service=storefront") })).model + expect(refiltered.filterDrawer.isOpen).toBe(true) + const elsewhere = update(refiltered, Message.ChangedUrl({ url: urlOf("/logs") })).model + expect(elsewhere.filterDrawer.isOpen).toBe(false) + }) +}) diff --git a/apps/local-ui-foldkit/src/shell/nav.ts b/apps/local-ui-foldkit/src/shell/nav.ts new file mode 100644 index 0000000000..8193b3f970 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/nav.ts @@ -0,0 +1,50 @@ +import type { IconName } from "@maple/web-foldkit/ui/icon" +import { AppRoute } from "../route" + +// apps/local-ui/src/components/app-sidebar.tsx: grouped and iconed like apps/web's sidebar. + +export type Section = "traces" | "logs" | "metrics" | "services" | "service-map" | "errors" | "sessions" | "query" + +export interface NavItem { + readonly section: Section + readonly label: string + readonly icon: IconName +} + +export const NAV_GROUPS: ReadonlyArray }>> = [ + { + label: "Monitor", + items: [ + { section: "services", label: "Services", icon: "ServerIcon" }, + { section: "service-map", label: "Service map", icon: "NetworkNodesIcon" }, + ], + }, + { + label: "Explore", + items: [ + { section: "traces", label: "Traces", icon: "PulseIcon" }, + { section: "logs", label: "Logs", icon: "FileIcon" }, + { section: "metrics", label: "Metrics", icon: "ChartLineIcon" }, + { section: "sessions", label: "Sessions", icon: "PlayRotateClockwiseIcon" }, + { section: "query", label: "Query", icon: "UiLayersIcon" }, + ], + }, + { label: "Triage", items: [{ section: "errors", label: "Errors", icon: "CircleWarningIcon" }] }, +] + +/** `activeSection`: a detail page lights its list's row. */ +export const activeSection = (route: AppRoute): Section => + AppRoute.match
(route, { + Traces: () => "traces", + TraceDetail: () => "traces", + Logs: () => "logs", + Metrics: () => "metrics", + MetricDetail: () => "metrics", + Services: () => "services", + ServiceDetail: () => "services", + ServiceMap: () => "service-map", + Errors: () => "errors", + Sessions: () => "sessions", + SessionDetail: () => "sessions", + Query: () => "query", + }) diff --git a/apps/local-ui-foldkit/src/shell/sidebar.ts b/apps/local-ui-foldkit/src/shell/sidebar.ts new file mode 100644 index 0000000000..20592ece26 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/sidebar.ts @@ -0,0 +1,107 @@ +import { ROW_LANE } from "@maple/ui/styles/ui/list-row" +import { sidebarMobileHeaderStyles, sidebarMobileInnerStyles, sidebarMobileStyles } from "@maple/ui/styles/ui/sidebar" +import { icon } from "@maple/web-foldkit/ui/icon" +import { leftSheet } from "@maple/web-foldkit/ui/sheet" +import { + sidebarContent, + sidebarDesktop, + sidebarFooter, + sidebarGroup, + sidebarGroupContent, + sidebarGroupLabel, + sidebarHeader, + sidebarMenu, + sidebarMenuButton, + sidebarMenuItem, + sidebarRail, + sidebarTrigger, +} from "@maple/web-foldkit/ui/sidebar" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "../message" +import type { Model } from "../model" +import { hrefFor } from "../route" +import { connectButton } from "./connect" +import { ingestStatus } from "./ingest-status" +import { localLockup } from "./lockup" +import { activeSection, NAV_GROUPS } from "./nav" + +/** Same selected-row rail as apps/web's sidebar: a 2px lane only the active row paints. */ +const ACTIVE_RAIL = `${ROW_LANE} data-[active=true]:rounded-l-none data-[active=true]:before:bg-sidebar-primary data-[active=true]:text-sidebar-primary` + +/** `AppSidebar`'s children, shared by the desktop rail and the mobile sheet. */ +const sidebarRegions = (h: HtmlBuilder, model: Model, linkQuery: URLSearchParams): ReadonlyArray => { + const active = activeSection(model.route) + const state = model.isSidebarOpen ? "expanded" : "collapsed" + return [ + sidebarHeader(h, [localLockup(h)], "h-12 flex-row items-center px-4 group-data-[collapsible=icon]:px-2"), + sidebarContent( + h, + NAV_GROUPS.map((group) => + sidebarGroup(h, undefined, [ + sidebarGroupLabel(h, "h-6 text-muted-foreground", [group.label]), + sidebarGroupContent(h, [ + sidebarMenu( + h, + undefined, + group.items.map((item) => + sidebarMenuItem(h, undefined, [ + sidebarMenuButton( + h, + { + className: ACTIVE_RAIL, + isActive: active === item.section, + href: hrefFor(`/${item.section}`, linkQuery), + hasTooltip: true, + attributes: active === item.section ? [h.AriaCurrent("page")] : [], + }, + [icon(h, item.icon, { size: 18 }), h.span([], [item.label])], + ), + ]), + ), + ), + ]), + ]), + ), + ), + sidebarFooter(h, [ + ingestStatus(h, model.maybeServer, model.env.port), + h.div( + [h.Class("flex items-center gap-1 group-data-[collapsible=icon]:flex-col")], + [ + connectButton(h, model), + sidebarTrigger(h, { + state, + className: "hidden size-8 md:inline-flex", + attributes: [h.OnClick(Message.ClickedToggleSidebar())], + }), + ], + ), + ]), + sidebarRail(h, Message.ClickedToggleSidebar()), + ] +} + +/** `AppSidebar` above `md`; `linkQuery` is the context every section link carries (range, service filter). */ +export const appSidebar = (h: HtmlBuilder, model: Model, linkQuery: URLSearchParams): Html => + sidebarDesktop(h, model.isSidebarOpen ? "expanded" : "collapsed", sidebarRegions(h, model, linkQuery)) + +/** `` below `md`: the same children in `SheetPopup side="left"` (packages/ui sidebar.tsx). */ +export const mobileSidebar = (h: HtmlBuilder, model: Model, linkQuery: URLSearchParams): Html => + leftSheet( + h, + { + id: "local-mobile-sidebar", + className: sidebarMobileStyles, + title: "Sidebar", + description: "Displays the mobile sidebar.", + headerClassName: sidebarMobileHeaderStyles, + slot: "sidebar", + popupAttributes: [ + h.DataAttribute("mobile", "true"), + h.DataAttribute("sidebar", "sidebar"), + h.Style({ "--nested-dialogs": "0", "--sidebar-width": "18rem" }), + ], + onDismiss: Message.DismissedMobileNav(), + }, + [h.div([h.Class(sidebarMobileInnerStyles)], sidebarRegions(h, model, linkQuery))], + ) diff --git a/apps/local-ui-foldkit/src/shell/stale-notice.test.ts b/apps/local-ui-foldkit/src/shell/stale-notice.test.ts new file mode 100644 index 0000000000..a79922cf12 --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/stale-notice.test.ts @@ -0,0 +1,55 @@ +import { Result } from "effect" +import { AsyncData } from "foldkit" +import { describe, expect, test } from "vitest" +import { shownDataOf } from "../data/shown" +import { idle } from "../data/slot" +import { describeNotice } from "./stale-notice" + +const NOW = Date.parse("2026-10-01T12:00:00Z") +const timeFormat = { locale: "en-US", timeZone: "UTC" } +const shownAt = (atMs: number, extra: Readonly<{ isStandIn?: boolean; isRefreshing?: boolean }> = {}) => ({ + rows: { + ...idle>(), + key: "k", + data: extra.isRefreshing === true ? AsyncData.Refreshing({ data: [1] }) : AsyncData.Success({ data: [1] }), + updatedAtMs: atMs, + isStandIn: extra.isStandIn ?? false, + }, +}) +const notice = (page: unknown, flags: Readonly<{ isDisconnected?: boolean; isBusy?: boolean }> = {}) => + describeNotice({ + isDisconnected: flags.isDisconnected ?? false, + isBusy: flags.isBusy ?? false, + shown: shownDataOf(page, NOW), + nowMs: NOW, + timeFormat, + }) + +describe("stale data notice", () => { + test("a stand-in from an older window says it is loading newer results", () => { + expect(notice(shownAt(NOW - 3_600_000, { isStandIn: true, isRefreshing: true }))?.text).toBe( + "Loading newer results. Showing results from 11:00 AM.", + ) + }) + + test("a busy server and a gone one say so; only the gone one offers Try again", () => { + const standIn = shownAt(NOW - 3_600_000, { isStandIn: true, isRefreshing: true }) + expect(notice(standIn, { isBusy: true })).toEqual({ tone: "warn", text: "Maple Local is busy. Showing results from 11:00 AM.", isRetrying: false }) + expect(notice(standIn, { isDisconnected: true })?.isRetrying).toBe(true) + }) + + test("fresh results on screen need no notice; a refetch of old ones does", () => { + expect(notice(shownAt(NOW - 5_000))).toBeNull() + expect(notice(shownAt(NOW - 5_000, { isRefreshing: true }))).toBeNull() + expect(notice(shownAt(NOW - 60_000, { isRefreshing: true }))?.tone).toBe("neutral") + }) + + test("an older day shows its date", () => { + expect(notice(shownAt(NOW - 26 * 3_600_000, { isStandIn: true }))?.text).toBe("Loading newer results. Showing results from Sep 30, 10:00 AM.") + }) + + test("an expanded error card's failed traces refresh counts as a failed refresh", () => { + const refreshed = { ...shownAt(NOW - 60_000).rows, data: AsyncData.settle(AsyncData.Refreshing({ data: [1] }), Result.fail({ message: "down" })) } + expect(shownDataOf({ cards: { abc: { isExpanded: true, traces: refreshed } } }, NOW).refreshFailed).toBe(true) + }) +}) diff --git a/apps/local-ui-foldkit/src/shell/stale-notice.ts b/apps/local-ui-foldkit/src/shell/stale-notice.ts new file mode 100644 index 0000000000..03e88c8e7c --- /dev/null +++ b/apps/local-ui-foldkit/src/shell/stale-notice.ts @@ -0,0 +1,62 @@ +import { TONE_BORDER, type Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { button } from "@maple/web-foldkit/ui/button" +import { statusDot } from "@maple/web-foldkit/ui/status-dot" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { ShownData } from "../data/shown" +import type { TimeFormat } from "../page/kit/local-time" + +// `StaleDataNotice` (apps/local-ui/src/components/stale-data-notice.tsx): says when the views +// show older results than the ones asked for, and why. Floats over the content. + +const dayKey = (format: TimeFormat, ms: number) => + new Intl.DateTimeFormat("en-US", { timeZone: format.timeZone, year: "numeric", month: "numeric", day: "numeric" }).format(ms) + +/** `formatAsOf`: "20:14" today, "Oct 14, 20:14" before that. */ +export const formatAsOf = (format: TimeFormat, ms: number, nowMs: number): string => { + const isSameDay = dayKey(format, ms) === dayKey(format, nowMs) + const options: Intl.DateTimeFormatOptions = isSameDay + ? { hour: "2-digit", minute: "2-digit" } + : { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" } + return new Intl.DateTimeFormat(format.locale, { ...options, timeZone: format.timeZone }).format(ms) +} + +export type Notice = Readonly<{ tone: Tone; text: string; isRetrying: boolean }> + +/** `describeNotice`: none while nothing on screen is older than asked for. */ +export const describeNotice = ( + input: Readonly<{ isDisconnected: boolean; isBusy: boolean; shown: ShownData; nowMs: number; timeFormat: TimeFormat }>, +): Notice | null => { + const asOf = (ms: number | null) => (ms === null ? "" : ` Showing results from ${formatAsOf(input.timeFormat, ms, input.nowMs)}.`) + const { shown } = input + if (input.isDisconnected) return { tone: "crit", text: `Can’t reach Maple Local.${asOf(shown.shownAsOfMs)}`, isRetrying: true } + if (shown.staleAsOfMs === null) return null + if (shown.refreshFailed) return { tone: "warn", text: `Couldn’t refresh.${asOf(shown.staleAsOfMs)}`, isRetrying: true } + if (input.isBusy) return { tone: "warn", text: `Maple Local is busy.${asOf(shown.staleAsOfMs)}`, isRetrying: false } + return { tone: "neutral", text: `Loading newer results.${asOf(shown.staleAsOfMs)}`, isRetrying: false } +} + +export const staleNotice = (h: HtmlBuilder, notice: Notice, onRetry: M): Html => + h.div( + [h.Class("pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center px-4")], + [ + h.output( + [ + h.Class( + cn( + "stale-notice pointer-events-auto flex max-w-full items-center gap-2 rounded-full border bg-popover py-1 pr-1 pl-3 text-xs text-popover-foreground shadow-sm", + TONE_BORDER[notice.tone], + !notice.isRetrying && "pr-3", + ), + ), + ], + [ + statusDot(h, { tone: notice.tone }), + h.span([h.Class("truncate")], [notice.text]), + ...(notice.isRetrying + ? [button(h, { variant: "ghost", size: "xs", className: "rounded-full", attributes: [h.OnClick(onRetry)] }, ["Try again"])] + : []), + ], + ), + ], + ) diff --git a/apps/local-ui-foldkit/src/styles.css b/apps/local-ui-foldkit/src/styles.css new file mode 100644 index 0000000000..ea54044f97 --- /dev/null +++ b/apps/local-ui-foldkit/src/styles.css @@ -0,0 +1,14 @@ +/* apps/local-ui's stylesheet, imported in place so both builds emit the same rules. */ +@import "../../local-ui/src/styles.css"; + +/* apps/local-ui's utilities come from scanning its own sources; scan them too, plus this app's views. */ +@source "../../local-ui/src"; +@source "./"; + +/* Base UI injects this rule from `utils/styles.mjs` for scroll area viewports and select lists. */ +.base-ui-disable-scrollbar { + scrollbar-width: none; +} +.base-ui-disable-scrollbar::-webkit-scrollbar { + display: none; +} diff --git a/apps/local-ui-foldkit/src/subscription.ts b/apps/local-ui-foldkit/src/subscription.ts new file mode 100644 index 0000000000..246ec873b1 --- /dev/null +++ b/apps/local-ui-foldkit/src/subscription.ts @@ -0,0 +1,44 @@ +import { Option, Stream } from "effect" +import { Dom, Subscription } from "foldkit" +import { MOBILE_QUERY } from "./env" +import { Message } from "./message" +import type { Model } from "./model" + +// The OS theme and the sidebar's ⌘B / Ctrl+B shortcut (`SidebarProvider`). + +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +const colorScheme: Stream.Stream = Dom.streamFromMediaQuery({ + query: "(prefers-color-scheme: light)", + mapMatches: (prefersLight) => Message.ChangedColorScheme({ theme: prefersLight ? "light" : "dark" }), +}) + +const sidebarShortcut: Stream.Stream = Dom.streamFromEventFilterMap({ + target: () => window, + type: "keydown", + filterMapEvent: (event): Option.Option => { + if (event.key !== SIDEBAR_KEYBOARD_SHORTCUT || !(event.metaKey || event.ctrlKey)) return Option.none() + event.preventDefault() + return Option.some(Message.PressedSidebarShortcut()) + }, +}) + +const viewportChanges: Stream.Stream = Dom.streamFromMediaQuery({ + query: MOBILE_QUERY, + mapMatches: (isMobile) => Message.ChangedViewport({ isMobile }), +}) + +export const subscriptions = Subscription.make()((entry) => ({ + viewport: entry({}, { + modelToDependencies: () => ({}), + dependenciesToStream: () => viewportChanges, + }), + colorScheme: entry({}, { + modelToDependencies: () => ({}), + dependenciesToStream: () => colorScheme, + }), + sidebarShortcut: entry({}, { + modelToDependencies: () => ({}), + dependenciesToStream: () => sidebarShortcut, + }), +})) diff --git a/apps/local-ui-foldkit/src/update-page.test.ts b/apps/local-ui-foldkit/src/update-page.test.ts new file mode 100644 index 0000000000..9e0bf52973 --- /dev/null +++ b/apps/local-ui-foldkit/src/update-page.test.ts @@ -0,0 +1,86 @@ +import { Option } from "effect" +import { AsyncData } from "foldkit" +import { fromString } from "foldkit/url" +import { describe, expect, test } from "vitest" +import { FetchSignalPresence, ProbeServer } from "./command" +import { init } from "./main" +import type { Model } from "./model" +import { PageOutMessage } from "./page/contract" +import { Message as LogsMessage } from "./page/logs/message" +import { PageMessage } from "./page/registry" +import { update } from "./update" +import { foldPageOut, updatePageMessage } from "./update-page" +import { Message } from "./message" + +const flags = { + env: { apiBase: "", port: "4318", otlpEndpoint: "http://127.0.0.1:4318", isHosted: false }, + rememberedRange: "1h", + isSidebarOpen: true, + isMobile: false, + theme: "dark" as const, + timeFormat: { locale: "en-US", timeZone: "UTC" }, + hasSavedResults: false, +} +const url = Option.getOrThrow(fromString("http://localhost/#/traces")) + +/** Settled: the first probe answered and a fresh presence read is on hand. */ +const settled = (): Model => ({ + ...init(flags, url).model, + probe: { isInFlight: false, version: 1 }, + presence: AsyncData.Success({ data: { rows: [], nowMs: 1_000 } }), +}) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => (commands ?? []).map(({ name }) => name) +const argsOf = (commands: ReadonlyArray<{ readonly args?: unknown }> | undefined) => (commands ?? []).map(({ args }) => args) + +describe("refresh in place", () => { + test("re-probes the server, and the next empty state reads the presence again", () => { + const refreshed = foldPageOut(settled(), PageOutMessage.RefetchedInPlace()) + expect(names(refreshed.commands)).toEqual([ProbeServer.name]) + const requested = foldPageOut(refreshed.model, PageOutMessage.RequestedSignalPresence()) + expect(names(requested.commands)).toEqual([FetchSignalPresence.name]) + expect(argsOf(requested.commands)).toEqual([{ apiBase: "", lastReadAtMs: null }]) + }) + + test("without a refresh, a fresh presence read is offered for reuse", () => { + const requested = foldPageOut(settled(), PageOutMessage.RequestedSignalPresence()) + expect(argsOf(requested.commands)).toEqual([{ apiBase: "", lastReadAtMs: 1_000 }]) + }) +}) + +describe("superseded requests", () => { + const urlOf = (hash: string) => Option.getOrThrow(fromString(`http://localhost/${hash}`)) + const NOW = Date.parse("2026-10-01T12:00:30Z") + /** /logs with its list and both facets in flight. */ + const loadingLogs = (): Model => { + const opened = update(settled(), Message.ChangedUrl({ url: urlOf("#/logs") })).model + const clocked = PageMessage.GotLogsMessage({ message: LogsMessage.CompletedReadClock({ nowMs: NOW }) }) + return updatePageMessage(opened, clocked).model + } + const stops = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + names(commands).filter((name) => name.endsWith(".Interrupt")) + + test("a new search stops the list and facet requests it supersedes", () => { + const searched = update(loadingLogs(), Message.ChangedUrl({ url: urlOf("#/logs?q=timeout") })) + expect(stops(searched.commands)).toEqual([ + "FetchLocalLogs.Interrupt", + "FetchLocalLogServices.Interrupt", + "FetchLocalLogSeverities.Interrupt", + ]) + }) + + test("leaving the page stops what it still has in flight", () => { + const left = update(loadingLogs(), Message.ChangedUrl({ url: urlOf("#/traces") })) + expect(stops(left.commands)).toEqual([ + "FetchLocalLogs.Interrupt", + "FetchLocalLogServices.Interrupt", + "FetchLocalLogSeverities.Interrupt", + ]) + }) + + test("an answer that lands stops nothing", () => { + const model = loadingLogs() + const key = model.page._tag === "LogsPage" ? (model.page.logs.key ?? "") : "" + const answer = LogsMessage.SucceededFetchLogs({ key, data: { pages: [[]], hasNextPage: false }, fetchedAtMs: NOW }) + expect(stops(updatePageMessage(model, PageMessage.GotLogsMessage({ message: answer })).commands)).toEqual([]) + }) +}) diff --git a/apps/local-ui-foldkit/src/update-page.ts b/apps/local-ui-foldkit/src/update-page.ts new file mode 100644 index 0000000000..823a99db96 --- /dev/null +++ b/apps/local-ui-foldkit/src/update-page.ts @@ -0,0 +1,100 @@ +import type { Resources } from "./platform/resources" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import { AsyncData, Command } from "foldkit" +import { Option } from "effect" +import { FetchSignalPresence, GoBack, Navigate, ProbeServer, RememberRange } from "./command" +import { Message } from "./message" +import { initFilterDrawer, type Model } from "./model" +import { canGoBack } from "./navigation" +import { shownDataOf } from "./data/shown" +import { type PageContext, PageOutMessage } from "./page/contract" +import { stopSuperseded } from "./data/lanes" +import { initPage, lanesOf, locationChanged, pageKeyOf, type PageMessage, refreshPage, updatePage } from "./page/registry" + +export type RootReturn = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +export const pageContextOf = (model: Model): PageContext => ({ + env: model.env, + location: model.location, + rememberedRange: model.rememberedRange, + timeFormat: model.timeFormat, + canGoBack: canGoBack(model.visited), +}) + +const toPageMessage = (message: PageMessage) => Message.GotPageMessage({ message }) + +/** `useSignalPresence` mounting: reuse the shared read unless it is loading or stale. */ +const requestPresence = (model: Model): RootReturn => { + if (AsyncData.isLoading(model.presence) || AsyncData.isRefreshing(model.presence)) return { model } + const lastReadAtMs = model.isPresenceStale + ? null + : Option.match(AsyncData.getData(model.presence), { onNone: () => null, onSome: (result) => result.nowMs }) + const presence = Option.getOrElse(AsyncData.revalidateOrLoad(model.presence), () => model.presence) + return { model: { ...model, presence, isPresenceStale: false }, commands: [FetchSignalPresence({ apiBase: model.env.apiBase, lastReadAtMs })] } +} + +/** One step of the Filters drawer's Dialog, its Commands lifted. */ +export const filterDrawerStep = (model: Model, step: ReturnType): RootReturn => ({ + model: { ...model, filterDrawer: step.model }, + commands: Command.mapMessages(step.commands ?? [], (message) => Message.GotFilterDrawerMessage({ message })), +}) + +/** Start a probe now, retiring any pending timer. */ +export const probe = (model: Model): RootReturn => + model.probe.isInFlight + ? { model } + : { + model: { ...model, probe: { isInFlight: true, version: model.probe.version + 1 } }, + commands: [ProbeServer({ apiBase: model.env.apiBase })], + } + +/** A page asked the root for something only the root may do. */ +export const foldPageOut = (model: Model, outMessage: PageOutMessage): RootReturn => + PageOutMessage.match(outMessage, { + RequestedNavigation: ({ hash, isReplace }) => ({ + model: { ...model, isReplacing: isReplace }, + commands: [Navigate({ hash, isReplace })], + }), + RequestedBack: ({ fallbackHash }) => + canGoBack(model.visited) + ? { model, commands: [GoBack()] } + : { model, commands: [Navigate({ hash: fallbackHash, isReplace: false })] }, + RememberedRange: ({ range, hash }) => ({ + model: { ...model, rememberedRange: range, isReplacing: true }, + commands: [RememberRange({ range }), Navigate({ hash, isReplace: true })], + }), + RequestedSignalPresence: () => requestPresence(model), + RefetchedInPlace: () => probe({ ...model, isPresenceStale: true }), + RequestedFilterDrawer: () => filterDrawerStep(model, Dialog.open(model.filterDrawer)), + }) + +/** Folds one page step's Model, Commands and OutMessage into the root. */ +const applyPage = ( + model: Model, + step: Readonly<{ model: Model["page"]; commands?: ReadonlyArray>; outMessage?: PageOutMessage }>, +): RootReturn => { + const withPage = { ...model, page: step.model, hasCache: model.hasCache || shownDataOf(step.model, 0).hasData } + const stops = stopSuperseded(lanesOf(model.page), lanesOf(step.model), (outcome) => Message.CompletedCancelPageFetch({ outcome })) + const pageCommands = [...stops, ...Command.mapMessages(step.commands ?? [], toPageMessage)] + if (step.outMessage === undefined) return { model: withPage, commands: pageCommands } + const folded = foldPageOut(withPage, step.outMessage) + return { model: folded.model, commands: [...pageCommands, ...(folded.commands ?? [])] } +} + +/** A new route builds a new page; the same page sees the new fragment as `locationChanged`. */ +export const enterLocation = (model: Model, previousKey: string | null): RootReturn => { + const context = pageContextOf(model) + return previousKey === pageKeyOf(model.route) + ? applyPage(model, locationChanged(model.page, context)) + : applyPage({ ...model, filterDrawer: initFilterDrawer() }, initPage(model.route, context)) +} + +export const updatePageMessage = (model: Model, message: PageMessage): RootReturn => + applyPage(model, updatePage(model.page, message, pageContextOf(model))) + +/** The notice's Try again: probe now and refetch the open page (`refetchQueries(["local"])`). */ +export const retryStaleData = (model: Model): RootReturn => { + const probed = probe(model) + const refreshed = applyPage(probed.model, refreshPage(probed.model.page, pageContextOf(probed.model))) + return { model: refreshed.model, commands: [...(probed.commands ?? []), ...(refreshed.commands ?? [])] } +} diff --git a/apps/local-ui-foldkit/src/update.ts b/apps/local-ui-foldkit/src/update.ts new file mode 100644 index 0000000000..ed44279fc0 --- /dev/null +++ b/apps/local-ui-foldkit/src/update.ts @@ -0,0 +1,120 @@ +import type { Resources } from "./platform/resources" +import * as Dialog from "@maple/web-foldkit/ui/dialog" +import * as Popover from "@maple/web-foldkit/ui/popover" +import * as Tabs from "@maple/web-foldkit/ui/tabs" +import * as Toast from "@maple/web-foldkit/ui/toast" +import { Option, Result } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import { UrlRequest } from "foldkit/navigation" +import { ApplyTheme, LoadExternal, Navigate, SaveSidebarState, WaitBeforeProbe } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { hashOf, locationOf, routeOf, trackVisit } from "./navigation" +import { updateCopy } from "./page/kit/copies" +import { pageKeyOf } from "./page/registry" +import { nextProbeDelayMs, nextServerState } from "./server/derive" +import { enterLocation, filterDrawerStep, probe, retryStaleData, type RootReturn, updatePageMessage } from "./update-page" + +type UpdateReturn = Update.Return + +/** `toggleSidebar`: the sheet below `md`, else the rail (and its cookie). */ +const toggleSidebar = (model: Model): RootReturn => { + if (model.isMobile) return { model: { ...model, isSidebarOpenMobile: !model.isSidebarOpenMobile } } + const isOpen = !model.isSidebarOpen + return { model: { ...model, isSidebarOpen: isOpen }, commands: [SaveSidebarState({ isOpen })] } +} + +/** A hash link: the browser ignores a click on the current hash; anything else is a new entry. */ +const followLink = (model: Model, request: UrlRequest): RootReturn => + UrlRequest.match(request, { + Internal: ({ url }) => + hashOf(url) === (model.visited[model.visited.length - 1] ?? "") + ? { model: { ...model, isSidebarOpenMobile: false } } + : { + model: { ...model, isSidebarOpenMobile: false }, + commands: [Navigate({ hash: `#${hashOf(url)}`, isReplace: false })], + }, + External: ({ href }) => ({ model, commands: [LoadExternal({ href })] }), + }) + +/** The fragment changed (a link, Back, or a page's own navigation). */ +const changeUrl = (model: Model, url: Parameters[0]): RootReturn => { + const location = locationOf(url) + const previousKey = pageKeyOf(model.route) + const next = { + ...model, + location, + route: routeOf(location), + visited: trackVisit(model.visited, hashOf(url), model.isReplacing), + isReplacing: false, + } + return enterLocation(next, previousKey) +} + +const presenceSettled = (model: Model, presence: Model["presence"]): RootReturn => ({ model: { ...model, presence } }) + +export const update = (model: Model, message: Message): UpdateReturn => + Message.match(message, { + ClickedLink: ({ request }) => followLink(model, request), + ChangedUrl: ({ url }) => changeUrl(model, url), + CompletedNavigate: () => ({ model }), + CompletedRememberRange: () => ({ model }), + CompletedProbeServer: ({ outcome, checkedAtMs }) => { + const server = nextServerState(model.maybeServer, outcome, checkedAtMs) + const version = model.probe.version + 1 + return { + model: { + ...model, + maybeServer: Option.some(server), + probe: { isInFlight: false, version }, + hasIngested: model.hasIngested || server.lastIngestAtMs !== null, + }, + commands: [WaitBeforeProbe({ version, delayMs: nextProbeDelayMs(server) })], + } + }, + ElapsedProbeDelay: ({ version }) => (version === model.probe.version ? probe(model) : { model }), + ClickedRetryConnection: () => probe(model), + ClickedRetryStaleData: () => retryStaleData(model), + ClickedToggleSidebar: () => toggleSidebar(model), + PressedSidebarShortcut: () => toggleSidebar(model), + CompletedSaveSidebarState: () => ({ model }), + ChangedViewport: ({ isMobile }) => ({ model: { ...model, isMobile, isSidebarOpenMobile: false } }), + DismissedMobileNav: () => ({ model: { ...model, isSidebarOpenMobile: false } }), + GotFilterDrawerMessage: ({ message: child }) => filterDrawerStep(model, Dialog.update(model.filterDrawer, child)), + GotInstallTabsMessage: ({ message: child }) => { + const next = Tabs.update(model.installTabs, child) + return { + model: { ...model, installTabs: next.model }, + commands: Command.mapMessages(next.commands ?? [], (tabs) => Message.GotInstallTabsMessage({ message: tabs })), + } + }, + GotConnectMessage: ({ message: child }) => { + const next = Popover.update(model.connect, child) + return { + model: { ...model, connect: next.model }, + commands: Command.mapMessages(next.commands ?? [], (popover) => Message.GotConnectMessage({ message: popover })), + } + }, + ChangedColorScheme: ({ theme }) => ({ model: { ...model, theme }, commands: [ApplyTheme({ theme })] }), + CompletedApplyTheme: () => ({ model }), + SucceededFetchSignalPresence: ({ result }) => presenceSettled(model, AsyncData.Success({ data: result })), + FailedFetchSignalPresence: ({ failure }) => presenceSettled(model, AsyncData.settle(model.presence, Result.fail(failure))), + ReusedSignalPresence: () => + presenceSettled( + model, + Option.match(AsyncData.getData(model.presence), { onNone: () => model.presence, onSome: (data) => AsyncData.Success({ data }) }), + ), + GotCopyMessage: ({ id, message: child }) => { + const next = updateCopy(model.copies, id, child, (copyId, copyMessage) => Message.GotCopyMessage({ id: copyId, message: copyMessage })) + return { model: { ...model, copies: next.copies }, commands: next.commands } + }, + GotToastMessage: ({ message: child }) => { + const next = Toast.update(model.toasts, child) + return { + model: { ...model, toasts: next.model }, + commands: Command.mapMessages(next.commands ?? [], (toast) => Message.GotToastMessage({ message: toast })), + } + }, + GotPageMessage: ({ message: child }) => updatePageMessage(model, child), + CompletedCancelPageFetch: () => ({ model }), + }) diff --git a/apps/local-ui-foldkit/src/view.ts b/apps/local-ui-foldkit/src/view.ts new file mode 100644 index 0000000000..ffe0abd451 --- /dev/null +++ b/apps/local-ui-foldkit/src/view.ts @@ -0,0 +1,113 @@ +import { sidebarInset, sidebarTrigger, sidebarWrapper } from "@maple/web-foldkit/ui/sidebar" +import * as ToastView from "@maple/web-foldkit/ui/toast-view" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Document, Html, HtmlBuilder } from "foldkit/html" +import { presenceOf } from "./data/signal-presence" +import { Message } from "./message" +import type { Model } from "./model" +import { rangeOf } from "./page/contract" +import { viewPage } from "./page/registry" +import { queryOf } from "./route" +import { connectionOf } from "./server/derive" +import { disconnectedState, rejectedState } from "./shell/connection-states" +import { shownDataOf } from "./data/shown" +import { describeNotice, staleNotice } from "./shell/stale-notice" +import { localLockup } from "./shell/lockup" +import { filterDrawer, filterRailOf } from "./shell/filter-drawer" +import { appSidebar, mobileSidebar } from "./shell/sidebar" +import { pageContextOf } from "./update-page" + +// apps/local-ui/src/App.tsx. + +/** Section links carry the cross-cutting context: the service filter and the range. */ +const sectionQuery = (model: Model): URLSearchParams => { + const context = pageContextOf(model) + const next = new URLSearchParams({ range: rangeOf(context) }) + const service = queryOf(model.location).get("service") + if (service) next.set("service", service) + return next +} + +const pageView = (h: HtmlBuilder, model: Model): Html => { + const presence = AsyncData.getData(model.presence) + const lastIngest = Option.flatMap(model.maybeServer, (server) => Option.fromNullishOr(server.lastIngestAtMs)) + return viewPage( + h, + model.page, + { + context: pageContextOf(model), + presenceOf: (signal) => presenceOf(presence, signal), + presenceNowMs: Option.match(presence, { onNone: () => 0, onSome: (result) => result.nowMs }), + clockMs: Option.match(model.maybeServer, { onNone: () => 0, onSome: (server) => server.checkedAtMs }), + hasNewDataSince: (sinceMs) => sinceMs > 0 && Option.exists(lastIngest, (last) => last > sinceMs), + }, + (message) => Message.GotPageMessage({ message }), + ) +} + +/** + * Only a server that is really gone (or refusing this page) swaps the views out. A gone server + * with saved results keeps them, and the notice says what they are. + */ +const mainContent = (h: HtmlBuilder, model: Model, page: Html): ReadonlyArray => { + const connection = connectionOf(model.maybeServer) + const isDisconnected = connection.status === "disconnected" + if (isDisconnected && !model.hasCache) return [disconnectedState(h, model)] + if (connection.status === "rejected" && connection.rejection !== null) return [rejectedState(h, connection.rejection)] + if (connection.status === "rejected") return [page] + const nowMs = Option.match(model.maybeServer, { onNone: () => 0, onSome: (server) => server.checkedAtMs }) + const notice = describeNotice({ + isDisconnected, + isBusy: Option.exists(model.maybeServer, (server) => server.reachability === "busy"), + shown: shownDataOf(model.page, nowMs), + nowMs, + timeFormat: model.timeFormat, + }) + return [page, ...(notice === null ? [] : [staleNotice(h, notice, Message.ClickedRetryStaleData())])] +} + +// NOTE: Foldkit's first render replaces the container, so the view root is `div#app` itself. +// The toast regions go first: their Mount moves them to . +export const view = (model: Model, h: HtmlBuilder): Document => { + const page = pageView(h, model) + return { + title: "Maple Local", + body: h.div( + [h.Id("app")], + [ + ...ToastView.regions(h, model.toasts, (message) => Message.GotToastMessage({ message })), + sidebarWrapper( + h, + [ + ...(model.isMobile ? [] : [appSidebar(h, model, sectionQuery(model))]), + sidebarInset( + h, + [ + h.header( + [h.Class("flex h-12 shrink-0 items-center gap-2 border-b px-4 md:hidden")], + [ + sidebarTrigger(h, { + state: model.isSidebarOpen ? "expanded" : "collapsed", + className: "-ml-1", + attributes: [h.OnClick(Message.ClickedToggleSidebar())], + }), + localLockup(h), + ], + ), + h.div([h.Class("relative min-h-0 flex-1")], mainContent(h, model, page)), + ], + "h-dvh text-foreground", + ), + ], + // A modal sheet hides the page behind it from assistive tech, like Base UI's `aria-hidden` root. + model.isMobile && model.isSidebarOpenMobile ? [h.AriaHidden(true)] : [], + "min-h-dvh", + ), + // Base UI's sheet portals, after the frame. + ...(model.isMobile && model.isSidebarOpenMobile ? [mobileSidebar(h, model, sectionQuery(model))] : []), + filterDrawer(h, model, filterRailOf(model, page)), + ], + ), + } +} diff --git a/apps/local-ui-foldkit/src/vitest-setup.ts b/apps/local-ui-foldkit/src/vitest-setup.ts new file mode 100644 index 0000000000..8fb7bedd3d --- /dev/null +++ b/apps/local-ui-foldkit/src/vitest-setup.ts @@ -0,0 +1,3 @@ +import { setup } from "foldkit/test/vitest" + +setup() diff --git a/apps/local-ui-foldkit/tsconfig.json b/apps/local-ui-foldkit/tsconfig.json new file mode 100644 index 0000000000..fcb7eca386 --- /dev/null +++ b/apps/local-ui-foldkit/tsconfig.json @@ -0,0 +1,33 @@ +{ + "include": ["src/**/*.ts", "vite.config.ts", "vitest.config.ts"], + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@/*": ["./src/*"], + "@maple/ui/styles/ui/*": ["../../packages/ui/src/components/ui/*.styles.ts"], + "@maple/ui/*": ["../../packages/ui/src/*"], + "@maple/web-foldkit/*": ["../web-foldkit/src/*"], + "@maple/local-ui/*": ["../local-ui/src/*"], + "@maple/web/*": ["../web/src/*"] + }, + "plugins": [ + { + "name": "@effect/language-service", + "reportSuggestionsAsWarningsInTsc": true + } + ] + } +} diff --git a/apps/local-ui-foldkit/vite.config.ts b/apps/local-ui-foldkit/vite.config.ts new file mode 100644 index 0000000000..327718a67e --- /dev/null +++ b/apps/local-ui-foldkit/vite.config.ts @@ -0,0 +1,26 @@ +import path from "node:path" +import { foldkit } from "@foldkit/vite-plugin" +import tailwindcss from "@tailwindcss/vite" +import { defineConfig } from "vite" + +// Same serving model as apps/local-ui: the `maple` binary serves the static build and owns +// `/local/*` and `/v1/*`; in dev Vite proxies both to a running binary. +const LOCAL_BINARY_URL = process.env.MAPLE_LOCAL_URL ?? "http://127.0.0.1:4318" + +export default defineConfig(({ command }) => ({ + // apps/local-ui's favicons. + publicDir: path.resolve(import.meta.dirname, "../local-ui/public"), + resolve: { tsconfigPaths: true }, + plugins: [ + tailwindcss(), + // The DevTools MCP relay only exists on the dev server. + foldkit(command === "serve" ? {} : { devToolsMcpPort: false }), + ], + build: { outDir: "dist", emptyOutDir: true }, + server: { + proxy: { + "/local": { target: LOCAL_BINARY_URL, changeOrigin: true }, + "/v1": { target: LOCAL_BINARY_URL, changeOrigin: true }, + }, + }, +})) diff --git a/apps/local-ui-foldkit/vitest.config.ts b/apps/local-ui-foldkit/vitest.config.ts new file mode 100644 index 0000000000..5f57fe4c25 --- /dev/null +++ b/apps/local-ui-foldkit/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + // Scene runs on VNodes: no DOM environment is needed. + environment: "node", + include: ["src/**/*.test.ts"], + setupFiles: ["./src/vitest-setup.ts"], + server: { deps: { inline: ["foldkit", "@foldkit/ui"] } }, + }, +}) diff --git a/apps/web-foldkit/index.html b/apps/web-foldkit/index.html new file mode 100644 index 0000000000..3a720c149f --- /dev/null +++ b/apps/web-foldkit/index.html @@ -0,0 +1,521 @@ + + + + + + Maple + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +

Collecting spans…

+
+
+ + + diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json new file mode 100644 index 0000000000..087a41134a --- /dev/null +++ b/apps/web-foldkit/package.json @@ -0,0 +1,62 @@ +{ + "name": "@maple/web-foldkit", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port ${PORT:-3481} --host ${HOST:-127.0.0.1}", + "build": "vite build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsrs --noEmit" + }, + "dependencies": { + "@effect/platform-browser": "4.0.4", + "@floating-ui/dom": "1.8.0", + "@foldkit/markdown": "0.14.3", + "@foldkit/ui": "0.168.0", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@maple-dev/effect-sdk": "workspace:*", + "@maple/agent-sessions": "workspace:*", + "@maple/chat-platform": "workspace:*", + "@maple/domain": "workspace:*", + "@maple/effect-db": "workspace:*", + "@maple/grid-engine": "workspace:*", + "@maple/query-engine": "workspace:*", + "@maple/query-model": "workspace:*", + "@maple/web": "workspace:*", + "@maple/web-data": "workspace:*", + "@maple/widgets": "workspace:*", + "@rrweb/replay": "^2.1.7", + "@tanstack/charts": "catalog:charts", + "@tanstack/charts-scales": "catalog:charts", + "@tanstack/db": "0.11.3", + "@tanstack/virtual-core": "3.17.11", + "@xyflow/system": "0.0.83", + "class-variance-authority": "catalog:ui", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "effect": "catalog:effect", + "foldkit": "0.168.0", + "fuse.js": "7.5.0", + "katex": "0.16.47", + "mermaid": "11.17.2", + "shiki": "4.4.3", + "three": "^0.186.1", + "uqr": "^0.1.3" + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@foldkit/vite-plugin": "0.27.1", + "@tailwindcss/vite": "catalog:tailwind", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.2.0", + "@types/node": "catalog:tooling", + "@types/three": "^0.186.0", + "playwright": "catalog:", + "tailwindcss": "catalog:tailwind", + "tw-animate-css": "catalog:tailwind", + "typescript": "catalog:tooling", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/web-foldkit/perf/service-map-bench.ts b/apps/web-foldkit/perf/service-map-bench.ts new file mode 100644 index 0000000000..def09ed240 --- /dev/null +++ b/apps/web-foldkit/perf/service-map-bench.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync, statSync } from "node:fs" +import { extname, join, resolve } from "node:path" +import { chromium, type Page } from "playwright" + +/** + * The measurements of apps/web/perf/service-map.perf.spec.ts (readiness, idle, pan and fit-all + * frame timing at 120 services / 400 edges) against static parity builds of both apps. + * Run: `bun apps/web-foldkit/perf/service-map-bench.ts [--runs 5] [--base 5290]`, after + * `parity build legacy-head` and `parity build foldkit` with the same PARITY_PORT_BASE. + */ + +const args = process.argv.slice(2) +const flag = (name: string, fallback: string) => { + const index = args.indexOf(`--${name}`) + return index >= 0 ? (args[index + 1] ?? fallback) : fallback +} +const RUNS = Number(flag("runs", "5")) +const BASE = flag("base", "5290") +const BENCH = "/lab/bench/service-map?services=120&edges=400&rps=high&seed=1" +const root = resolve(import.meta.dirname, "../../..") +const builds = { legacy: "legacy-head", foldkit: "foldkit" } as const + +const TYPES = new Map([ + [".html", "text/html"], + [".js", "text/javascript"], + [".css", "text/css"], + [".woff2", "font/woff2"], + [".svg", "image/svg+xml"], + [".png", "image/png"], + [".json", "application/json"], +]) + +/** SPA static server over one build directory. */ +const serve = (dir: string, port: number) => + Bun.serve({ + port, + fetch: (request) => { + const path = join(dir, decodeURIComponent(new URL(request.url).pathname)) + const file = existsSync(path) && statSync(path).isFile() ? path : join(dir, "index.html") + return new Response(readFileSync(file), { + headers: { "content-type": TYPES.get(extname(file)) ?? "application/octet-stream" }, + }) + }, + }) + +interface Frames { + readonly fps: number + readonly frameP50: number + readonly frameP95: number + readonly longTasks: number + readonly totalBlockingMs: number +} + +interface Milestones { + firstMap?: number + elkReady?: number +} + +/** Navigation-relative milestones: first map paint (the 2 s fallback layout) and ELK's final layout. */ +const recordMilestones = () => { + const marks: Milestones = {} + window.__s5Marks = marks + new MutationObserver(() => { + if (marks.firstMap === undefined && document.querySelector(".react-flow__edge")) + marks.firstMap = performance.now() + if (marks.elkReady === undefined && document.querySelector('[data-elk-status="ready"]')) + marks.elkReady = performance.now() + }).observe(document, { subtree: true, childList: true, attributes: true }) +} + +const measure = async (page: Page, url: string) => { + await page.addInitScript(recordMilestones) + await page.goto(url) + await page.waitForFunction(() => window.__smBench?.ready === true, undefined, { timeout: 90_000 }) + const readyMs = await page.evaluate(() => window.__s5Marks?.elkReady ?? -1) + const firstMapMs = await page.evaluate(() => window.__s5Marks?.firstMap ?? -1) + await page.evaluate(async () => { + window.__smBench?.setCamera({ x: 0, y: 0, zoom: 1 }) + await window.__smBench?.waitForQuiet() + }) + const idle = await page.evaluate(() => window.__smBench?.run({ durationMs: 4000, pan: false })) + const pan = await page.evaluate(() => window.__smBench?.run({ durationMs: 4000, pan: true })) + const fitAll = await page.evaluate(async () => { + window.__smBench?.fitCamera() + await new Promise((done) => setTimeout(done, 300)) + return await window.__smBench?.run({ durationMs: 2000, pan: false }) + }) + return { readyMs, firstMapMs, idle, pan, fitAll } +} + +type Sample = Awaited> + +const pct = (values: ReadonlyArray, p: number) => { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((p / 100) * (sorted.length - 1))))] +} + +const summarize = (samples: ReadonlyArray) => { + const rows = { + "first map ms": samples.map((s) => s.firstMapMs), + "ELK ready ms": samples.map((s) => s.readyMs), + "idle fps": samples.map((s) => s.idle?.fps ?? Number.NaN), + "idle frame p50": samples.map((s) => s.idle?.frameP50 ?? Number.NaN), + "idle frame p95": samples.map((s) => s.idle?.frameP95 ?? Number.NaN), + "idle TBT ms": samples.map((s) => s.idle?.totalBlockingMs ?? Number.NaN), + "pan fps": samples.map((s) => s.pan?.fps ?? Number.NaN), + "pan frame p50": samples.map((s) => s.pan?.frameP50 ?? Number.NaN), + "pan frame p95": samples.map((s) => s.pan?.frameP95 ?? Number.NaN), + "pan TBT ms": samples.map((s) => s.pan?.totalBlockingMs ?? Number.NaN), + "pan long tasks": samples.map((s) => s.pan?.longTasks ?? Number.NaN), + "fit-all fps": samples.map((s) => s.fitAll?.fps ?? Number.NaN), + "fit-all frame p95": samples.map((s) => s.fitAll?.frameP95 ?? Number.NaN), + } + return Object.fromEntries( + Object.entries(rows).map(([key, values]) => [ + key, + { p50: +pct(values, 50).toFixed(1), p95: +pct(values, 95).toFixed(1) }, + ]), + ) +} + +const browser = await chromium.launch() +const results: Record = {} +for (const [label, build] of Object.entries(builds)) { + const port = 5600 + Object.keys(results).length + const server = serve(join(root, `packages/ui-parity/.parity/builds-${BASE}/${build}`), port) + const samples: Sample[] = [] + for (let run = 0; run < RUNS; run++) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + reducedMotion: "no-preference", + }) + const page = await context.newPage() + samples.push(await measure(page, `http://127.0.0.1:${port}${BENCH}`)) + await context.close() + console.error(`[perf] ${label} run ${run + 1}/${RUNS}: ready ${samples.at(-1)?.readyMs}ms`) + } + server.stop(true) + results[label] = { samples, summary: summarize(samples) } +} +await browser.close() +console.log(JSON.stringify(results, null, 2)) + +declare global { + interface Window { + __s5Marks?: Milestones + __smBench?: { + ready: boolean + readyMs: number | null + run: (options?: { durationMs?: number; pan?: boolean }) => Promise + setCamera: (viewport: { x: number; y: number; zoom: number }) => void + fitCamera: () => void + waitForQuiet: (options?: { maxMs?: number; quietMs?: number }) => Promise + } + } +} diff --git a/apps/web-foldkit/scripts/generate-icons.tsx b/apps/web-foldkit/scripts/generate-icons.tsx new file mode 100644 index 0000000000..05da61d8e0 --- /dev/null +++ b/apps/web-foldkit/scripts/generate-icons.tsx @@ -0,0 +1,546 @@ +/** + * Renders the React icons the Foldkit app uses with react-dom/server and writes their element trees + * to `src/ui/icons.generated.ts`, so the Foldkit markup is the React output, not a hand copy. + * + * bun apps/web-foldkit/scripts/generate-icons.tsx + */ +import { writeFileSync } from "node:fs" +import { resolve } from "node:path" +import { createElement, type ComponentType } from "../../web/node_modules/react/index.js" +import { renderToStaticMarkup } from "../../web/node_modules/react-dom/server.node.js" +import * as UiIcons from "../../../packages/ui/src/components/icons/index.ts" +import { MAPLE_MARK_PATH } from "../../../packages/ui/src/components/icons/maple-mark.tsx" +import { BUN_MARK_PATH } from "../../../packages/ui/src/components/icons/bun.tsx" +import { CLICKHOUSE_MARK_PATH } from "../../../packages/ui/src/components/icons/clickhouse.tsx" +import { CLOUDFLARE_MARK_PATH } from "../../../packages/ui/src/components/icons/cloudflare.tsx" +import { DENO_MARK_PATH } from "../../../packages/ui/src/components/icons/deno.tsx" +import { MONGODB_MARK_PATH } from "../../../packages/ui/src/components/icons/mongodb.tsx" +import { MYSQL_MARK_PATH } from "../../../packages/ui/src/components/icons/mysql.tsx" +import { NODEJS_MARK_PATH } from "../../../packages/ui/src/components/icons/nodejs.tsx" +import { OPENJDK_MARK_PATH } from "../../../packages/ui/src/components/icons/openjdk.tsx" +import { POSTGRES_MARK_PATH } from "../../../packages/ui/src/components/icons/postgres.tsx" +import { PYTHON_MARK_PATH } from "../../../packages/ui/src/components/icons/python.tsx" +import { REDIS_MARK_PATH } from "../../../packages/ui/src/components/icons/redis.tsx" +import { RUBY_MARK_PATH } from "../../../packages/ui/src/components/icons/ruby.tsx" +import { RUST_MARK_PATH } from "../../../packages/ui/src/components/icons/rust.tsx" +import { KAFKA_MARK_PATH } from "../../web/src/components/icons/kafka.tsx" +import { NATS_MARK_PATH } from "../../web/src/components/icons/nats.tsx" +import { RABBITMQ_MARK_PATH } from "../../web/src/components/icons/rabbitmq.tsx" +import { PIXEL_GLYPHS } from "../../web/src/components/onboarding/pixel-glyph.tsx" +import * as WebIcons from "../../web/src/components/icons/index.ts" +// Outside the apps/web barrel (its chunk loads at startup): only the Google Cloud pages draw them. +import { CloudRunIcon } from "../../web/src/components/icons/cloud-run.tsx" +import { GooglePubSubIcon } from "../../web/src/components/icons/google-pubsub.tsx" +import { MysqlDolphinIcon } from "../../web/src/components/icons/mysql-dolphin.tsx" +import { ParenthesesDotsIcon } from "../../web/src/components/icons/parentheses-dots.tsx" +import { SqlServerIcon } from "../../web/src/components/icons/sql-server.tsx" +import * as FrameworkIcons from "../../web/src/components/quick-start/framework-icons.tsx" + +type Icon = ComponentType<{ size?: string; className?: string }> + +/** + * The icons the Foldkit app renders, by their `@maple/ui` name, else their apps/web name. apps/web + * re-exports `CloudflareBrandIcon as CloudflareIcon`; here `CloudflareIcon` is @maple/ui's mono one. + */ +const NAMES = [ + "AlertWarningIcon", + "ArrowLeftIcon", + "ArrowRightIcon", + "ArrowRotateAnticlockwiseIcon", + "ArrowRotateClockwiseIcon", + "ArrowUpDownIcon", + "ArrowTrendDownIcon", + "ArrowTrendUpIcon", + "AppleIcon", + "AwsIcon", + "AzureIcon", + "DockerIcon", + "FolderIcon", + "LinuxIcon", + "MicrochipIcon", + "MicrosoftIcon", + "ArrowUpIcon", + "AwsLambdaIcon", + "BellIcon", + "BoxArchiveIcon", + "BracketsCurlyIcon", + "BranchForkIcon", + "BunIcon", + "ChartBarHorizontalIcon", + "ChartBarIcon", + "ChartBarTrendUpIcon", + "ChartLineIcon", + "ChatBubbleIcon", + "ChatBubbleSparkleIcon", + "CheckIcon", + "ChevronDownIcon", + "ChevronExpandYIcon", + "ChevronLeftIcon", + "ChevronRightIcon", + "ChevronUpIcon", + "CircleCheckIcon", + "CircleInfoIcon", + "CirclePercentageIcon", + "CircleQuestionIcon", + "CircleWarningIcon", + "CircleXmarkIcon", + "ClickhouseIcon", + "ClockIcon", + "CloudflareBrandIcon", + "CloudflareIcon", + "CloudflareMonoIcon", + "CodeIcon", + "ComputerIcon", + "ChromeIcon", + "SafariIcon", + "FirefoxIcon", + "EdgeIcon", + "OperaIcon", + "MobileIcon", + "TabletIcon", + "MediaPlayIcon", + "ArrowPathIcon", + "CursorIcon", + "ArrowRightFromLineIcon", + "CursorPointerIcon", + "FingerprintIcon", + "PixelBracketsCurlyIcon", + "PixelCrosshairsIcon", + "PixelNodesIcon", + "PixelSparkleIcon", + "PixelWindowIcon", + "OpenAiIcon", + "ClaudeIcon", + "GeminiIcon", + "PerplexityIcon", + "MetaIcon", + "ByteDanceIcon", + "DeepSeekIcon", + "GrokIcon", + "MistralIcon", + "KimiIcon", + "AmazonIcon", + "CohereIcon", + "MarkdownIcon", + "FileCodeIcon", + "ArrowThroughLineRightIcon", + "MicrosoftColorIcon", + "ConnectionIcon", + "CopyIcon", + "CubeIcon", + "DatabaseIcon", + "DenoIcon", + "DiscordIcon", + "DotsIcon", + "DotsVerticalIcon", + "DownloadIcon", + "EnvelopeIcon", + "ExternalLinkIcon", + "EyeIcon", + "FaceRobotIcon", + "FileIcon", + "FilterIcon", + "FireIcon", + "FloppyDiskIcon", + "GearIcon", + "GithubIcon", + "GlobeIcon", + "GlobePointerIcon", + "GoogleCloudIcon", + "GridIcon", + "GridSquareCirclePlusIcon", + "HazelIcon", + "GripDotsIcon", + "HistoryIcon", + "HouseIcon", + "IndustryIcon", + "KafkaIcon", + "KeyboardIcon", + "KubernetesIcon", + "LayersIcon", + "LayoutLeftIcon", + "LayoutMoveToLeftIcon", + "LayoutMoveToRightIcon", + "LayoutRightIcon", + "LinkIcon", + "LoaderIcon", + "LockIcon", + "LogoutIcon", + "LineHeightIcon", + "MagnifierIcon", + "MapleMark", + "MaximizeIcon", + "MediaPauseIcon", + "MenuIcon", + "MessagesIcon", + "MinimizeIcon", + "MinusIcon", + "MongodbIcon", + "MoonIcon", + "MysqlIcon", + "NatsIcon", + "NetworkNodesIcon", + "NodejsIcon", + "NodejsMonoIcon", + "OpenjdkIcon", + "OpenjdkMonoIcon", + "PaletteIcon", + "PaperPlaneIcon", + "PencilIcon", + "PixelTriangleWarningIcon", + "PlanetScaleIcon", + "PlayRotateClockwiseIcon", + "PlusIcon", + "PostgresIcon", + "PulseIcon", + "PuzzlePieceIcon", + "PythonIcon", + "RabbitmqIcon", + "RailwayIcon", + "RedisIcon", + "RocketIcon", + "RubyIcon", + "RustIcon", + "ServerIcon", + "ShieldIcon", + "SlackIcon", + "SlidersIcon", + "SquareSparkleIcon", + "SquareTerminalIcon", + "StackPerspectiveIcon", + "StarFilledIcon", + "StarIcon", + "SunIcon", + "TextWrapIcon", + "ThumbtackIcon", + "TagIcon", + "TelegramIcon", + "TrashIcon", + "UnorderedListIcon", + "TruckIcon", + "UiLayersIcon", + "UploadIcon", + "UserIcon", + "XmarkIcon", + "ArrowDownIcon", + "AspectRatioIcon", + "IdBadgeIcon", + "SquareMinusIcon", + "SquarePlusIcon", + "SparkleIcon", + "CloudRunIcon", + "GooglePubSubIcon", + "MysqlDolphinIcon", + "ParenthesesDotsIcon", + "SqlServerIcon", + "SitemapIcon", + "GkeIcon", + "GoogleCloudMonoIcon", + "StopwatchIcon", + "ShieldCheckIcon", + // agent sessions: framework marks, model vendor marks, kinds of work + "AionLabsIcon", + "AnthropicIcon", + "ArceeIcon", + "BaiduIcon", + "BoltIcon", + "CrewAiIcon", + "EffectIcon", + "GoogleIcon", + "HaystackIcon", + "HuggingFaceIcon", + "IbmIcon", + "InceptionIcon", + "KwaipilotIcon", + "LangchainIcon", + "LiquidIcon", + "MeituanIcon", + "MiniMaxIcon", + "MoonshotIcon", + "MorphIcon", + "NousResearchIcon", + "NvidiaIcon", + "OpenRouterIcon", + "CornerDownLeftIcon", + "SquareIcon", + "CompassIcon", + "PoolsideIcon", + "PydanticIcon", + "QwenIcon", + "RelaceIcon", + "SpringIcon", + "StepFunIcon", + "TencentIcon", + "UpstageIcon", + "VeniceIcon", + "VercelIcon", + "XaiIcon", + "ZaiIcon", + "TranscriptIcon", + "CompactLinesIcon", + "CreditCardIcon", + "KeyIcon", + "PrometheusIcon", + "WarpStreamIcon", + "MagnifierCheckIcon", + "RadioCheckedIcon", + "FrameworkNextjsIcon", + "FrameworkNodejsIcon", + "FrameworkPythonIcon", + "FrameworkGoIcon", + "FrameworkEffectIcon", + "FrameworkOpenTelemetryIcon", +] as const + +const duplicates = NAMES.filter((name, index) => NAMES.indexOf(name) !== index) +if (duplicates.length > 0) throw new Error(`listed twice: ${duplicates.join(", ")}`) + +/** Names both icon sets export with different markup: which one the Foldkit callers mean. */ +const SOURCE_OF = new Map([ + ["CloudflareIcon", "ui"], + ["LayersIcon", "web"], +]) + +type Source = "ui" | "web" | "framework" + +/** + * Names for an icon another set shadows: `UiLayersIcon` is @maple/ui's `LayersIcon` (the kit + * gallery); `Framework*` are the quick-start stack logos (`components/quick-start/framework-icons`). + */ +const ALIASES = new Map([ + ["UiLayersIcon", ["ui", "LayersIcon"]], + ["FrameworkNextjsIcon", ["framework", "NextjsIcon"]], + ["FrameworkNodejsIcon", ["framework", "NodejsIcon"]], + ["FrameworkPythonIcon", ["framework", "PythonIcon"]], + ["FrameworkGoIcon", ["framework", "GoIcon"]], + ["FrameworkEffectIcon", ["framework", "EffectIcon"]], + ["FrameworkOpenTelemetryIcon", ["framework", "OpenTelemetryIcon"]], +]) + +const uiIcons = new Map(Object.entries(UiIcons)) +const webIcons = new Map([ + ...Object.entries(WebIcons), + ["CloudRunIcon", CloudRunIcon], + ["GooglePubSubIcon", GooglePubSubIcon], + // copied: `MicrosoftColorIcon` (analytics/ai/ai-product-icon.tsx, module-private there). + [ + "MicrosoftColorIcon", + ({ size = 24, ...props }) => + createElement( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + viewBox: "0 0 24 24", + width: size, + height: size, + "aria-hidden": true, + ...props, + }, + createElement("path", { fill: "#F25022", d: "M0 0h11.377v11.377H0z" }), + createElement("path", { fill: "#7FBA00", d: "M12.623 0H24v11.377H12.623z" }), + createElement("path", { fill: "#00A4EF", d: "M0 12.623h11.377V24H0z" }), + createElement("path", { fill: "#FFB900", d: "M12.623 12.623H24V24H12.623z" }), + ), + ], + ["MysqlDolphinIcon", MysqlDolphinIcon], + ["ParenthesesDotsIcon", ParenthesesDotsIcon], + ["SqlServerIcon", SqlServerIcon], + // `GcpServiceIcon`'s GKE glyph: the Kubernetes wheel in the text colour. + ["GkeIcon", (props) => createElement(WebIcons.KubernetesIcon, { fill: "currentColor", ...props })], +]) +const frameworkIcons = new Map(Object.entries(FrameworkIcons)) +const setOf = (source: Source) => (source === "ui" ? uiIcons : source === "web" ? webIcons : frameworkIcons) + +/** Placeholders the template fills per call: React writes `size` to width/height. */ +const SIZE = "7777" +const CLASS = "__icon_class__" +/** Stands for the one `useId` an icon renders (a gradient); `icon(..., { id })` fills it per call. */ +const ID = "__icon_id__" +const TAGS = [ + "svg", + "path", + "g", + "line", + "circle", + "rect", + "polyline", + "polygon", + "ellipse", + "defs", + "linearGradient", + "stop", +] as const + +type Node = readonly [ + tag: string, + attributes: ReadonlyArray, + children: ReadonlyArray, +] + +const decode = (value: string) => + value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") + +/** React's static markup is well-formed XML without text nodes, so a tag scanner is enough. */ +const parse = (markup: string): Node => { + const token = /<(\/?)([a-zA-Z][\w:-]*)((?:\s+[\w:-]+="[^"]*")*)\s*(\/?)>/g + const stack: Array<{ tag: string; attributes: Array; children: Node[] }> = [] + let root: Node | undefined + const attach = (node: Node) => { + const parent = stack.at(-1) + if (parent) parent.children.push(node) + else root = node + } + for (const match of markup.matchAll(token)) { + const [, closing, tag = "", rawAttributes = "", selfClosing] = match + if (closing) { + const done = stack.pop() + if (!done) throw new Error(`unbalanced `) + attach([done.tag, done.attributes, done.children]) + continue + } + const attributes = [...rawAttributes.matchAll(/([\w:-]+)="([^"]*)"/g)].map( + ([, name = "", value = ""]) => [name, decode(value)] as const, + ) + if (selfClosing) attach([tag, attributes, []]) + else stack.push({ tag, attributes, children: [] }) + } + if (!root) throw new Error("no root element") + return root +} + +/** React's `useId` output under react-dom/server (`_R_0_`). */ +const USE_ID = /_R_[A-Za-z0-9]*_/g + +/** + * A `useId` value becomes the id mark, wherever the markup refers to it (`url(#…)`). Several ids + * derived from one `useId` (`${id}-a`, `${id}-b`) mark that one value. + */ +const markIds = (markup: string): string => { + const ids = [...new Set([...markup.matchAll(/ id="([^"]*)"/g)].map(([, id = ""]) => id))] + if (ids.length <= 1) return ids.reduce((marked, id) => marked.replaceAll(id, ID), markup) + const bases = [...new Set(ids.flatMap((id) => id.match(USE_ID) ?? [id]))] + if (bases.length > 1) throw new Error(`renders ${bases.length} ids; the id mark covers one useId`) + return bases.reduce((marked, base) => marked.replaceAll(base, ID), markup) +} + +const render = (component: Icon, props: { size?: string; className?: string }) => { + const markup = markIds(renderToStaticMarkup(createElement(component, props))) + if (/>[^<]/.test(markup)) throw new Error(`renders text; extend IconNode first`) + const tags = [...markup.matchAll(/<\/?([a-zA-Z]+)/g)].map(([, tag]) => tag) + const unknown = tags.find((tag) => !TAGS.some((known) => known === tag)) + if (unknown !== undefined) throw new Error(`uses <${unknown}>; add it to TAGS first`) + return markup +} + +const pick = (name: string): Icon => { + const alias = ALIASES.get(name) + if (alias !== undefined) { + const aliased = setOf(alias[0]).get(alias[1]) + if (aliased === undefined) throw new Error(`${name}: ${alias[1]} is not an icon in ${alias[0]}`) + return aliased + } + const ui = uiIcons.get(name) + const web = webIcons.get(name) + const choice = SOURCE_OF.get(name) + const component = choice === "web" ? web : choice === "ui" ? ui : (ui ?? web) + if (component === undefined) throw new Error(`${name} is not an icon in @maple/ui or apps/web`) + if (choice === undefined && ui !== undefined && web !== undefined && render(ui, {}) !== render(web, {})) + throw new Error(`${name} differs between @maple/ui and apps/web; add it to SOURCE_OF`) + return component +} + +/** The default `size` an icon writes where the template has the size mark. */ +const defaultSize = (template: Node, plain: Node): string | undefined => { + const own = template[1].findIndex(([, value]) => value === SIZE) + if (own !== -1) return plain[1].find(([name]) => name === template[1][own]?.[0])?.[1] + return template[2] + .map((child, index) => defaultSize(child, plain[2][index] ?? child)) + .find((v) => v !== undefined) +} + +const entries = NAMES.map((name) => { + const component = pick(name) + const template = parse(render(component, { size: SIZE, className: CLASS })) + const classes = JSON.stringify(template).match(/"class","([^"]*)"/g) ?? [] + if (classes.some((entry) => entry !== `"class","${CLASS}"`)) + throw new Error(`${name} composes its className; extend the class mark first`) + const size = defaultSize(template, parse(render(component, {}))) + return { name, tree: JSON.stringify(template), size } +}) + +const sizes = entries.filter((entry) => entry.size !== undefined && entry.size !== "24") + +const out = resolve(import.meta.dir, "../src/ui/icons.generated.ts") +writeFileSync( + out, + `// Generated by scripts/generate-icons.tsx from packages/ui and apps/web icons. Do not edit. +// Each icon is [tag, attributes, children]; "${SIZE}" marks \`size\`, "${CLASS}" the className +// and "${ID}" an icon's own \`useId\`. + +export const ICON_SIZE_MARK = "${SIZE}" +export const ICON_CLASS_MARK = "${CLASS}" +export const ICON_ID_MARK = "${ID}" + +export type IconTag = ${TAGS.map((tag) => JSON.stringify(tag)).join(" | ")} + +export type IconNode = readonly [IconTag, ReadonlyArray, ReadonlyArray] + +export const ICON_TREES = { +${entries.map((entry) => `\t${entry.name}: ${entry.tree},`).join("\n")} +} as const satisfies Record + +export type IconName = keyof typeof ICON_TREES + +/** \`size\` defaults other than 24. */ +export const ICON_DEFAULT_SIZES: Partial> = { +${sizes.map((entry) => `\t${entry.name}: ${JSON.stringify(entry.size)},`).join("\n")} +} + +/** \`MAPLE_MARK_PATH\` from packages/ui/src/components/icons/maple-mark.tsx (the boot splash draws it). */ +export const MAPLE_MARK_PATH = ${JSON.stringify(MAPLE_MARK_PATH)} + +/** \`PIXEL_GLYPHS\` from apps/web/src/components/onboarding/pixel-glyph.tsx: one path per segment. */ +export const PIXEL_GLYPHS = ${JSON.stringify(PIXEL_GLYPHS, null, "\t")} as const satisfies Record> +`, +) +console.log(`wrote ${entries.length} icons to ${out}`) + +// The 3D service map's machine badges: SVG paths only, imported by the lazy scene chunk alone. +const MARK_PATHS = { + kafka: KAFKA_MARK_PATH, + nats: NATS_MARK_PATH, + rabbitmq: RABBITMQ_MARK_PATH, + postgres: POSTGRES_MARK_PATH, + mysql: MYSQL_MARK_PATH, + clickhouse: CLICKHOUSE_MARK_PATH, + mongodb: MONGODB_MARK_PATH, + redis: REDIS_MARK_PATH, + openjdk: OPENJDK_MARK_PATH, + nodejs: NODEJS_MARK_PATH, + bun: BUN_MARK_PATH, + deno: DENO_MARK_PATH, + cloudflare: CLOUDFLARE_MARK_PATH, + rust: RUST_MARK_PATH, + python: PYTHON_MARK_PATH, + ruby: RUBY_MARK_PATH, +} +const marksOut = resolve(import.meta.dir, "../src/page/service-map/three/mark-paths.generated.ts") +writeFileSync( + marksOut, + `// Generated by scripts/generate-icons.tsx from the packages/ui and apps/web mark paths. Do not edit. + +import type { MarkKey } from "../badge" + +export const MARK_PATHS = { +${Object.entries(MARK_PATHS) + .map(([key, path]) => `\t${key}: ${JSON.stringify(path)},`) + .join("\n")} +} as const satisfies Record +`, +) +console.log(`wrote ${Object.keys(MARK_PATHS).length} mark paths to ${marksOut}`) diff --git a/apps/web-foldkit/src/charts/area-chart.ts b/apps/web-foldkit/src/charts/area-chart.ts new file mode 100644 index 0000000000..bda26891ad --- /dev/null +++ b/apps/web-foldkit/src/charts/area-chart.ts @@ -0,0 +1,135 @@ +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { Message, type Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" +import { ChartSpec, ChartTooltip, type TimeseriesAxisSpec } from "./chart-spec" +import { bucketTooltipBody } from "./bucket-tooltip" +import { plotFrame } from "./plot-frame" +import { resolveColor, type ThemeColors } from "./theme-colors" +import { + SERIES_FALLBACK_COLOR, + seriesColorTokens, + seriesDefinitionsOf, +} from "./timeseries" +import { timeseriesLegend } from "./timeseries-legend" + +// Port of `QueryBuilderAreaChart` + `Timeseries.Frame` (packages/ui/src/components/charts/area/, +// plot/timeseries.tsx) with a hidden, bottom or right legend. +// TODO(legend): the hoisted header legend is not ported. + +/** The tokens a chart's series and chrome can resolve to; a page reads them with `ResolveThemeColors`. */ +export const AREA_CHART_TOKENS = [ + "--border", + "--background", + "var(--chart-1)", + "var(--chart-p50)", + "var(--chart-p95)", + "var(--chart-p99)", + "var(--severity-info)", + "var(--severity-error)", + "var(--severity-warn)", + "var(--muted-foreground)", +] + +export interface AreaChartInputs { + readonly chartId: string + readonly data: ReadonlyArray>> + readonly theme: ThemeColors + readonly unit?: string | undefined + readonly timeZone?: string | undefined + /** The clock the in-flight tail is judged against (`Date.now()` in the React chart). */ + readonly nowMs: number + readonly className?: string + readonly showTooltip?: boolean + /** `visible` under the plot, `right` beside it; otherwise the legend hoists (nothing here). */ + readonly legend?: "hidden" | "visible" | "right" + /** `QueryBuilderLineChart`: lines instead of bands. */ + readonly mark?: "area" | "line" + readonly stacked?: boolean + readonly curveType?: "linear" | "monotone" + readonly showPoints?: boolean + /** `seriesStats`: the visible legend is the Min/Max/Mean/Last table. */ + readonly seriesStats?: boolean + /** Thresholds, log scale and soft bounds (`QueryBuilderAreaChart`'s cartesian props). */ + readonly axis?: TimeseriesAxisSpec | undefined +} + +const tooltipBody = ( + h: HtmlBuilder, + tooltip: ChartTooltip, + portal: Attribute, +): Option.Option => + ChartTooltip.match(tooltip, { + Bucket: (bucket) => Option.some(bucketTooltipBody(h, bucket, portal)), + PieSlice: () => Option.none(), + NumericBin: () => Option.none(), + CategoricalBin: () => Option.none(), + HeatmapSlot: () => Option.none(), + }) + +export const view = Submodel.defineView((model, inputs, h) => { + const { definitions } = seriesDefinitionsOf(inputs.data) + const tokens = seriesColorTokens(definitions) + const colors = Object.fromEntries( + definitions.map((definition) => [ + definition.chartKey, + resolveColor( + inputs.theme, + tokens.get(definition.chartKey) ?? SERIES_FALLBACK_COLOR, + SERIES_FALLBACK_COLOR, + ), + ]), + ) + const series = definitions.map((definition) => ({ + key: definition.chartKey, + label: definition.rawKey, + color: colors[definition.chartKey] ?? SERIES_FALLBACK_COLOR, + })) + const legend = timeseriesLegend(h, model, { + series, + data: inputs.data, + definitions, + unit: inputs.unit, + nowMs: inputs.nowMs, + legend: inputs.legend, + seriesStats: inputs.seriesStats === true, + }) + return h.div( + [h.Class(cn("h-full w-full", inputs.className)), h.OnMount(MeasureChartContainer())], + [ + plotFrame(h, { + chartId: inputs.chartId, + className: "h-full w-full", + ariaLabel: "Time series", + spec: ChartSpec.QueryBuilderArea({ + data: inputs.data, + colors, + hidden: model.hiddenKeys, + fallbackColor: SERIES_FALLBACK_COLOR, + chrome: { + border: resolveColor(inputs.theme, "--border"), + background: resolveColor(inputs.theme, "--background"), + }, + unit: inputs.unit ?? null, + timeZone: inputs.timeZone ?? null, + nowMs: inputs.nowMs, + containerWidth: model.containerWidth, + gradientPrefix: `qbArea-${inputs.chartId}`, + showTooltip: inputs.showTooltip ?? true, + ...(inputs.mark === undefined ? undefined : { mark: inputs.mark }), + ...(inputs.stacked === undefined ? undefined : { stacked: inputs.stacked }), + ...(inputs.curveType === undefined ? undefined : { curve: inputs.curveType }), + ...(inputs.showPoints === undefined ? undefined : { showPoints: inputs.showPoints }), + ...(inputs.axis === undefined ? undefined : { axis: inputs.axis }), + }), + // A hoisting `TimeseriesLegend` renders nothing, but `PlotFrame` still gets an element. + legend, + legendPlacement: inputs.legend === "right" ? "right" : "bottom", + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => tooltipBody(h, tooltip, portal)), + }), + ], + ) +}) diff --git a/apps/web-foldkit/src/charts/bar-chart.ts b/apps/web-foldkit/src/charts/bar-chart.ts new file mode 100644 index 0000000000..abab976b8b --- /dev/null +++ b/apps/web-foldkit/src/charts/bar-chart.ts @@ -0,0 +1,122 @@ +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html } from "foldkit/html" +import type { Message, Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" +import { timeseriesLegend } from "./timeseries-legend" +import { bucketTimeseries, MAX_BAR_SERIES, OTHER_LABEL } from "@maple/ui/components/charts/_shared/bucket-series" +import { ChartSpec, ChartTooltip, type TimeseriesAxisSpec } from "./chart-spec" +import { bucketTooltipBody } from "./bucket-tooltip" +import { plotFrame } from "./plot-frame" +import { resolveColor, type ThemeColors } from "./theme-colors" +import { SERIES_FALLBACK_COLOR, seriesColorTokens, seriesDefinitionsOf } from "./timeseries" + +// Port of `QueryBuilderBarChart` + `Timeseries.Frame` (packages/ui/src/components/charts/bar/, +// plot/timeseries.tsx): the long tail folded into "Other", and its legend; the definition is +// `definitions/bar.ts`. + +/** The tokens the bar series can resolve to; a page reads them with `ResolveThemeColors`. */ +export const BAR_CHART_TOKENS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "var(--muted-foreground)", +] + +export interface BarChartInputs { + readonly chartId: string + readonly data: ReadonlyArray>> + readonly theme: ThemeColors + readonly unit?: string | undefined + readonly timeZone?: string | undefined + /** The clock the in-flight tail is judged against (`Date.now()` in the React chart). */ + readonly nowMs: number + readonly stacked?: boolean + readonly className?: string + readonly showTooltip?: boolean + readonly axis?: TimeseriesAxisSpec | undefined + readonly legend?: "hidden" | "visible" | "right" | undefined + readonly seriesStats?: boolean +} + +type Row = Readonly> + +/** `bucketBarSeries`: past `MAX_BAR_SERIES` the long tail folds into one "Other" series. */ +export const bucketBarSeries = (data: ReadonlyArray): ReadonlyArray => { + const seriesKeys = [ + ...new Set(data.flatMap((row) => Object.keys(row).filter((key) => key !== "bucket" && key !== "partial"))), + ] + if (seriesKeys.length <= MAX_BAR_SERIES) return data + const { rows } = bucketTimeseries(data, seriesKeys, MAX_BAR_SERIES) + return rows.map((row, index) => (data[index]?.partial === true ? { ...row, partial: true } : row)) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const data = bucketBarSeries(inputs.data) + const { definitions } = seriesDefinitionsOf(data) + const tokens = seriesColorTokens(definitions) + const otherColor = resolveColor(inputs.theme, "--muted-foreground") + const colors = Object.fromEntries( + definitions.map((definition) => [ + definition.chartKey, + definition.rawKey === OTHER_LABEL + ? otherColor + : resolveColor( + inputs.theme, + tokens.get(definition.chartKey) ?? SERIES_FALLBACK_COLOR, + SERIES_FALLBACK_COLOR, + ), + ]), + ) + const series = definitions.map((definition) => ({ + key: definition.chartKey, + label: definition.rawKey, + color: colors[definition.chartKey] ?? SERIES_FALLBACK_COLOR, + })) + const tooltipBody = (portal: Attribute): Option.Option => + Option.flatMap(model.tooltip, (tooltip) => + ChartTooltip.match(tooltip, { + Bucket: (bucket) => Option.some(bucketTooltipBody(h, bucket, portal)), + PieSlice: () => Option.none(), + NumericBin: () => Option.none(), + CategoricalBin: () => Option.none(), + HeatmapSlot: () => Option.none(), + }), + ) + return h.div( + [h.Class(cn("h-full w-full", inputs.className)), h.OnMount(MeasureChartContainer())], + [ + plotFrame(h, { + chartId: inputs.chartId, + className: "h-full w-full", + ariaLabel: "Time series", + spec: ChartSpec.QueryBuilderBar({ + data, + colors, + fallbackColor: SERIES_FALLBACK_COLOR, + unit: inputs.unit ?? null, + timeZone: inputs.timeZone ?? null, + nowMs: inputs.nowMs, + stacked: inputs.stacked ?? false, + showTooltip: inputs.showTooltip ?? true, + ...(inputs.axis === undefined ? undefined : { axis: inputs.axis }), + }), + legend: timeseriesLegend(h, model, { + series, + data, + definitions, + unit: inputs.unit, + nowMs: inputs.nowMs, + legend: inputs.legend, + seriesStats: inputs.seriesStats === true, + }), + legendPlacement: inputs.legend === "right" ? "right" : "bottom", + tooltipBody, + }), + ], + ) +}) + diff --git a/apps/web-foldkit/src/charts/bucket-tooltip.ts b/apps/web-foldkit/src/charts/bucket-tooltip.ts new file mode 100644 index 0000000000..eb9b3a7375 --- /dev/null +++ b/apps/web-foldkit/src/charts/bucket-tooltip.ts @@ -0,0 +1,72 @@ +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { ChartTooltip } from "./chart-spec" + +// `PlotTooltipBody` for a time-bucketed chart (fixed metrics, query-builder areas): the bucket +// heading, then one swatch, label and value per series; a dashed swatch for a partial series. + +export type BucketTooltip = Extract + +/** `PlotTooltipBody`: the bucket heading, then one swatch, label and value per series. */ +export const bucketTooltipBody = ( + h: HtmlBuilder, + tooltip: BucketTooltip, + portal: Attribute, +): Html => + h.div( + [h.Class("grid min-w-[9rem] max-w-[22rem] items-start gap-1.5"), portal], + [ + h.div( + [h.Class("border-border/50 border-b pb-1 font-medium text-muted-foreground tracking-tight")], + [tooltip.heading], + ), + h.div( + [h.Class("grid gap-1.5")], + tooltip.rows.map((row, index) => + h.div( + [ + h.Key(`${index}:${row.label}`), + h.Class( + row.label === tooltip.highlighted + ? "flex w-full items-center gap-2 [&_*]:font-semibold" + : "flex w-full items-center gap-2", + ), + ], + [ + h.span([ + h.Class( + row.dashed + ? "size-2.5 shrink-0 rounded-xs border border-dashed" + : "size-2.5 shrink-0 rounded-xs", + ), + h.Style( + row.dashed + ? { "border-color": row.color } + : { "background-color": row.color }, + ), + ]), + h.div( + [h.Class("flex flex-1 items-center justify-between gap-3 leading-none")], + [ + h.span( + [ + h.Class("min-w-0 truncate text-muted-foreground"), + h.Title(row.label), + ], + [row.label], + ), + h.span( + [ + h.Class( + "shrink-0 font-mono font-semibold text-foreground tabular-nums", + ), + ], + [row.value], + ), + ], + ), + ], + ), + ), + ), + ], + ) diff --git a/apps/web-foldkit/src/charts/chart-host.ts b/apps/web-foldkit/src/charts/chart-host.ts new file mode 100644 index 0000000000..8a787ea17a --- /dev/null +++ b/apps/web-foldkit/src/charts/chart-host.ts @@ -0,0 +1,213 @@ +import type { ChartBounds, ChartValue, ResolvedScale } from "@tanstack/charts" +import { createChartRendererAdapter } from "@tanstack/charts/adapter/renderer" +import { canvasChartRenderer } from "@tanstack/charts/canvas" +import { renderChartSvg } from "@tanstack/charts/svg" +import { createSvgChartRenderer } from "@tanstack/charts/svg/renderer" +import { Option } from "effect" +import { ChartSpec, type ChartTooltip } from "./chart-spec" +import { buildAlertRule } from "./definitions/alert-rule" +import { buildAnalyticsTraffic } from "./definitions/analytics-traffic" +import { buildQueryBuilderArea } from "./definitions/area" +import { buildQueryBuilderBar } from "./definitions/bar" +import type { BuiltChart } from "./definitions/built-chart" +import { + buildBinnedHistogram, + buildCategoricalHistogram, + buildNumericHistogram, +} from "./definitions/histogram" +import { buildBenchOverview } from "./definitions/bench-overview" +import { buildFixedMetric } from "./definitions/fixed-metric" +import { buildInfraMetric } from "./definitions/infra-metric" +import { buildPlanetScaleMetric } from "./definitions/planetscale-metric" +import { buildLogsVolume } from "./definitions/logs-volume" +import { buildPie, buildPieSpike } from "./definitions/pie" +import { buildOccurrences } from "./definitions/occurrences" +import { buildAnomalySignal } from "./definitions/anomaly-signal" +import { buildSparkline } from "./definitions/sparkline" +import { buildStackedBreakdown } from "./definitions/stacked-breakdown" +import { buildToolSeries } from "./definitions/tool-series" +import { buildSpendCumulative } from "./definitions/spend" +import { buildHeatmap } from "./definitions/heatmap" + +/** + * What `RendererChartImplementation` (`@tanstack/charts/react`) does with a `CanvasChart` or SVG + * `Chart`, without React: one adapter per container, `update` on every new definition or height. + * Imported lazily by the chart Mount, so `@tanstack/charts` never reaches the startup bundle. + */ + +export type ChartRendererKind = "canvas" | "svg" + +/** A time x scale reduced to its affine map: the domain ends in epoch ms and their pixels. */ +export interface TimeAxis { + readonly domainStartMs: number + readonly domainEndMs: number + readonly rangeStart: number + readonly rangeEnd: number +} + +const timeAxisOf = (scale: ResolvedScale | undefined): Option.Option => { + const start = scale?.domain[0] + const end = scale?.domain[scale.domain.length - 1] + return scale !== undefined && start instanceof Date && end instanceof Date + ? Option.some({ + domainStartMs: start.getTime(), + domainEndMs: end.getTime(), + rangeStart: scale.map(start), + rangeEnd: scale.map(end), + }) + : Option.none() +} + +export interface ChartHostCallbacks { + readonly onFocusChange: (maybeKey: Option.Option) => void + readonly onTooltipChange: (maybeTooltip: Option.Option) => void + readonly onRender: (bounds: ChartBounds, timeAxis: Option.Option) => void + /** A library-drawn legend (`interactiveColorLegend`) toggled `key` of `keys`. */ + readonly onLegendToggle?: (key: string, keys: ReadonlyArray) => void +} + +export interface ChartHostOptions { + /** Read at every draw: the frame relabels the chart without remounting it. */ + readonly ariaLabel: () => string + readonly idPrefix: string + readonly renderer: ChartRendererKind + readonly callbacks: ChartHostCallbacks +} + +export interface ChartHost { + /** False when the spec's kind changed; the caller destroys this host and creates a new one. */ + readonly update: (spec: ChartSpec, height: number) => boolean + readonly destroy: () => void +} + +export const hostFor = ( + container: HTMLElement, + initialSpec: S, + initialHeight: number, + narrow: (spec: ChartSpec) => Option.Option, + build: (spec: S) => BuiltChart, + { ariaLabel, idPrefix, renderer, callbacks }: ChartHostOptions, +): ChartHost => { + // One renderer per host, as the React components memoise it: a new one would remount the surface. + const chartRenderer = renderer === "canvas" ? canvasChartRenderer : createSvgChartRenderer(renderChartSvg) + // NOTE: the callbacks must keep their identity across updates, like React's `useCallback`s: the + // tooltip replaces its body element whenever `onTooltipBodyChange` changes, detaching the portaled body. + const current = { built: build(initialSpec) } + const onFocusChange = (point: Parameters["focusKeyOf"]>[0] | null) => + callbacks.onFocusChange(Option.map(Option.fromNullishOr(point), current.built.focusKeyOf)) + const onTooltipBodyChange = ( + target: { readonly points: Parameters["tooltipOf"]>[0] } | null, + ) => callbacks.onTooltipChange(target === null ? Option.none() : current.built.tooltipOf(target.points)) + const onRender = (context: { + readonly scene: { + readonly chart: ChartBounds + readonly scales: Readonly> + } + }) => callbacks.onRender(context.scene.chart, timeAxisOf(context.scene.scales.x)) + const optionsFor = (height: number) => ({ + renderer: chartRenderer, + definition: current.built.definition, + ariaLabel: ariaLabel(), + idPrefix, + height, + initialWidth: 640, + onFocusChange, + onTooltipBodyChange, + onRender, + }) + + const adapter = createChartRendererAdapter(optionsFor(initialHeight)) + adapter.mount(container) + + return { + update: (spec, height) => + Option.match(narrow(spec), { + onNone: () => false, + onSome: (next) => { + current.built = build(next) + adapter.update(optionsFor(height)) + return true + }, + }), + destroy: () => adapter.destroy(), + } +} + +export const variant = + (tag: Tag) => + (spec: ChartSpec): Option.Option> => + Option.liftPredicate( + spec, + (candidate): candidate is Extract => candidate._tag === tag, + ) + +/** Mounts the chart a spec describes into `container` (the `.ts-chart-surface` element). */ +export const createChartHost = ( + container: HTMLElement, + spec: ChartSpec, + height: number, + options: ChartHostOptions, +): ChartHost => + ChartSpec.match(spec, { + Pie: (pie) => hostFor(container, pie, height, variant("Pie"), buildPie, options), + PieSpike: (pie) => hostFor(container, pie, height, variant("PieSpike"), buildPieSpike, options), + NumericHistogram: (histogram) => + hostFor( + container, + histogram, + height, + variant("NumericHistogram"), + buildNumericHistogram, + options, + ), + CategoricalHistogram: (histogram) => + hostFor( + container, + histogram, + height, + variant("CategoricalHistogram"), + buildCategoricalHistogram, + options, + ), + Sparkline: (sparkline) => + hostFor(container, sparkline, height, variant("Sparkline"), buildSparkline, options), + QueryBuilderArea: (area) => + hostFor(container, area, height, variant("QueryBuilderArea"), buildQueryBuilderArea, options), + QueryBuilderBar: (bar) => + hostFor(container, bar, height, variant("QueryBuilderBar"), buildQueryBuilderBar, options), + Occurrences: (occurrences) => + hostFor(container, occurrences, height, variant("Occurrences"), buildOccurrences, options), + LogsVolume: (volume) => + hostFor(container, volume, height, variant("LogsVolume"), buildLogsVolume, options), + BinnedHistogram: (histogram) => + hostFor(container, histogram, height, variant("BinnedHistogram"), buildBinnedHistogram, options), + BenchOverview: (bench) => + hostFor(container, bench, height, variant("BenchOverview"), buildBenchOverview, options), + FixedMetric: (metric) => + hostFor(container, metric, height, variant("FixedMetric"), buildFixedMetric, options), + AlertRule: (rule) => hostFor(container, rule, height, variant("AlertRule"), buildAlertRule, options), + AnomalySignal: (signal) => + hostFor(container, signal, height, variant("AnomalySignal"), buildAnomalySignal, options), + SpendCumulative: (spend) => + hostFor(container, spend, height, variant("SpendCumulative"), buildSpendCumulative, options), + InfraMetric: (metric) => + hostFor(container, metric, height, variant("InfraMetric"), buildInfraMetric, options), + AnalyticsTraffic: (traffic) => + hostFor(container, traffic, height, variant("AnalyticsTraffic"), buildAnalyticsTraffic, options), + PlanetScaleMetric: (metric) => + hostFor(container, metric, height, variant("PlanetScaleMetric"), buildPlanetScaleMetric, options), + StackedBreakdown: (breakdown) => + hostFor( + container, + breakdown, + height, + variant("StackedBreakdown"), + buildStackedBreakdown, + options, + ), + ToolSeries: (series) => + hostFor(container, series, height, variant("ToolSeries"), buildToolSeries, options), + Heatmap: (heatmap) => hostFor(container, heatmap, height, variant("Heatmap"), buildHeatmap, options), + // Drawn by the lab's host (`MountChart host: "lab"`); this host has no definition for it. + LabSpike: () => ({ update: () => false, destroy: () => undefined }), + }) diff --git a/apps/web-foldkit/src/charts/chart-mounts.ts b/apps/web-foldkit/src/charts/chart-mounts.ts new file mode 100644 index 0000000000..388cff8ed4 --- /dev/null +++ b/apps/web-foldkit/src/charts/chart-mounts.ts @@ -0,0 +1,220 @@ +import { Effect, Option, Queue, Schema, Stream } from "effect" +import { Mount } from "foldkit" +import { Message } from "./chart" +import type { ChartBounds } from "@tanstack/charts" +import type { ChartHost, TimeAxis } from "./chart-host" +import { CHART_LABEL_ATTRIBUTE, CHART_SPEC_ATTRIBUTE, decodeChartSpec } from "./chart-spec" + +/** `PlotFrame`'s `FALLBACK_HEIGHT`: drawn at until the plot box has been measured. */ +const FALLBACK_HEIGHT = 320 + +export const PLOT_BOX_ATTRIBUTE = "data-chart-plot-box" + +export const ChartRenderer = Schema.Literals(["canvas", "svg"]) +export type ChartRenderer = typeof ChartRenderer.Type + +type ChartMountMessage = + | typeof Message.CompletedMountChart.Type + | typeof Message.FailedMountChart.Type + | typeof Message.RenderedPlot.Type + | typeof Message.ChangedChartFocus.Type + | typeof Message.ChangedChartTooltip.Type + | typeof Message.ToggledLegendSeries.Type + +const failed = (reason: string) => Message.FailedMountChart({ reason }) + +/** + * `PlotFrame` + `CanvasChart`/`Chart` on the plot box: measures the box's height + * (`useMeasuredHeight`), mounts `@tanstack/charts` into `.ts-chart-surface`, keeps the plot anchor + * on the scene's plot rect, and follows the box's `data-chart-spec` (a MutationObserver plays the + * part of React re-rendering with a new definition). Focus and tooltip changes become Messages. + */ +export const MountChart = Mount.defineStream("MountChart", { + args: { + chartId: Schema.String, + ariaLabel: Schema.String, + renderer: ChartRenderer, + /** Report the plot rect and time axis (`RenderedPlot`) for an overlay. */ + reportPlot: Schema.Boolean, + /** `"lab"`: the `/lab/charts` prototypes' host, loaded only there. */ + host: Schema.optional(Schema.Literal("lab")), + }, + messages: [ + Message.CompletedMountChart, + Message.FailedMountChart, + Message.RenderedPlot, + Message.ChangedChartFocus, + Message.ChangedChartTooltip, + Message.ToggledLegendSeries, + ], + execute: ({ element, chartId, ariaLabel, renderer, reportPlot, host: hostKind }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.tryPromise(() => + hostKind === "lab" ? import("../lab/charts/spike-host") : import("./chart-host"), + ).pipe( + Effect.flatMap(({ createChartHost }) => + Effect.try(() => { + const host = element.querySelector(".ts-chart-host") + const surface = host?.querySelector(".ts-chart-surface") + const anchor = element.querySelector("[data-chart-plot]") + const initialSpec = decodeChartSpec(element.getAttribute(CHART_SPEC_ATTRIBUTE)) + if (!host || !surface || Option.isNone(initialSpec)) { + Queue.offerUnsafe(queue, failed("chart host or spec missing")) + return () => undefined + } + + // The last geometry sent: `onRender` runs on every pointer tick, a Message only on change. + const sent = { key: "" } + // A chart without a time x scale (a pie) reports its rect with an empty domain. + const NO_AXIS = { domainStartMs: 0, domainEndMs: 0, rangeStart: 0, rangeEnd: 0 } + const reportPlotOnce = (bounds: ChartBounds, timeAxis: Option.Option) => + Option.map(Option.orElseSome(timeAxis, () => NO_AXIS), (axis) => { + const plot = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + ...axis, + } + const key = JSON.stringify(plot) + if (key === sent.key) return + sent.key = key + Queue.offerUnsafe(queue, Message.RenderedPlot({ plot })) + }) + const create = (spec: typeof initialSpec.value, height: number): ChartHost => + createChartHost(surface, spec, height, { + ariaLabel: () => element.getAttribute(CHART_LABEL_ATTRIBUTE) ?? ariaLabel, + idPrefix: `ts-chart-${chartId}`, + renderer, + callbacks: { + onFocusChange: (maybeKey) => + Queue.offerUnsafe(queue, Message.ChangedChartFocus({ maybeKey })), + onTooltipChange: (maybeTooltip) => + Queue.offerUnsafe( + queue, + Message.ChangedChartTooltip({ maybeTooltip }), + ), + onLegendToggle: (key, keys) => + Queue.offerUnsafe(queue, Message.ToggledLegendSeries({ key, keys })), + onRender: (bounds, timeAxis) => { + if (reportPlot) reportPlotOnce(bounds, timeAxis) + if (!anchor) return + anchor.style.transform = `translate(${bounds.x}px, ${bounds.y}px)` + anchor.style.width = `${bounds.width}px` + anchor.style.height = `${bounds.height}px` + }, + }, + }) + // NOTE: mounted at the fallback height and then corrected, the order React's layout + // effects run in (chart first, then the frame's measurement); the first scene shapes the canvas state. + host.style.height = `${FALLBACK_HEIGHT}px` + // The live handle and what it was last drawn from; local to this Mount's lifetime. + const state = { + height: FALLBACK_HEIGHT, + chart: create(initialSpec.value, FALLBACK_HEIGHT), + } + // Measured with the fallback drawn, as legacy's layout effect is: a flex item's + // automatic minimum then holds a fixed-height box at its height instead of shrinking. + const measured = Math.round(element.getBoundingClientRect().height) + + const redraw = (spec: typeof initialSpec.value) => { + if (state.chart.update(spec, state.height)) return + state.chart.destroy() + state.chart = create(spec, state.height) + } + if (measured > 0 && measured !== FALLBACK_HEIGHT) { + state.height = measured + host.style.height = `${measured}px` + redraw(initialSpec.value) + } + const resizeObserver = new ResizeObserver((entries) => { + const next = Math.round(entries[0]?.contentRect.height ?? 0) + if (next <= 0 || next === state.height) return + state.height = next + host.style.height = `${next}px` + Option.map( + decodeChartSpec(element.getAttribute(CHART_SPEC_ATTRIBUTE)), + redraw, + ) + }) + resizeObserver.observe(element) + const specObserver = new MutationObserver(() => + Option.map( + decodeChartSpec(element.getAttribute(CHART_SPEC_ATTRIBUTE)), + redraw, + ), + ) + specObserver.observe(element, { + attributes: true, + attributeFilter: [CHART_SPEC_ATTRIBUTE, CHART_LABEL_ATTRIBUTE], + }) + + Queue.offerUnsafe(queue, Message.CompletedMountChart()) + return () => { + specObserver.disconnect() + resizeObserver.disconnect() + state.chart.destroy() + } + }), + ), + Effect.catch((error) => + Effect.sync(() => { + Queue.offerUnsafe(queue, failed(String(error))) + return () => undefined + }), + ), + ), + (release) => Effect.sync(release), + ).pipe(Effect.flatMap(() => Effect.never)), + ), +}) + +/** + * `createPortal(renderTooltipBody(...), target.element)`: moves the tooltip body the view renders + * into the body element the library's tooltip shell created in this chart's surface. + */ +export const PortalTooltipBody = Mount.define("PortalTooltipBody", { + messages: [Message.CompletedPortalTooltipBody], + execute: ({ element }) => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => + element.parentElement + ?.closest(`[${PLOT_BOX_ATTRIBUTE}]`) + ?.querySelector(".ts-chart-tooltip__body") + ?.appendChild(element), + ), + () => Effect.sync(() => element.remove()), + ) + return Message.CompletedPortalTooltipBody() + }), +}) + +/** `useContainerSize(containerRef)`: the container's rounded width, on mount and on every resize. */ +export const MeasureChartContainer = Mount.defineStream("MeasureChartContainer", { + messages: [Message.ResizedChartContainer], + execute: ({ element }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const last = { width: -1, height: -1 } + const apply = (width: number, height: number) => { + const size = { width: Math.round(width), height: Math.round(height) } + if (size.width === last.width && size.height === last.height) return + last.width = size.width + last.height = size.height + Queue.offerUnsafe(queue, Message.ResizedChartContainer(size)) + } + const box = element.getBoundingClientRect() + apply(box.width, box.height) + const observer = new ResizeObserver((entries) => + apply(entries[0]?.contentRect.width ?? 0, entries[0]?.contentRect.height ?? 0), + ) + observer.observe(element) + return observer + }), + (observer) => Effect.sync(() => observer.disconnect()), + ).pipe(Effect.flatMap(() => Effect.never)), + ), +}) diff --git a/apps/web-foldkit/src/charts/chart-runtime.ts b/apps/web-foldkit/src/charts/chart-runtime.ts new file mode 100644 index 0000000000..18f5b05254 --- /dev/null +++ b/apps/web-foldkit/src/charts/chart-runtime.ts @@ -0,0 +1,35 @@ +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { defineTaggedUnion } from "foldkit/schema" + +/** + * The lazily loaded chart library (`./chart-host` and `@tanstack/charts`). A page loads it once and + * renders its charts when it is `Ready`, so each chart Mount finds the module cached and draws + * before the first paint that shows the card, as React's layout-effect mount does. Without it the + * cards paint empty and the canvases arrive frames later. + */ +export const ChartRuntime = defineTaggedUnion({ + Loading: {}, + Ready: {}, + Failed: { reason: Schema.String }, +}) +export type ChartRuntime = typeof ChartRuntime.Type + +export const ChartRuntimeMessage = defineMessageUnion({ + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, +}) +export type ChartRuntimeMessage = typeof ChartRuntimeMessage.Type + +export const LoadChartRuntime = Command.define("LoadChartRuntime", { + messages: [ChartRuntimeMessage.CompletedLoadChartRuntime, ChartRuntimeMessage.FailedLoadChartRuntime], + execute: Effect.tryPromise(() => import("./chart-host")).pipe( + Effect.as(ChartRuntimeMessage.CompletedLoadChartRuntime()), + Effect.catch((error) => + Effect.succeed(ChartRuntimeMessage.FailedLoadChartRuntime({ reason: String(error) })), + ), + ), +}) + +export const isChartRuntimeReady = (runtime: ChartRuntime): boolean => runtime._tag === "Ready" diff --git a/apps/web-foldkit/src/charts/chart-spec.ts b/apps/web-foldkit/src/charts/chart-spec.ts new file mode 100644 index 0000000000..176cb41d9e --- /dev/null +++ b/apps/web-foldkit/src/charts/chart-spec.ts @@ -0,0 +1,452 @@ +import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import { ChartEventMarker, MarkerColors } from "./event-marker-spec" + +/** + * Everything a chart definition is built from, as data. The view renders it into the chart host's + * `data-chart-spec` attribute and the chart Mount rebuilds the `@tanstack/charts` definition from it, + * so the definition follows the Model the way a React `useMemo` follows its props. + * + * Optional values are `NullOr`, not `Option`: this is a JSON wire format, not Model state. + */ + +const Nullable = (schema: S) => Schema.NullOr(schema) + +export const PieSliceRow = Schema.Struct({ + name: Schema.String, + value: Schema.Number, + color: Schema.String, + collapsedCount: Schema.optionalKey(Schema.Number), +}) +export type PieSliceRow = typeof PieSliceRow.Type + +export const NumericBin = Schema.Struct({ x1: Schema.Number, x2: Schema.Number, value: Schema.Number }) +export type NumericBin = typeof NumericBin.Type + +export const CategoricalBin = Schema.Struct({ name: Schema.String, value: Schema.Number }) +export type CategoricalBin = typeof CategoricalBin.Type + +/** + * The y-axis settings a widget's line, area or bar chart honours: threshold rules (colours already + * canvas-safe literals), log scale and the soft bounds. + */ +export const TimeseriesAxisSpec = Schema.Struct({ + thresholds: Schema.Array( + Schema.Struct({ value: Schema.Number, color: Schema.String, label: Schema.NullOr(Schema.String) }), + ), + logScale: Schema.Boolean, + softMin: Schema.NullOr(Schema.Number), + softMax: Schema.NullOr(Schema.Number), + fitYAxisToData: Schema.Boolean, +}) +export type TimeseriesAxisSpec = typeof TimeseriesAxisSpec.Type + +/** One heatmap slot: a cell, or a hole when `value` is null. */ +export const HeatmapSlotSchema = Schema.Struct({ + x: Schema.String, + y: Schema.String, + value: Schema.NullOr(Schema.Number), +}) + +/** One bucket of a fixed-metric chart: the bucket plus its primitive columns. */ +export const FixedMetricRow = Schema.Record( + Schema.String, + Schema.Union([Schema.Number, Schema.String, Schema.Boolean, Schema.Null]), +) +export type FixedMetricRow = typeof FixedMetricRow.Type + +export const ChartSpec = defineTaggedUnion({ + /** + * `ToolSeriesChart` and the tool detail grid (apps/web/src/components/agent-sessions/tools): one + * monotone line per series over zero-filled buckets, colours resolved, ticks in the metric's unit. + */ + ToolSeries: { + rows: Schema.Array(FixedMetricRow), + series: Schema.Array(Schema.Struct({ key: Schema.String, label: Schema.String, color: Schema.String })), + chrome: Schema.Struct({ border: Schema.String, background: Schema.String }), + /** The tick and tooltip format: a tool metric (overview), or one of the detail grid's formatters. */ + format: Schema.Literals(["calls", "sessions", "error_rate", "duration", "number", "errorRate", "durationNs", "oneDecimal"]), + tickCount: Schema.NullOr(Schema.Number), + marginTop: Schema.Number, + timeZone: Schema.String, + }, + /** `QueryBuilderPieChart`. */ + Pie: { + rows: Schema.Array(PieSliceRow), + activeName: Nullable(Schema.String), + highlighted: Nullable(Schema.String), + background: Schema.String, + donut: Schema.Boolean, + innerRadius: Nullable(Schema.Number), + showLabels: Schema.Boolean, + showPercent: Schema.Boolean, + unit: Nullable(Schema.String), + showTooltip: Schema.Boolean, + }, + /** `PieFigure` in apps/web/src/lab/charts/pie-spike.tsx. `activeName` absent = not wired. */ + PieSpike: { + rows: Schema.Array(PieSliceRow), + donut: Schema.Boolean, + activeName: Schema.optionalKey(Nullable(Schema.String)), + }, + /** `NumericHistogram` in `QueryBuilderHistogramChart`. */ + NumericHistogram: { + bins: Schema.Array(NumericBin), + color: Schema.String, + unit: Nullable(Schema.String), + useLogY: Schema.Boolean, + showTooltip: Schema.Boolean, + }, + /** `CategoricalHistogram` in `QueryBuilderHistogramChart`. */ + CategoricalHistogram: { + bins: Schema.Array(CategoricalBin), + color: Schema.String, + useLogY: Schema.Boolean, + showTooltip: Schema.Boolean, + }, + /** `PlotSparkline`: the services table's trend line. */ + Sparkline: { + values: Schema.Array(Schema.Number), + color: Schema.String, + gradientId: Schema.String, + fillStart: Schema.Number, + fillEnd: Schema.Number, + /** Bumped when the data arrives again: React rebuilds the definition on every new result. */ + revision: Schema.Number, + /** `PlotSparkline`'s `curve`; absent is linear. */ + curve: Schema.optionalKey(Schema.Literals(["linear", "monotone"])), + }, + /** + * `QueryBuilderAreaChart` (unstacked, linear): the query's rows as returned, the resolved colour + * per series key (`s1..sN`), and the clock that decides the in-flight tail. + */ + QueryBuilderArea: { + data: Schema.Array( + Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Number, Schema.Boolean])), + ), + colors: Schema.Record(Schema.String, Schema.String), + /** Series keys the legend hid: not painted, and not on the y axis. */ + hidden: Schema.Array(Schema.String), + fallbackColor: Schema.String, + chrome: Schema.Struct({ border: Schema.String, background: Schema.String }), + unit: Nullable(Schema.String), + timeZone: Nullable(Schema.String), + nowMs: Schema.Number, + containerWidth: Schema.Number, + gradientPrefix: Schema.String, + showTooltip: Schema.Boolean, + /** `QueryBuilderLineChart` draws lines over the same rows; absent is the area chart. */ + mark: Schema.optionalKey(Schema.Literals(["area", "line"])), + /** `stacked`: bands on top of each other, flat fills, no closed-run edges. */ + stacked: Schema.optionalKey(Schema.Boolean), + /** `curveType`; absent is linear. */ + curve: Schema.optionalKey(Schema.Literals(["linear", "monotone"])), + /** `showPoints`: every point, none, or (absent) only isolated ones unless they fit. */ + showPoints: Schema.optionalKey(Schema.Boolean), + axis: Schema.optionalKey(TimeseriesAxisSpec), + }, + /** + * `QueryBuilderBarChart` (threshold-free, linear): the rows as returned, the resolved colour per + * series key (`s1..sN`), stacked or grouped, and the clock that decides the in-flight tail. + */ + QueryBuilderBar: { + data: Schema.Array( + Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Number, Schema.Boolean])), + ), + colors: Schema.Record(Schema.String, Schema.String), + fallbackColor: Schema.String, + unit: Nullable(Schema.String), + timeZone: Nullable(Schema.String), + nowMs: Schema.Number, + stacked: Schema.Boolean, + showTooltip: Schema.Boolean, + axis: Schema.optionalKey(TimeseriesAxisSpec), + }, + /** `IssueOccurrenceChart` (apps/web/src/components/errors): bucketed counts as bars. */ + Occurrences: { + points: Schema.Array(Schema.Struct({ bucket: Schema.String, count: Schema.Number })), + color: Schema.String, + timeZone: Schema.String, + }, + /** `LogsVolumePlot`: the /logs severity histogram, stacked per bucket, with its drag selection. */ + LogsVolume: { + buckets: Schema.Array( + Schema.Struct({ bucket: Schema.String, counts: Schema.Record(Schema.String, Schema.Number) }), + ), + seriesKeys: Schema.Array(Schema.String), + /** Each series' resolved colour, by key. */ + colors: Schema.Record(Schema.String, Schema.String), + border: Schema.String, + selection: Nullable(Schema.Struct({ left: Schema.String, right: Schema.String })), + isSelecting: Schema.Boolean, + timeZone: Schema.String, + }, + /** `HistogramSpike` (binX over raw observations) in apps/web/src/lab/charts/histogram-spike.tsx. */ + BinnedHistogram: { + values: Schema.Array(Schema.Number), + color: Schema.String, + logScaleY: Schema.Boolean, + }, + /** `InfraMetricChart` (apps/web/src/components/infra/primitives): the host and Kubernetes detail charts. */ + InfraMetric: { + rows: Schema.Array( + Schema.Struct({ bucket: Schema.String, attributeValue: Schema.String, value: Schema.Number }), + ), + unit: Schema.Literals([ + "percent", + "cores", + "seconds", + "load", + "bytes_per_second", + "bytes", + "milliseconds", + "rate", + "count", + ]), + seriesLabel: Nullable(Schema.String), + stacked: Schema.Boolean, + showThreshold: Schema.Boolean, + /** Bucket ISO strings the x axis spans (siblings sharing one axis); `null` domains over the rows. */ + xDomain: Nullable(Schema.Array(Schema.String)), + gaps: Schema.Boolean, + /** Resolved colour per series name (`resolveSeriesColors`, then the call site's overrides). */ + colors: Schema.Record(Schema.String, Schema.String), + chrome: Schema.Struct({ border: Schema.String, background: Schema.String }), + /** The 80% rule's colour, `--severity-warn` resolved. */ + thresholdColor: Schema.String, + gradientPrefix: Schema.String, + timeZone: Schema.String, + /** Deploy markers drawn at the same instant on every strip. */ + markers: Schema.Array(ChartEventMarker), + markerColors: MarkerColors, + }, + /** + * The `/lab/bench/tanstack` overview charts (apps/web `lab/bench/tanstack/*.tsx`): point-scaled + * buckets, y pinned at 0, no legend. Rows are `overviewBenchRows`. + */ + BenchOverview: { + chart: Schema.Literals(["Throughput", "ErrorRate", "Latency"]), + rows: Schema.Array(FixedMetricRow), + /** Resolved series colours by series key; `border`/`background` are the chrome. */ + colors: Schema.Record(Schema.String, Schema.String), + gradientId: Schema.String, + }, + /** `ThroughputAreaChart`, `ErrorRateAreaChart`, `ApdexAreaChart`, `LatencyLineChart` (charts/area, charts/line). */ + FixedMetric: { + chart: Schema.Literals(["Throughput", "ErrorRate", "Latency", "Apdex"]), + /** `yAxisWidth`: a left-margin lock that aligns plot rects across a grid. */ + marginLeft: Schema.optionalKey(Schema.Number), + rows: Schema.Array(FixedMetricRow), + /** Resolved series colours by series key; `border`/`background` are the chrome. */ + colors: Schema.Record(Schema.String, Schema.String), + perSecond: Schema.Boolean, + timeZone: Nullable(Schema.String), + /** The render-time clock the in-flight split reads (`Date.now()` in legacy). */ + nowMs: Schema.Number, + gradientPrefix: Schema.String, + showTooltip: Schema.Boolean, + }, + /** + * `AlertRuleChart` (apps/web/src/components/alerts/alert-rule-chart.tsx): the rule's signal, the + * threshold rules and the shaded bands, already decided by `@maple/web-data/alerts/chart-series`. + */ + AlertRule: { + rows: Schema.Array(Schema.Record(Schema.String, Schema.NullOr(Schema.Number))), + seriesKeys: Schema.Array(Schema.String), + /** Tooltip labels by series key (`Query`, `Evaluated`, or the group). */ + labels: Schema.Record(Schema.String, Schema.String), + isMultiSeries: Schema.Boolean, + hasGhost: Schema.Boolean, + /** Resolved colours: `signal`, `destructive`, `warn`, `muted`, `border`, `background`, series keys. */ + colors: Schema.Record(Schema.String, Schema.String), + domainMin: Schema.Number, + domainMax: Schema.Number, + yMax: Schema.Number, + threshold: Schema.Number, + thresholdUpper: Nullable(Schema.Number), + criticalThreshold: Nullable(Schema.Number), + comparator: Schema.String, + signalType: Schema.String, + bands: Schema.Struct({ + noData: Schema.Array(Schema.Struct({ x1: Schema.Number, x2: Schema.Number })), + incidentOpen: Schema.Array(Schema.Struct({ x1: Schema.Number, x2: Schema.Number })), + incidentClosed: Schema.Array(Schema.Struct({ x1: Schema.Number, x2: Schema.Number })), + held: Schema.Array(Schema.Struct({ x1: Schema.Number, x2: Schema.Number })), + wouldFire: Schema.Array(Schema.Struct({ x1: Schema.Number, x2: Schema.Number })), + }), + /** The tooltip heading's per-bucket facts (`bucketMeta`). */ + meta: Schema.Array( + Schema.Struct({ + t: Schema.Number, + sampleCount: Schema.Number, + status: Schema.String, + provisional: Schema.Boolean, + }), + ), + timeZone: Schema.String, + bucketSeconds: Nullable(Schema.Number), + gradientId: Schema.String, + }, + /** `StackedBreakdownChart` (components/infra/cloudflare/cloudflare-zone-detail-charts.tsx). */ + StackedBreakdown: { + rows: Schema.Array( + Schema.Struct({ bucket: Schema.String, attributeValue: Schema.String, value: Schema.Number }), + ), + /** Stack and legend order, already folded and ranked. */ + series: Schema.Array(Schema.String), + /** Display label per series (the pooled tail reads "Other"). */ + labels: Schema.Record(Schema.String, Schema.String), + /** Resolved colour per series, plus `border` / `background` chrome. */ + colors: Schema.Record(Schema.String, Schema.String), + timeZone: Schema.String, + gradientPrefix: Schema.String, + }, + /** + * `AnalyticsTrafficChart` (components/analytics/analytics-traffic-chart.tsx): the selected metric + * and its companion per bucket, painted largest total first, ticks in the metric's format. + */ + AnalyticsTraffic: { + points: Schema.Array( + Schema.Struct({ + bucket: Schema.String, + primary: Schema.optionalKey(Nullable(Schema.Number)), + companion: Schema.optionalKey(Nullable(Schema.Number)), + }), + ), + /** The metric keys behind `primary` and (when paired) `companion`, in paint order. */ + series: Schema.Array(Schema.Struct({ key: Schema.Literals(["primary", "companion"]), metric: Schema.String })), + /** The metric whose format the y ticks use. */ + tickMetric: Schema.String, + /** Resolved `primary` / `companion` colours plus `border` / `background` chrome. */ + colors: Schema.Record(Schema.String, Schema.String), + timeZone: Schema.String, + gradientPrefix: Schema.String, + isNarrow: Schema.Boolean, + }, + /** `PlanetScaleChart` (components/infra/planetscale/planetscale-chart.tsx): one gauge, gaps kept. */ + PlanetScaleMetric: { + metric: Schema.Literals([ + "connectionsAvg", + "cpuMaxPercent", + "memMaxPercent", + "storageUsedPercent", + "replicaLagMaxSeconds", + ]), + points: Schema.Array(Schema.Struct({ bucket: Schema.String, value: Nullable(Schema.Number) })), + /** The series colour plus `border` / `background` chrome. */ + colors: Schema.Record(Schema.String, Schema.String), + markers: Schema.Array(ChartEventMarker), + markerColors: MarkerColors, + timeZone: Schema.String, + }, + /** `AnomalyTimeseriesChart` (components/anomalies/): observed signal, baseline and threshold rules. */ + AnomalySignal: { + points: Schema.Array(Schema.Struct({ bucket: Schema.String, value: Schema.Number })), + signalType: Schema.Literals(["error_rate", "latency_p95", "throughput", "error_spike", "log_volume"]), + baselineMedian: Schema.Number, + thresholdValue: Schema.Number, + firstTriggeredAt: Schema.String, + resolvedAt: Nullable(Schema.String), + /** `stroke`, `muted`, `threshold`, plus `border` / `background` chrome, resolved. */ + colors: Schema.Record(Schema.String, Schema.String), + gradientId: Schema.String, + timeZone: Schema.String, + }, + /** + * `SpendChart` (apps/web/src/components/settings/spend-chart.tsx): cumulative spend by band, day + * by day (`buildCumulativeSeries`), and the dashed projection to the end of the cycle. + */ + SpendCumulative: { + points: Schema.Array( + Schema.Struct({ + date: Schema.String, + dayMs: Schema.Number, + /** Cumulative dollars per band (`base`, then each feature); `null` on days to come. */ + bands: Schema.Record(Schema.String, Schema.NullOr(Schema.Number)), + projected: Nullable(Schema.Number), + future: Schema.Boolean, + }), + ), + bands: Schema.Array(Schema.Struct({ key: Schema.String, label: Schema.String, color: Schema.String })), + primary: Schema.String, + chrome: Schema.Struct({ border: Schema.String, background: Schema.String }), + projected: Schema.Number, + yMax: Schema.Number, + currency: Schema.String, + }, + /** `QueryBuilderHeatmapChart`: band cells and focusable holes over a sequential ramp. */ + Heatmap: { + cells: Schema.Array(HeatmapSlotSchema), + holes: Schema.Array(HeatmapSlotSchema), + xDomain: Schema.Array(Schema.String), + yDomain: Schema.Array(Schema.String), + /** The y tick text per category, already shortened and truncated into the gutter. */ + yLabels: Schema.Record(Schema.String, Schema.String), + margin: Nullable( + Schema.Struct({ top: Schema.Number, right: Schema.Number, bottom: Schema.Number, left: Schema.Number }), + ), + paddingInnerX: Schema.Number, + paddingInnerY: Schema.Number, + radius: Schema.Number, + stops: Schema.Array(Schema.String), + domain: Schema.Tuple([Schema.Number, Schema.Number]), + scaleType: Schema.Literals(["linear", "log"]), + grout: Schema.String, + foreground: Schema.String, + showTooltip: Schema.Boolean, + }, + /** + * A `/lab/charts` prototype, drawn by the lab's own host (`lab/charts/spike-host.ts`): the arm + * names its fixture and definition; the legend's emphasis and the resolved tokens ride along. + */ + LabSpike: { + arm: Schema.String, + colors: Schema.Record(Schema.String, Schema.String), + highlighted: Nullable(Schema.String), + active: Nullable(Schema.String), + hidden: Schema.Array(Schema.String), + }, +}) +export type ChartSpec = typeof ChartSpec.Type + +const ChartSpecJson = Schema.fromJsonString(ChartSpec) + +export const encodeChartSpec = Schema.encodeSync(ChartSpecJson) +export const decodeChartSpec = Schema.decodeUnknownOption(ChartSpecJson) + +/** What a tooltip body renders: the focused datum, reduced to data. */ +export const ChartTooltip = defineTaggedUnion({ + PieSlice: { + name: Schema.String, + value: Schema.Number, + fraction: Schema.Number, + color: Schema.String, + collapsedCount: Schema.optionalKey(Schema.Number), + }, + NumericBin: { x1: Schema.Number, x2: Schema.Number, value: Schema.Number }, + CategoricalBin: { name: Schema.String, value: Schema.Number }, + HeatmapSlot: { x: Schema.String, y: Schema.String, value: Schema.NullOr(Schema.Number) }, + /** `PlotTooltipBody` for a fixed-metric chart: the bucket heading and one formatted row per series. */ + Bucket: { + heading: Schema.String, + /** The row `PlotTooltipBody` emphasises: the series nearest the cursor. */ + highlighted: Schema.optionalKey(Schema.String), + rows: Schema.Array( + Schema.Struct({ + label: Schema.String, + color: Schema.String, + dashed: Schema.Boolean, + value: Schema.String, + }), + ), + /** A closing total row under the series (the lab's stacked bar). */ + total: Schema.optionalKey(Schema.String), + }, +}) +export type ChartTooltip = typeof ChartTooltip.Type + +/** The attribute the chart Mount watches. */ +export const CHART_SPEC_ATTRIBUTE = "data-chart-spec" + +/** The chart's accessible name, watched with the spec so a relabelled chart is relabelled in place. */ +export const CHART_LABEL_ATTRIBUTE = "data-chart-label" diff --git a/apps/web-foldkit/src/charts/chart.scene.test.ts b/apps/web-foldkit/src/charts/chart.scene.test.ts new file mode 100644 index 0000000000..bb08436b48 --- /dev/null +++ b/apps/web-foldkit/src/charts/chart.scene.test.ts @@ -0,0 +1,107 @@ +import { pieSampleData } from "@maple/ui/components/charts/_shared/sample-data" +import { Option } from "effect" +import { + attr, + click, + expect, + find, + given, + hover, + Mount, + role, + scene, + selector, + tap, + withViewInputs, +} from "foldkit/scene" +import { modifyFields } from "foldkit/struct" +import { describe, expect as expectValue, test } from "vitest" +import * as Chart from "./chart" +import { MeasureChartContainer, MountChart, PortalTooltipBody } from "./chart-mounts" +import { CHART_SPEC_ATTRIBUTE, ChartTooltip, decodeChartSpec } from "./chart-spec" +import * as PieChart from "./pie-chart" + +// The production pie as the lab renders it: side stats legend once the card is measured wide. +const CARD_WIDTH = 670 +const view = withViewInputs(PieChart.view, { + chartId: "pie", + data: pieSampleData, + theme: { "--background": "#ffffff" }, + legend: "right", + tooltip: "visible", +})() +const measured = modifyFields(Chart.init(), { containerWidth: () => CARD_WIDTH }) + +const legendRow = (name: string) => role("button", { name: new RegExp(`^${name}`) }) +const tooltip = selector("[data-chart-tooltip-holder]") + +/** What the chart Mount draws from: the pie spec on the plot box. */ +const expectPieSpec = (expected: { activeName: string | null; highlighted: string | null }) => + tap((simulation) => { + const spec = Option.flatMap( + Option.flatMap(find(simulation.html, "[data-chart-plot-box]"), attr(CHART_SPEC_ATTRIBUTE)), + decodeChartSpec, + ) + expectValue( + Option.map(spec, (value) => + value._tag === "Pie" + ? { activeName: value.activeName, highlighted: value.highlighted } + : value._tag, + ), + ).toEqual(Option.some(expected)) + }) + +const resolveMounts = (mountChartResult: Chart.Message) => + Mount.resolveAll( + [MeasureChartContainer, Chart.Message.ResizedChartContainer({ width: CARD_WIDTH, height: 0 })], + [MountChart, mountChartResult], + ) + +const apiGatewaySlice = ChartTooltip.PieSlice({ + name: "api-gateway", + value: 4820, + fraction: 0.43, + color: "#0a7", +}) + +describe("chart hover", () => { + test("plot focus on a slice grows that slice", () => { + scene( + { update: Chart.update, view }, + given(measured), + expectPieSpec({ activeName: null, highlighted: null }), + resolveMounts(Chart.Message.ChangedChartFocus({ maybeKey: Option.some("api-gateway") })), + expectPieSpec({ activeName: "api-gateway", highlighted: null }), + ) + }) + + test("hovering a legend row grows its slice, clicking pins it until clicked again", () => { + scene( + { update: Chart.update, view }, + given(measured), + resolveMounts(Chart.Message.CompletedMountChart()), + hover(legendRow("order-service")), + expectPieSpec({ activeName: "order-service", highlighted: null }), + click(legendRow("order-service")), + expect(legendRow("order-service")).toHaveAttr("aria-pressed", "true"), + expectPieSpec({ activeName: "order-service", highlighted: "order-service" }), + click(legendRow("order-service")), + expect(legendRow("order-service")).toHaveAttr("aria-pressed", "false"), + expectPieSpec({ activeName: "order-service", highlighted: null }), + ) + }) + + test("the tooltip body follows the tooltip datum and goes when the tooltip hides", () => { + scene( + { update: Chart.update, view }, + given(modifyFields(measured, { tooltip: () => Option.some(apiGatewaySlice) })), + expect(tooltip).toContainText("api-gateway"), + expect(tooltip).toContainText("4.8K"), + expect(tooltip).toContainText("43%"), + Mount.resolve(PortalTooltipBody, Chart.Message.CompletedPortalTooltipBody()), + resolveMounts(Chart.Message.ChangedChartTooltip({ maybeTooltip: Option.none() })), + Mount.expectEnded(PortalTooltipBody), + expect(tooltip).toBeEmpty(), + ) + }) +}) diff --git a/apps/web-foldkit/src/charts/chart.ts b/apps/web-foldkit/src/charts/chart.ts new file mode 100644 index 0000000000..2200a4f906 --- /dev/null +++ b/apps/web-foldkit/src/charts/chart.ts @@ -0,0 +1,125 @@ +import { Option, Schema } from "effect" +import type { Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" +import { ChartTooltip } from "./chart-spec" + +/** + * One chart's interaction state, the part React kept in `useState` beside the chart: the hovered + * datum (from the plot's focus or a legend row), the legend's pinned emphasis, the tooltip datum + * and the measured container width. The library owns the canvas; this owns what the view reads. + */ + +// MODEL + +/** The plot rect and the x scale's domain-to-pixel mapping of the last render (`usePlotRect`, `usePlotScales`). */ +export const PlotGeometry = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + domainStartMs: Schema.Number, + domainEndMs: Schema.Number, + rangeStart: Schema.Number, + rangeEnd: Schema.Number, +}) +export type PlotGeometry = typeof PlotGeometry.Type + +export const Model = Schema.Struct({ + /** Set once the chart rendered a time x scale; overlays (deploy markers) position against it. */ + plot: Schema.Option(PlotGeometry), + /** `activeName`: the focused datum's key, shared by plot focus and legend hover. */ + activeKey: Schema.Option(Schema.String), + /** `usePlotLegendHighlight`: a legend click pins one key until clicked again. */ + highlightedKey: Schema.Option(Schema.String), + /** What the tooltip shell's body shows; `None` while the library hides it. */ + tooltip: Schema.Option(ChartTooltip), + /** `useContainerSize(containerRef).width`, rounded; 0 before the first measurement. */ + containerWidth: Schema.Number, + /** `useContainerSize(containerRef).height`: a side legend's height cap; 0 before measured. */ + containerHeight: Schema.Number, + /** `useSeriesVisibility`: series keys a time-series legend has hidden. */ + hiddenKeys: Schema.Array(Schema.String), + /** A grouped DOM chart's hovered group (a funnel breakdown), beside the hovered row. */ + activeGroup: Schema.Option(Schema.String), +}) +export type Model = typeof Model.Type + +export const init = (): Model => ({ + plot: Option.none(), + activeKey: Option.none(), + highlightedKey: Option.none(), + tooltip: Option.none(), + containerWidth: 0, + containerHeight: 0, + hiddenKeys: [], + activeGroup: Option.none(), +}) + +// MESSAGE + +export const Message = defineMessageUnion({ + CompletedMountChart: {}, + FailedMountChart: { reason: Schema.String }, + RenderedPlot: { plot: PlotGeometry }, + ChangedChartFocus: { maybeKey: Schema.Option(Schema.String) }, + ChangedChartTooltip: { maybeTooltip: Schema.Option(ChartTooltip) }, + CompletedPortalTooltipBody: {}, + ResizedChartContainer: { width: Schema.Number, height: Schema.Number }, + EnteredLegendItem: { key: Schema.String }, + LeftLegend: {}, + ClickedLegendItem: { key: Schema.String }, + /** A hiding legend's row: `keys` are every series, so the last visible one cannot be hidden. */ + ToggledLegendSeries: { key: Schema.String, keys: Schema.Array(Schema.String) }, + ChangedActiveGroup: { maybeGroup: Schema.Option(Schema.String) }, + /** The pointer left a DOM chart: no row and no group is hovered. */ + LeftChart: {}, +}) +export type Message = typeof Message.Type + +// UPDATE + +/** `useSeriesVisibility`'s toggle: unhide, or hide unless it is the last series left. */ +const toggledHidden = ( + hidden: ReadonlyArray, + key: string, + keys: ReadonlyArray, +): ReadonlyArray => { + if (hidden.includes(key)) return hidden.filter((candidate) => candidate !== key) + const remaining = keys.filter((candidate) => candidate !== key && !hidden.includes(candidate)).length + return remaining === 0 ? hidden : [...hidden, key] +} + +const toggled = (current: Option.Option, key: string): Option.Option => + Option.isSome(current) && current.value === key ? Option.none() : Option.some(key) + +export const update = (model: Model, message: Message) => + Message.match>(message, { + CompletedMountChart: () => ({ model }), + FailedMountChart: () => ({ model }), + RenderedPlot: ({ plot }) => ({ model: modifyFields(model, { plot: () => Option.some(plot) }) }), + ChangedChartFocus: ({ maybeKey }) => ({ model: modifyFields(model, { activeKey: () => maybeKey }) }), + ChangedChartTooltip: ({ maybeTooltip }) => ({ + model: modifyFields(model, { tooltip: () => maybeTooltip }), + }), + CompletedPortalTooltipBody: () => ({ model }), + ResizedChartContainer: ({ width, height }) => ({ + model: modifyFields(model, { containerWidth: () => width, containerHeight: () => height }), + }), + EnteredLegendItem: ({ key }) => ({ + model: modifyFields(model, { activeKey: () => Option.some(key) }), + }), + LeftLegend: () => ({ model: modifyFields(model, { activeKey: () => Option.none() }) }), + ClickedLegendItem: ({ key }) => ({ + model: modifyFields(model, { highlightedKey: (current) => toggled(current, key) }), + }), + ChangedActiveGroup: ({ maybeGroup }) => ({ + model: modifyFields(model, { activeGroup: () => maybeGroup }), + }), + LeftChart: () => ({ + model: modifyFields(model, { activeKey: () => Option.none(), activeGroup: () => Option.none() }), + }), + ToggledLegendSeries: ({ key, keys }) => ({ + model: modifyFields(model, { hiddenKeys: (current) => toggledHidden(current, key, keys) }), + }), + }) diff --git a/apps/web-foldkit/src/charts/definitions/alert-rule.ts b/apps/web-foldkit/src/charts/definitions/alert-rule.ts new file mode 100644 index 0000000000..9647950c2a --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/alert-rule.ts @@ -0,0 +1,242 @@ +import { focusCrosshair } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { bucketTimeScale } from "@maple/ui/components/plot/plot-scales" +import { formatBucketLabel } from "@maple/ui/lib/format" +import { formatSignalValue } from "@maple/web-data/alerts/form-utils" +import { GHOST_KEY, SINGLE_KEY } from "@maple/web-data/alerts/chart-series" +import { areaY, d3Curve, defineChart, lineY, rect, ruleY, type ChartValue } from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { curveMonotoneX } from "d3-shape" +import { Option, Schema } from "effect" +import { AlertSignalType } from "@maple/domain/http" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { roundCapDasharray } from "./fixed-metric-axes" +import { cursorTooltip } from "./plot-constants" + +// The definition half of `AlertRuleChart` (apps/web/src/components/alerts/alert-rule-chart.tsx); +// code copied, constants unchanged. The view half is `page/alerts/shared/rule-chart.ts`. + +type AlertRuleSpec = Extract +type SignalPoint = Record & { t: number; at: Date } +type Band = Readonly<{ x1: number; x2: number }> + +const PLOT_RIGHT = 12 +const Y_AXIS_WIDTH = 72 +const isSignalType = Schema.is(AlertSignalType) + +const num = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} +const clamp01 = (value: number): number => (value < 0 ? 0 : value > 1 ? 1 : value) + +export const buildAlertRule = (spec: AlertRuleSpec): BuiltChart => { + const signalType: AlertSignalType = isSignalType(spec.signalType) ? spec.signalType : "builder_query" + const color = (key: string) => spec.colors[key] ?? spec.colors.border ?? "" + const palette = { + signal: color("signal"), + destructive: color("destructive"), + warn: color("warn"), + muted: color("muted"), + } + const chrome = { border: color("border"), background: color("background") } + const yDomain: [number, number] = [0, spec.yMax] + const breachAbove = spec.comparator === "gt" || spec.comparator === "gte" + const breachBelow = spec.comparator === "lt" || spec.comparator === "lte" + const splitOffset = clamp01((yDomain[1] - spec.threshold) / (yDomain[1] - yDomain[0] || 1)) + const axisContext = { + rangeMs: spec.domainMax - spec.domainMin, + bucketSeconds: spec.bucketSeconds ?? undefined, + timeZone: spec.timeZone, + } + const formatTime = (value: number, mode: "tick" | "tooltip") => + formatBucketLabel(new Date(value).toISOString(), axisContext, mode) + const points: Array = spec.rows.map((row) => ({ ...row, t: num(row.t), at: new Date(num(row.t)) })) + const at = (point: SignalPoint) => point.at + const valueOf = (key: string) => (point: SignalPoint) => { + const value = point[key] + return typeof value === "number" ? value : null + } + const curve = d3Curve(curveMonotoneX) + const gradientId = spec.gradientId + + const signalGradient = { + id: gradientId, + x1: 0, + y1: 0, + x2: 0, + y2: 1, + stops: breachAbove + ? [ + { offset: 0, color: palette.destructive, opacity: 0.32 }, + { offset: splitOffset, color: palette.destructive, opacity: 0.08 }, + { offset: splitOffset, color: palette.signal, opacity: 0.12 }, + { offset: 1, color: palette.signal, opacity: 0.02 }, + ] + : breachBelow + ? [ + { offset: 0, color: palette.signal, opacity: 0.12 }, + { offset: splitOffset, color: palette.signal, opacity: 0.05 }, + { offset: splitOffset, color: palette.destructive, opacity: 0.08 }, + { offset: 1, color: palette.destructive, opacity: 0.3 }, + ] + : [ + { offset: 0.05, color: palette.signal, opacity: 0.45 }, + { offset: 0.95, color: palette.signal, opacity: 0.04 }, + ], + } + + const band = (bands: ReadonlyArray, fill: string, fillOpacity: number, id: string) => + bands.length === 0 + ? [] + : [ + decorative( + rect(bands, { + id, + x1: (b: Band) => new Date(b.x1), + x2: (b: Band) => new Date(b.x2), + y1: () => yDomain[0], + y2: () => yDomain[1], + fill, + fillOpacity, + stroke: "none", + }), + ), + ] + + const definition = defineChart({ + gradients: [signalGradient], + marks: [ + ...band(spec.bands.noData, palette.muted, 0.1, "no-data"), + ...band(spec.bands.incidentOpen, palette.destructive, 0.12, "incident-open"), + ...band(spec.bands.incidentClosed, palette.destructive, 0.06, "incident-closed"), + ...band(spec.bands.held, palette.muted, 0.16, "incident-held"), + ...band(spec.bands.wouldFire, palette.destructive, 0.08, "would-fire"), + ruleY( + spec.thresholdUpper != null + ? [{ value: spec.threshold }, { value: spec.thresholdUpper }] + : [{ value: spec.threshold }], + { + y: (entry: { value: number }) => entry.value, + stroke: spec.criticalThreshold != null ? palette.warn : palette.destructive, + strokeOpacity: 1, + strokeWidth: 1.5, + strokeDasharray: "6 4", + }, + ), + ...(spec.criticalThreshold != null + ? [ + ruleY([{ value: spec.criticalThreshold }], { + y: (entry: { value: number }) => entry.value, + stroke: palette.destructive, + strokeOpacity: 1, + strokeWidth: 1.5, + strokeDasharray: "6 4", + }), + ] + : []), + ...(spec.hasGhost + ? [ + lineY(points, { + id: GHOST_KEY, + x: at, + y: valueOf(GHOST_KEY), + stroke: palette.muted, + strokeWidth: 1.5, + strokeDasharray: roundCapDasharray(5, 3, 1.5), + curve, + }), + ] + : []), + ...(spec.isMultiSeries + ? spec.seriesKeys.map((key) => + lineY(points, { + id: key, + x: at, + y: valueOf(key), + stroke: color(key), + strokeWidth: 1.5, + curve, + }), + ) + : [ + areaY(points, { + id: `${SINGLE_KEY}-band`, + x: at, + y: valueOf(SINGLE_KEY), + y1: () => yDomain[0], + fill: `url(#${gradientId})`, + stroke: "none", + curve, + }), + lineY(points, { + id: SINGLE_KEY, + x: at, + y: valueOf(SINGLE_KEY), + stroke: palette.signal, + strokeWidth: 2, + curve, + }), + ]), + focusCrosshair(chrome), + ], + scales: { + x: { + scale: bucketTimeScale([new Date(spec.domainMin), new Date(spec.domainMax)], spec.timeZone), + axis: { + line: false, + ticks: { size: 0, padding: 8, format: (value: Date) => formatTime(value.getTime(), "tick") }, + tickLabels: { thin: { minGap: 12 } }, + }, + }, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { + line: false, + ticks: { size: 0, padding: 8, format: (value: number) => formatSignalValue(signalType, value) }, + }, + }, + }, + margin: { top: 8, right: PLOT_RIGHT, left: Y_AXIS_WIDTH }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + + const metaByT = new Map(spec.meta.map((entry) => [entry.t, entry])) + const tooltipKeys = [...(spec.isMultiSeries ? spec.seriesKeys : [SINGLE_KEY]), ...(spec.hasGhost ? [GHOST_KEY] : [])] + const tooltipOf = ( + focused: ReadonlyArray<{ readonly datum: SignalPoint }>, + ): Option.Option => + Option.map(Option.fromNullishOr(focused[0]), ({ datum }) => { + const t = num(datum.t) + const meta = metaByT.get(t) + const extras = [ + meta != null ? `${meta.sampleCount} samples` : null, + meta?.status === "skipped" ? "skipped" : null, + meta?.provisional === true ? "in progress" : null, + ].filter((part) => part !== null) + const label = formatTime(t, "tooltip") + return ChartTooltip.Bucket({ + heading: extras.length > 0 ? `${label} · ${extras.join(" · ")}` : label, + rows: tooltipKeys.flatMap((key) => { + const value = datum[key] + return typeof value !== "number" + ? [] + : [ + { + label: spec.labels[key] ?? key, + color: key === GHOST_KEY ? palette.muted : spec.isMultiSeries ? color(key) : palette.signal, + dashed: key === GHOST_KEY, + value: formatSignalValue(signalType, value), + }, + ] + }), + }) + }) + + return { definition, focusKeyOf: (point) => String(num(point.datum.t)), tooltipOf } +} diff --git a/apps/web-foldkit/src/charts/definitions/analytics-traffic.ts b/apps/web-foldkit/src/charts/definitions/analytics-traffic.ts new file mode 100644 index 0000000000..9fff821db9 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/analytics-traffic.ts @@ -0,0 +1,116 @@ +import { ANALYTICS_METRICS, findMetric } from "@maple/web/components/analytics/metrics" +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { areaY, d3Curve, defineChart, lineY, type ChartPoint, type ChartValue } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { verticalGradient } from "./fixed-metric-axes" +import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" + +// Port of `AnalyticsTrafficChart`'s definition (apps/web/src/components/analytics/analytics-traffic-chart.tsx), +// constants unchanged. Colours arrive resolved in the spec; series arrive in paint order. + +type TrafficSpec = Extract +type SeriesKey = "primary" | "companion" + +interface TrafficPoint { + readonly bucket: string + readonly date: Date + readonly primary?: number | null + readonly companion?: number | null +} + +/** The descriptor a spec names by key; an unknown key reads as the strip's first metric. */ +const metricOf = (key: string) => ANALYTICS_METRICS.find((metric) => metric.key === key) ?? findMetric("visitors") + +export const buildAnalyticsTraffic = ( + spec: TrafficSpec, +): BuiltChart => { + const data: ReadonlyArray = spec.points.map((point) => ({ ...point, date: bucketDate(point.bucket) })) + const axis = makeBucketAxis( + data.map((point) => point.bucket), + spec.timeZone, + ) + const chrome = { border: spec.colors["border"] ?? "", background: spec.colors["background"] ?? "" } + const colorOf = (key: SeriesKey) => spec.colors[key] ?? chrome.border + const at = (point: TrafficPoint) => point.date + const valueOf = (key: SeriesKey) => (point: TrafficPoint) => { + const value = point[key] + return typeof value === "number" ? value : null + } + const curve = d3Curve(curveMonotoneX) + const tickMetric = metricOf(spec.tickMetric) + const focus = tooltipFocusCapture() + const definition = defineChart({ + gradients: spec.series.map((entry) => + verticalGradient(`${spec.gradientPrefix}-${entry.key}`, colorOf(entry.key), 0.35, 0.02), + ), + marks: [ + ...spec.series.flatMap((entry) => [ + areaY(data, { + id: `${entry.key}-band`, + x: at, + y: valueOf(entry.key), + y1: () => 0, + fill: `url(#${spec.gradientPrefix}-${entry.key})`, + stroke: "none", + curve, + }), + lineY(data, { + id: entry.key, + x: at, + y: valueOf(entry.key), + stroke: colorOf(entry.key), + strokeWidth: 1.5, + curve, + }), + ]), + ...spec.series.map((entry) => focusDot(data, at, valueOf(entry.key), colorOf(entry.key), chrome)), + focusCrosshair(chrome), + ], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear, + axis: { + line: false, + ticks: { + size: 0, + padding: 8, + format: (value: number) => (value === 0 ? "0" : tickMetric.format(value)), + }, + }, + }, + }, + margin: { left: spec.isNarrow ? 36 : 52, right: 8, top: 4 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip(focus.anchor), + }) + const tooltipOf = (points: ReadonlyArray>) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => { + const highlighted = focus.highlight( + spec.series.map((entry): readonly [string, number | null] => [ + metricOf(entry.metric).label, + valueOf(entry.key)(datum), + ]), + ) + const base = { + heading: axis.heading(datum.bucket), + rows: spec.series.flatMap((entry) => { + const value = valueOf(entry.key)(datum) + const metric = metricOf(entry.metric) + return value === null + ? [] + : [{ label: metric.label, color: colorOf(entry.key), dashed: false, value: metric.format(value) }] + }), + } + return highlighted === undefined ? ChartTooltip.Bucket(base) : ChartTooltip.Bucket({ ...base, highlighted }) + }) + return { definition, focusKeyOf: (point) => point.datum.bucket, tooltipOf } +} diff --git a/apps/web-foldkit/src/charts/definitions/anomaly-signal.ts b/apps/web-foldkit/src/charts/definitions/anomaly-signal.ts new file mode 100644 index 0000000000..213d222516 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/anomaly-signal.ts @@ -0,0 +1,151 @@ +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { toEpochMs } from "@maple/ui/lib/time-format" +import { formatSignalValue } from "@maple/web-data/anomalies/format" +import { areaY, type ChartValue, d3Curve, defineChart, lineY, rect, ruleY, text } from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { verticalGradient } from "./fixed-metric-axes" +import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" + +// The definition half of `AnomalyTimeseriesChart` (apps/web/src/components/anomalies/ +// anomaly-timeseries-chart.tsx); code copied. `thresholdRules` is copied below because +// threshold-rules.ts reads the theme through a React module; colours arrive resolved. + +type AnomalySignalSpec = Extract +type SignalPoint = Readonly<{ bucket: string; value: number }> +type Threshold = Readonly<{ value: number; color: string; label: string }> + +// copied: threshold-rules.ts constants. +const THRESHOLD_STROKE_WIDTH = 1.5 +const THRESHOLD_LABEL_FONT_SIZE = 10 +const THRESHOLD_LABEL_DX = -4 +const THRESHOLD_LABEL_DY = -6 + +/** `thresholdRules` with resolved colours: one dashed `ruleY`, labels anchored at `labelX`. */ +const thresholdRules = (thresholds: ReadonlyArray, labelX: Date | undefined) => { + const valid = thresholds.filter((threshold) => Number.isFinite(threshold.value)) + if (valid.length === 0) return [] + const rules = ruleY(valid, { + y: (threshold: Threshold) => threshold.value, + stroke: (threshold: Threshold) => threshold.color, + strokeOpacity: 1, + strokeWidth: THRESHOLD_STROKE_WIDTH, + strokeDasharray: "4 4", + }) + if (labelX === undefined) return [rules] + return [ + rules, + decorative( + text(valid, { + x: () => labelX, + y: (threshold: Threshold) => threshold.value, + text: (threshold: Threshold) => threshold.label, + fill: (threshold: Threshold) => threshold.color, + anchor: "end", + dx: THRESHOLD_LABEL_DX, + dy: THRESHOLD_LABEL_DY, + fontSize: THRESHOLD_LABEL_FONT_SIZE, + }), + ), + ] +} + +/** The points that parse, oldest first: what the chart draws (empty means the empty message). */ +export const sortedSignal = (points: ReadonlyArray): ReadonlyArray => + points.toSorted((a, b) => toEpochMs(a.bucket) - toEpochMs(b.bucket)) + +export const buildAnomalySignal = (spec: AnomalySignalSpec): BuiltChart => { + const color = (key: string) => spec.colors[key] ?? spec.colors.border ?? "" + const stroke = color("stroke") + const chrome = { border: color("border"), background: color("background") } + const valueFormatter = (value: number) => formatSignalValue(spec.signalType, Number.isFinite(value) ? value : 0) + const data = sortedSignal(spec.points) + const axis = makeBucketAxis( + data.map((point) => point.bucket), + spec.timeZone, + ) + + // Snap the incident window to actual bucket values so the shading lines up with the points. + const startMs = toEpochMs(spec.firstTriggeredAt) + const endMs = spec.resolvedAt !== null ? toEpochMs(spec.resolvedAt) : Infinity + const x1 = data.find((point) => toEpochMs(point.bucket) >= startMs)?.bucket ?? data[data.length - 1]?.bucket + const x2Raw = data.findLast((point) => toEpochMs(point.bucket) <= endMs)?.bucket + const x2 = x2Raw === undefined || x1 === undefined || toEpochMs(x2Raw) < toEpochMs(x1) ? x1 : x2Raw + const window = x1 === undefined || x2 === undefined ? null : { x1: bucketDate(x1), x2: bucketDate(x2) } + + const maxVal = data.reduce( + (max, point) => Math.max(max, point.value), + Math.max(spec.thresholdValue, spec.baselineMedian), + ) + const yDomain: [number, number] = [0, maxVal * 1.15] + const at = (point: SignalPoint) => bucketDate(point.bucket) + const value = (point: SignalPoint) => point.value + const focus = tooltipFocusCapture() + + return { + definition: defineChart({ + gradients: [verticalGradient(spec.gradientId, stroke, 0.3, 0.03)], + marks: [ + ...(window + ? [ + decorative( + rect([window], { + x1: (w: { x1: Date; x2: Date }) => w.x1, + x2: (w: { x1: Date; x2: Date }) => w.x2, + y1: () => yDomain[0], + y2: () => yDomain[1], + fill: stroke, + fillOpacity: 0.06, + stroke: "none", + }), + ), + ] + : []), + ...thresholdRules( + [ + { value: spec.baselineMedian, color: color("muted"), label: "Baseline" }, + { value: spec.thresholdValue, color: color("threshold"), label: "Threshold" }, + ], + axis.domainMs ? new Date(axis.domainMs[1]) : undefined, + ), + areaY(data, { + x: at, + y: value, + y1: () => yDomain[0], + fill: `url(#${spec.gradientId})`, + stroke: "none", + curve: d3Curve(curveMonotoneX), + }), + lineY(data, { x: at, y: value, stroke, strokeWidth: 2, curve: d3Curve(curveMonotoneX) }), + focusDot(data, at, value, stroke, chrome), + focusCrosshair(chrome), + ], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { line: false, ticks: { size: 0, padding: 8, format: valueFormatter } }, + }, + }, + margin: { top: 8, right: 8, left: 70 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip(focus.anchor), + }), + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => + ChartTooltip.Bucket({ + heading: axis.heading(datum.bucket), + rows: [{ label: "Observed", color: stroke, dashed: false, value: valueFormatter(datum.value) }], + }), + ), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/area.ts b/apps/web-foldkit/src/charts/definitions/area.ts new file mode 100644 index 0000000000..4cb5743b0a --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/area.ts @@ -0,0 +1,260 @@ +import { formatBucketLabel, formatValueByUnit } from "@maple/ui/lib/format" +import { isolatedPointIndexes, pointsFit } from "@maple/ui/components/charts/_shared/sparse-series" +import { + findFirstPartialIndex, + splitAtFirstPartial, + trimEmptyTrailingBuckets, +} from "@maple/ui/components/plot/partial-buckets" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { + areaY, + d3Curve, + defineChart, + dot, + lineY, + type ChartLinearGradient, + type ChartValue, +} from "@tanstack/charts" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { thresholdMarks } from "./threshold-marks" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import { + axisContextOf, + bucketSecondsOf, + normaliseRows, + scaleRates, + seriesDefinitionsOf, + timeseriesYAxis, +} from "../timeseries" +import { asFiniteNumber, type TimeseriesRow } from "../timeseries-rows" +import { timeseriesXAxis } from "@maple/ui/components/plot/timeseries-axis" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip, UNBOUNDED_FOCUS_DISTANCE } from "./plot-constants" + +// Port of `QueryBuilderAreaChart` and `QueryBuilderLineChart` (packages/ui/src/components/charts/area/, +// line/), definition half: bands or lines, stacked or not, linear or monotone. +// TODO(thresholds): threshold rules, log scale and fitted or soft y bounds are not ported. + +type AreaSpec = Extract + +const STROKE_WIDTH = 2 +const STACKED_STROKE_WIDTH = 1 +const AREA_FILL_OPACITY = 0.6 +const STACKED_FILL_OPACITY = 0.55 +const PARTIAL_STACKED_FILL_OPACITY = 0.2 +const FILL_GRADIENT = [0.8, 0.1] as const +const PARTIAL_FILL_GRADIENT = [0.15, 0] as const + +// copied: `verticalGradient` and `roundCapDasharray` (plot-paint.ts imports React's `useId`). +const verticalGradient = ( + id: string, + color: string, + startOpacity: number, + endOpacity: number, +): ChartLinearGradient => ({ + id, + x1: 0, + y1: 0, + x2: 0, + y2: 1, + stops: [ + { offset: 0.05, color, opacity: startOpacity }, + { offset: 0.95, color, opacity: endOpacity }, + ], +}) + +const roundCapDasharray = (on: number, off: number, strokeWidth: number): string => + `${Math.max(0.01, on - strokeWidth)} ${off + strokeWidth}` + +interface Series { + readonly key: string + readonly label: string + readonly color: string +} + +/** `computeStackBases`: per row object, where each visible band starts. */ +const stackBases = ( + rows: ReadonlyArray, + visible: ReadonlyArray, +): ReadonlyMap>> => + new Map( + rows.map((row) => { + const perKey = Object.fromEntries( + visible.map((entry, index) => [ + entry.key, + visible + .slice(0, index) + .reduce((total, below) => total + asFiniteNumber(row[below.key]), 0), + ]), + ) + return [row, perKey] + }), + ) + +export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart => { + const isLine = spec.mark === "line" + const isStacked = !isLine && spec.stacked === true + const { definitions } = seriesDefinitionsOf(spec.data) + const unit = spec.unit ?? undefined + const keys = definitions.map((definition) => definition.chartKey) + const normalised = normaliseRows(spec.data, definitions) + const rows = scaleRates(normalised, keys, unit, bucketSecondsOf(normalised)) + const visible: ReadonlyArray = definitions + .filter((definition) => !spec.hidden.includes(definition.chartKey)) + .map((definition) => ({ + key: definition.chartKey, + label: definition.rawKey, + color: spec.colors[definition.chartKey] ?? spec.fallbackColor, + })) + const visibleKeys = visible.map((entry) => entry.key) + const axisContext = axisContextOf(rows, spec.timeZone ?? undefined) + const chrome = spec.chrome + + const first = findFirstPartialIndex(rows, { now: spec.nowMs }) + const plotRows = first === -1 ? rows : trimEmptyTrailingBuckets(rows, visibleKeys, first) + const axis = spec.axis + const yAxis = timeseriesYAxis({ + rows: plotRows, + visibleKeys, + unit, + stacked: isStacked, + ...(axis === undefined + ? undefined + : { + logScale: axis.logScale, + fitYAxisToData: axis.fitYAxisToData, + ...(axis.softMin === null ? undefined : { softMin: axis.softMin }), + ...(axis.softMax === null ? undefined : { softMax: axis.softMax }), + thresholds: axis.thresholds, + }), + }) + const yDomainFloor = yAxis.domain[0] + const bases = isStacked ? stackBases(plotRows, visible) : null + const baseOf = (row: TimeseriesRow, key: string) => Math.max(yDomainFloor, bases?.get(row)?.[key] ?? 0) + const topOf = (row: TimeseriesRow, key: string) => + isLine + ? asFiniteNumber(row[key]) + : Math.max(yDomainFloor, (bases?.get(row)?.[key] ?? 0) + asFiniteNumber(row[key])) + + const dotIndexes: ReadonlyMap> = spec.showPoints === false + ? new Map() + : spec.showPoints === true || pointsFit(spec.containerWidth, plotRows.length) + ? new Map(visibleKeys.map((key) => [key, new Set(plotRows.map((_, index) => index))])) + : isolatedPointIndexes(plotRows, visibleKeys) + + const { solid, dashed } = splitAtFirstPartial(plotRows, visibleKeys, { now: spec.nowMs }) + const curve = spec.curve === "monotone" ? d3Curve(curveMonotoneX) : undefined + const partialDash = roundCapDasharray(4, 4, isStacked ? STACKED_STROKE_WIDTH : STROKE_WIDTH) + const gradientIdFor = (key: string, partial: boolean) => + `${spec.gradientPrefix}-${key}${partial ? "-partial" : ""}` + const hasDashed = dashed.length > 0 + const fillOpacityFor = (partial: boolean) => + isStacked ? (partial ? PARTIAL_STACKED_FILL_OPACITY : STACKED_FILL_OPACITY) : AREA_FILL_OPACITY + + const band = (rowSlice: ReadonlyArray, entry: Series, partial: boolean) => + areaY(rowSlice, { + id: partial ? `${entry.key}-partial-area` : `${entry.key}-area`, + x: (row: TimeseriesRow) => row.date, + y: (row: TimeseriesRow) => topOf(row, entry.key), + y1: (row: TimeseriesRow) => baseOf(row, entry.key), + fill: isStacked ? entry.color : `url(#${gradientIdFor(entry.key, partial)})`, + fillOpacity: fillOpacityFor(partial), + stroke: "none", + curve, + }) + + const edge = (rowSlice: ReadonlyArray, entry: Series, partial: boolean) => + lineY(rowSlice, { + id: partial ? `${entry.key}-partial` : entry.key, + x: (row: TimeseriesRow) => row.date, + y: (row: TimeseriesRow) => topOf(row, entry.key), + stroke: entry.color, + strokeWidth: isStacked ? STACKED_STROKE_WIDTH : STROKE_WIDTH, + curve, + strokeDasharray: partial ? partialDash : undefined, + }) + + const gradients = + isLine || isStacked + ? [] + : [ + ...visible.map((entry) => + verticalGradient(gradientIdFor(entry.key, false), entry.color, ...FILL_GRADIENT), + ), + ...(hasDashed + ? visible.map((entry) => + verticalGradient( + gradientIdFor(entry.key, true), + entry.color, + ...PARTIAL_FILL_GRADIENT, + ), + ) + : []), + ] + + const paths = isLine + ? [ + ...visible.map((entry) => edge(solid, entry, false)), + ...(hasDashed ? visible.map((entry) => edge(dashed, entry, true)) : []), + ] + : [ + ...visible.map((entry) => band(solid, entry, false)), + ...(hasDashed ? visible.map((entry) => band(dashed, entry, true)) : []), + ...(isStacked ? [] : visible.map((entry) => edge(solid, entry, false))), + ...(hasDashed ? visible.map((entry) => edge(dashed, entry, true)) : []), + ] + + const definition = defineChart({ + gradients, + marks: [ + ...thresholdMarks(axis?.thresholds ?? [], plotRows.at(-1)?.date), + ...paths, + ...visible.flatMap((entry) => { + const indexes = dotIndexes.get(entry.key) + if (!indexes || indexes.size === 0) return [] + const points = solid.filter((_, index) => indexes.has(index)) + return [ + dot(points, { + x: (row: TimeseriesRow) => row.date, + y: (row: TimeseriesRow) => topOf(row, entry.key), + r: 2.5, + fill: entry.color, + }), + ] + }), + ...visible.map((entry) => + focusDot( + plotRows, + (row: TimeseriesRow) => row.date, + (row: TimeseriesRow) => topOf(row, entry.key), + entry.color, + chrome, + ), + ), + focusCrosshair(chrome), + ], + scales: { x: timeseriesXAxis(axisContext), y: yAxis.y }, + focus: "group-x", + ...(isLine ? undefined : { maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE }), + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip("pointer") : false, + }) + + return { + definition, + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]?.datum), (datum) => + ChartTooltip.Bucket({ + heading: formatBucketLabel(datum.bucket, axisContext, "tooltip"), + rows: visible.map((entry) => ({ + label: entry.label, + color: entry.color, + dashed: false, + value: formatValueByUnit(asFiniteNumber(datum[entry.key]), unit), + })), + }), + ), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/bar.ts b/apps/web-foldkit/src/charts/definitions/bar.ts new file mode 100644 index 0000000000..ab8a2505c3 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/bar.ts @@ -0,0 +1,192 @@ +import { formatBucketLabel, formatValueByUnit } from "@maple/ui/lib/format" +import { findFirstPartialIndex, trimEmptyTrailingBuckets } from "@maple/ui/components/plot/partial-buckets" +import { bucketTimeScale, minBarLength } from "@maple/ui/components/plot/plot-scales" +import { timeseriesXAxis } from "@maple/ui/components/plot/timeseries-axis" +import { barY, type ChartValue, defineChart, group } from "@tanstack/charts" +import { Option } from "effect" +import { thresholdMarks } from "./threshold-marks" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import { + axisContextOf, + bucketSecondsOf, + normaliseRows, + scaleRates, + seriesDefinitionsOf, + timeseriesYAxis, +} from "../timeseries" +import { asFiniteNumber, type TimeseriesAxisContext, type TimeseriesRow } from "../timeseries-rows" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip, UNBOUNDED_FOCUS_DISTANCE } from "./plot-constants" + +// Port of `QueryBuilderBarChart` (packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx), +// definition half: bars centred on their bucket on a time scale padded by half a bucket, closed and +// in-flight lanes, the hovered column kept bright. +// TODO(bar-thresholds): thresholds, log scale, soft bounds and the "Other" bucketing past +// `MAX_BAR_SERIES` are not ported. + +type BarSpec = Extract + +/** Matches the Recharts `maxBarSize={48}`. */ +const MAX_BAR_THICKNESS = 48 +const BAR_RADIUS = 2 +const PARTIAL_FILL_OPACITY = 0.45 +const DIMMED_FILL_OPACITY = 0.3 +const DIMMED_PARTIAL_FILL_OPACITY = 0.15 + +/** One bar: a bucket, a series, and the value where the two meet. */ +interface BarCell { + readonly row: TimeseriesRow + readonly key: string + readonly color: string + readonly value: number + /** The running total beneath it in a stack; 0 otherwise. */ + readonly base: number + readonly partial: boolean +} + +// copied: `bucketBandDomain` and `timeseriesBandXAxis` (plot/timeseries.tsx, a React module). +const bucketBandDomain = ( + rows: ReadonlyArray, + bucketSeconds: number | undefined, +): [Date, Date] | null => { + const times = rows.map((row) => row.date.getTime()).filter((time) => Number.isFinite(time)) + if (rows.length < 2 || times.length === 0) return null + const min = Math.min(...times) + const max = Math.max(...times) + const gaps = times.slice(1).flatMap((time, index) => (time > times[index] ? [time - times[index]] : [])) + const smallestGap = gaps.length === 0 ? Number.POSITIVE_INFINITY : Math.min(...gaps) + if (min === max) return null + const interval = + bucketSeconds != null && bucketSeconds > 0 + ? bucketSeconds * 1000 + : Number.isFinite(smallestGap) + ? smallestGap + : 0 + if (interval <= 0) return null + return [new Date(min - interval / 2), new Date(max + interval / 2)] +} + +const timeseriesBandXAxis = (axisContext: TimeseriesAxisContext, rows: ReadonlyArray) => { + const domain = bucketBandDomain(rows, axisContext.bucketSeconds) + if (domain === null) return timeseriesXAxis(axisContext) + return { ...timeseriesXAxis(axisContext), scale: bucketTimeScale(domain, axisContext.timeZone) } +} + +interface Series { + readonly key: string + readonly label: string + readonly color: string +} + +/** The cells of every visible series per bucket, in `visible` order (the stack is bottom-up). */ +const cellsOf = ( + rows: ReadonlyArray, + visible: ReadonlyArray, + first: number, + stacked: boolean, +): ReadonlyArray => + rows.flatMap((row, index) => { + const partial = first !== -1 && index >= first + return visible.map((entry, entryIndex) => ({ + row, + key: entry.key, + color: entry.color, + value: asFiniteNumber(row[entry.key]), + base: stacked + ? visible.slice(0, entryIndex).reduce((sum, below) => sum + asFiniteNumber(row[below.key]), 0) + : 0, + partial, + })) + }) + +export const buildQueryBuilderBar = (spec: BarSpec): BuiltChart => { + const { definitions } = seriesDefinitionsOf(spec.data) + const unit = spec.unit ?? undefined + const keys = definitions.map((definition) => definition.chartKey) + const normalised = normaliseRows(spec.data, definitions) + const rows = scaleRates(normalised, keys, unit, bucketSecondsOf(normalised)) + const visible: ReadonlyArray = definitions.map((definition) => ({ + key: definition.chartKey, + label: definition.rawKey, + color: spec.colors[definition.chartKey] ?? spec.fallbackColor, + })) + const visibleKeys = visible.map((entry) => entry.key) + const axisContext = axisContextOf(rows, spec.timeZone ?? undefined) + const first = findFirstPartialIndex(rows, { now: spec.nowMs }) + // A trailing in-flight bucket with nothing in it is dropped: it is not a reading of zero. + const plotRows = first === -1 ? rows : trimEmptyTrailingBuckets(rows, visibleKeys, first) + const cells = cellsOf(plotRows, visible, first, spec.stacked) + const axis = spec.axis + // `fitYAxisToData` is deliberately not forwarded: a bar's value is its area from the baseline. + const yAxis = timeseriesYAxis({ + rows: plotRows, + visibleKeys, + unit, + stacked: spec.stacked, + ...(axis === undefined + ? undefined + : { + logScale: axis.logScale, + ...(axis.softMin === null ? undefined : { softMin: axis.softMin }), + ...(axis.softMax === null ? undefined : { softMax: axis.softMax }), + thresholds: axis.thresholds, + }), + }) + const floor = yAxis.domain[0] + const floored = (value: number) => Math.max(floor, value) + const lift = minBarLength(yAxis.domain) + const liftBar = (value: number) => (spec.stacked ? value : (lift(value) ?? value)) + + // Both lanes see every cell (the other lane's come back null), so both infer one bandwidth. + const lane = (partialLane: boolean) => + barY(cells, { + id: partialLane ? "bars-partial" : "bars", + x: (cell: BarCell) => cell.row.date, + y: (cell: BarCell) => (cell.partial === partialLane ? cell.value : null), + y1: (cell: BarCell) => floored(cell.base), + y2: (cell: BarCell) => + cell.partial === partialLane ? floored(cell.base + liftBar(cell.value)) : null, + z: (cell: BarCell) => cell.key, + fill: (cell: BarCell) => cell.color, + fillOpacity: partialLane ? PARTIAL_FILL_OPACITY : undefined, + maxThickness: MAX_BAR_THICKNESS, + radius: spec.stacked ? 0 : BAR_RADIUS, + layout: spec.stacked ? undefined : group(), + states: [ + { + when: (context: { matches: (match: "x") => boolean }) => !context.matches("x"), + style: { fillOpacity: partialLane ? DIMMED_PARTIAL_FILL_OPACITY : DIMMED_FILL_OPACITY }, + }, + ], + }) + + const definition = defineChart({ + marks: [ + ...thresholdMarks(axis?.thresholds ?? [], bucketBandDomain(plotRows, axisContext.bucketSeconds)?.[1] ?? plotRows.at(-1)?.date), + lane(false), + lane(true), + ], + scales: { x: timeseriesBandXAxis(axisContext, plotRows), y: yAxis.y }, + focus: "group-x", + maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE, + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip("pointer") : false, + }) + + return { + definition, + focusKeyOf: (point) => point.datum.row.bucket, + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]?.datum.row), (row) => + ChartTooltip.Bucket({ + heading: formatBucketLabel(row.bucket, axisContext, "tooltip"), + rows: visible.map((entry) => ({ + label: entry.label, + color: entry.color, + dashed: false, + value: formatValueByUnit(asFiniteNumber(row[entry.key]), unit), + })), + }), + ), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/bench-overview.ts b/apps/web-foldkit/src/charts/definitions/bench-overview.ts new file mode 100644 index 0000000000..087ce5a99b --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/bench-overview.ts @@ -0,0 +1,209 @@ +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { + formatBucketLabel, + formatErrorRate, + formatLatency, + formatThroughput, + inferBucketSeconds, + inferRangeMs, +} from "@maple/ui/lib/format" +import { areaY, type ChartValue, defineChart, lineY } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { scalePoint } from "@tanstack/charts-scales/point" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { verticalGradient } from "./fixed-metric-axes" +import { cursorTooltip } from "./plot-constants" + +// Ports of the `/lab/bench/tanstack` chart definitions (apps/web lab/bench/tanstack/ +// throughput-area.tsx, error-rate-area.tsx, latency-line.tsx). Code copied, constants unchanged. + +type BenchSpec = Extract +type Row = BenchSpec["rows"][number] + +interface Series { + readonly label: string + readonly key: string + readonly color: string + readonly dashed: boolean + readonly format: (value: number) => string +} + +const bucketOf = (row: Row): string => { + const value = row["bucket"] + return typeof value === "string" ? value : "" +} +const numberOf = (row: Row, key: string): number => { + const value = row[key] + return typeof value === "number" ? value : 0 +} + +/** `scales.x`: a point scale over the buckets, ticks every 72px. */ +const xScale = (axisContext: { rangeMs: number; bucketSeconds: number | undefined }) => ({ + scale: scalePoint, + axis: { + line: false, + ticks: { + size: 0, + padding: 8, + spacing: 72, + format: (value: string) => formatBucketLabel(value, axisContext, "tick"), + }, + }, +}) + +const yScale = (domain: readonly [number, number], format: (value: number) => string, nice: boolean) => ({ + scale: scaleLinear().domain(domain), + nice, + grid: true, + axis: { line: false, ticks: { size: 0, padding: 6, format } }, +}) + +/** The series a chart draws, in its legacy order, with the per-second rows throughput reads. */ +const seriesOf = (spec: BenchSpec, rateLabel: string): ReadonlyArray => { + const color = (key: string) => spec.colors[key] ?? "" + const perSecond = (value: number) => `${value.toLocaleString()}${rateLabel}` + switch (spec.chart) { + case "Throughput": + return [ + { + label: "Throughput", + key: "throughput", + color: color("throughput"), + dashed: false, + format: perSecond, + }, + { + label: "Errors", + key: "errorThroughput", + color: color("error"), + dashed: true, + format: perSecond, + }, + ] + case "ErrorRate": + return [ + { + label: "Error Rate", + key: "errorRate", + color: color("error"), + dashed: false, + format: formatErrorRate, + }, + ] + case "Latency": + return [ + { + label: "P99", + key: "p99LatencyMs", + color: color("p99"), + dashed: false, + format: formatLatency, + }, + { + label: "P95", + key: "p95LatencyMs", + color: color("p95"), + dashed: false, + format: formatLatency, + }, + { + label: "P50", + key: "p50LatencyMs", + color: color("p50"), + dashed: false, + format: formatLatency, + }, + ] + } +} + +/** Throughput's rows: per-second volume and the error volume it derives (`errorRate` is a fraction). */ +const rowsOf = (spec: BenchSpec, bucketSeconds: number | undefined): ReadonlyArray => + spec.chart !== "Throughput" + ? spec.rows + : spec.rows.map((row) => { + const throughput = numberOf(row, "throughput") / (bucketSeconds || 1) + return { ...row, throughput, errorThroughput: throughput * numberOf(row, "errorRate") } + }) + +/** The y domain and tick format: throughput and latency from 0 to the peak, nice; error rate clamped. */ +const yOf = (spec: BenchSpec, rows: ReadonlyArray, rateLabel: string) => { + const peak = (key: string) => rows.reduce((max, row) => Math.max(max, numberOf(row, key)), 0) + switch (spec.chart) { + case "Throughput": + return yScale([0, peak("throughput")], (value) => formatThroughput(value, rateLabel), true) + case "ErrorRate": + return yScale([0, Math.min(1, Math.max(peak("errorRate") * 1.2, 0.01))], formatErrorRate, false) + case "Latency": + return yScale([0, peak("p99LatencyMs")], formatLatency, true) + } +} + +export const buildBenchOverview = (spec: BenchSpec): BuiltChart => { + const bucketSeconds = inferBucketSeconds(spec.rows.map((row) => ({ bucket: bucketOf(row) }))) + const axisContext = { + rangeMs: inferRangeMs(spec.rows.map((row) => ({ bucket: bucketOf(row) }))), + bucketSeconds, + } + const rateLabel = "/s" + const rows = rowsOf(spec, bucketSeconds) + const series = seriesOf(spec, rateLabel) + const chrome = { border: spec.colors["border"] ?? "", background: spec.colors["background"] ?? "" } + const x = (row: Row) => bucketOf(row) + const y = (key: string) => (row: Row) => numberOf(row, key) + const isArea = spec.chart !== "Latency" + const lead = series[0] + const marks = [ + ...(isArea && lead !== undefined + ? [ + areaY(rows, { + id: `${lead.key}Area`, + x, + y: y(lead.key), + fill: `url(#${spec.gradientId})`, + stroke: "none", + }), + ] + : []), + ...series.map((entry) => + entry.dashed + ? lineY(rows, { + id: entry.key, + x, + y: y(entry.key), + stroke: entry.color, + strokeWidth: 1.5, + strokeDasharray: "3 3", + }) + : lineY(rows, { id: entry.key, x, y: y(entry.key), stroke: entry.color, strokeWidth: 2 }), + ), + ...series.map((entry) => focusDot(rows, x, y(entry.key), entry.color, chrome)), + focusCrosshair(chrome), + ] + return { + definition: defineChart({ + gradients: isArea && lead !== undefined ? [verticalGradient(spec.gradientId, lead.color)] : [], + marks, + scales: { x: xScale(axisContext), y: yOf(spec, rows, rateLabel) }, + focus: "group-x", + focusRing: false, + // TODO(tooltip-anchor): legacy anchors the card on the focus store; here it follows the pointer. + tooltip: cursorTooltip("pointer"), + }), + focusKeyOf: (point) => bucketOf(point.datum), + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => + ChartTooltip.Bucket({ + heading: formatBucketLabel(bucketOf(datum), axisContext, "tooltip"), + rows: series.map((entry) => ({ + label: entry.label, + color: entry.color, + dashed: entry.dashed, + value: entry.format(numberOf(datum, entry.key)), + })), + }), + ), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/built-chart.ts b/apps/web-foldkit/src/charts/definitions/built-chart.ts new file mode 100644 index 0000000000..4f854fe57f --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/built-chart.ts @@ -0,0 +1,12 @@ +import type { ChartPoint, ChartValue, DomChartDefinition } from "@tanstack/charts" +import type { Option } from "effect" +import type { ChartTooltip } from "../chart-spec" + +/** A definition plus the two readings the Mount turns into Messages: focus key and tooltip datum. */ +export interface BuiltChart { + readonly definition: DomChartDefinition + readonly focusKeyOf: (point: ChartPoint) => string + readonly tooltipOf: ( + points: ReadonlyArray>, + ) => Option.Option +} diff --git a/apps/web-foldkit/src/charts/definitions/event-markers.ts b/apps/web-foldkit/src/charts/definitions/event-markers.ts new file mode 100644 index 0000000000..9e9dfc6952 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/event-markers.ts @@ -0,0 +1,100 @@ +import { rect, ruleX, text } from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import { toEpochMs } from "@maple/ui/lib/time-format" +import { type ChartEventMarker, EventTone, type MarkerColors } from "../event-marker-spec" + +// Port of `chartEventMarkerMarks` and `placeMarkersInWindow` +// (apps/web/src/components/infra/primitives/chart-event-markers.tsx), colours resolved by the page. + +interface PlacedMarker { + readonly marker: ChartEventMarker + readonly x: Date + readonly x2?: Date +} + +/** Markers inside the window from the first bucket to the end of the last one, at their own instant. */ +export const placeMarkersInWindow = ( + markers: ReadonlyArray, + bucketIsos: ReadonlyArray, +): ReadonlyArray => { + if (bucketIsos.length === 0 || markers.length === 0) return [] + const times = bucketIsos + .map((iso) => toEpochMs(iso)) + .filter((ms) => Number.isFinite(ms)) + .toSorted((a, b) => a - b) + const first = times[0] + const last = times[times.length - 1] + if (first === undefined || last === undefined) return [] + const width = times.length > 1 ? (times[1] ?? first) - first : 0 + const end = last + width + const inWindow = (t: number) => Number.isFinite(t) && t >= first && t <= end + return markers.flatMap((marker): ReadonlyArray => { + const at = toEpochMs(marker.at) + if (!inWindow(at)) return [] + const x = new Date(at) + if (marker.endsAt === undefined) return [{ marker, x }] + const until = toEpochMs(marker.endsAt) + return [inWindow(until) && until > at ? { marker, x, x2: new Date(until) } : { marker, x }] + }) +} + +const MAX_LABELLED_MARKERS = 3 + +export const chartEventMarkerMarks = ( + placed: ReadonlyArray, + yDomain: readonly [number, number], + colors: MarkerColors, +) => { + if (placed.length === 0) return [] + const labelled = placed.length <= MAX_LABELLED_MARKERS + const lines = placed.filter((entry) => entry.x2 === undefined) + const bands = placed.filter((entry) => entry.x2 !== undefined) + const bandMarks = EventTone.literals.flatMap((tone) => { + const ofTone = bands.filter((entry) => entry.marker.tone === tone) + return ofTone.length === 0 + ? [] + : [ + decorative( + rect(ofTone, { + x1: (entry: PlacedMarker) => entry.x, + x2: (entry: PlacedMarker) => entry.x2 ?? entry.x, + y1: () => yDomain[0], + y2: () => yDomain[1], + fill: colors[tone], + fillOpacity: 0.08, + stroke: "none", + }), + ), + ] + }) + const lineMarks = + lines.length === 0 + ? [] + : [ + decorative( + ruleX(lines, { + x: (entry: PlacedMarker) => entry.x, + stroke: (entry: PlacedMarker) => colors[entry.marker.tone], + strokeOpacity: 1, + strokeWidth: 1, + strokeDasharray: "3 3", + }), + ), + ...(labelled + ? [ + decorative( + text(lines, { + x: (entry: PlacedMarker) => entry.x, + y: () => yDomain[1], + text: (entry: PlacedMarker) => entry.marker.label, + fill: colors.label, + anchor: "middle", + dy: -4, + fontSize: 9, + }), + ), + ] + : []), + ] + return [...bandMarks, ...lineMarks] +} diff --git a/apps/web-foldkit/src/charts/definitions/fixed-metric-axes.ts b/apps/web-foldkit/src/charts/definitions/fixed-metric-axes.ts new file mode 100644 index 0000000000..7c8faa886e --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/fixed-metric-axes.ts @@ -0,0 +1,26 @@ +import type { ChartLinearGradient } from "@tanstack/charts" + +// copied: the paint helpers of packages/ui/src/components/plot/plot-paint.ts (its module imports +// React). The axes are in `../timeseries`. + +/** `verticalGradient` (plot-paint.ts). */ +export const verticalGradient = ( + id: string, + color: string, + startOpacity = 0.8, + endOpacity = 0.1, +): ChartLinearGradient => ({ + id, + x1: 0, + y1: 0, + x2: 0, + y2: 1, + stops: [ + { offset: 0.05, color, opacity: startOpacity }, + { offset: 0.95, color, opacity: endOpacity }, + ], +}) + +/** `roundCapDasharray` (plot-paint.ts): the round cap moves from the dash into the gap. */ +export const roundCapDasharray = (on: number, off: number, strokeWidth: number): string => + `${Math.max(0.01, on - strokeWidth)} ${off + strokeWidth}` diff --git a/apps/web-foldkit/src/charts/definitions/fixed-metric.ts b/apps/web-foldkit/src/charts/definitions/fixed-metric.ts new file mode 100644 index 0000000000..ab13ca049b --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/fixed-metric.ts @@ -0,0 +1,402 @@ +import { areaY, defineChart, lineY, type ChartPoint, type ChartValue } from "@tanstack/charts" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { splitAtFirstPartial } from "@maple/ui/components/plot/partial-buckets" +import { formatBucketLabel, formatErrorRate, formatLatency, formatThroughput } from "@maple/ui/lib/format" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import { + ERROR_KEY, + type FixedMetricModel, + fixedMetricModel, + LATENCY_SERIES, + THROUGHPUT_KEY, + throughputPlot, + TRACED_KEY, +} from "../fixed-metric-series" +import { timeseriesXAxis } from "@maple/ui/components/plot/timeseries-axis" +import { timeseriesYAxis } from "../timeseries" +import { asFiniteNumber, type TimeseriesRow } from "../timeseries-rows" +import type { BuiltChart } from "./built-chart" +import { roundCapDasharray, verticalGradient } from "./fixed-metric-axes" +import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" + +// Ports of `ThroughputAreaChart`, `ErrorRateAreaChart` and `LatencyLineChart` +// (packages/ui/src/components/charts/{area,line}), definition half. Code copied, constants unchanged. + +type FixedMetricSpec = Extract +type Point = ChartPoint + +/** `PlotTooltipSeries`: one tooltip row reader. */ +interface TooltipSeries { + readonly label: string + readonly color: string + readonly dashed?: boolean + readonly value: (row: TimeseriesRow) => number | null + readonly format: (value: number) => string +} + +const STROKE_WIDTH = 2 +const FILL_OPACITY = 0.6 +const at = (row: TimeseriesRow) => row.date +const valueOf = (key: string) => (row: TimeseriesRow) => asFiniteNumber(row[key]) +const read = (key: string) => (row: TimeseriesRow) => { + const value = row[key] + return typeof value === "number" ? value : null +} + +const colorOf = (spec: FixedMetricSpec, key: string): string => spec.colors[key] ?? "" +/** `margin: yAxisWidth == null ? undefined : { left: yAxisWidth }`. */ +const marginOf = (spec: FixedMetricSpec) => + spec.marginLeft === undefined ? undefined : { left: spec.marginLeft } +const chromeOf = (spec: FixedMetricSpec) => ({ + border: colorOf(spec, "border"), + background: colorOf(spec, "background"), +}) + +/** `PlotTooltipBody` reduced to data: rows off `points[0].datum`, nulls skipped. */ +const bucketTooltip = + ( + model: FixedMetricModel, + series: ReadonlyArray, + focus: ReturnType, + ) => + (points: ReadonlyArray): Option.Option => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => { + // `resolveTooltipHighlight`: the series nearest the cursor is emphasised. + const highlighted = focus.highlight(series.map((entry) => [entry.label, entry.value(datum)] as const)) + const base = { + heading: formatBucketLabel(datum.bucket, model.axisContext, "tooltip"), + rows: series.flatMap((entry) => { + const value = entry.value(datum) + return value == null + ? [] + : [ + { + label: entry.label, + color: entry.color, + dashed: entry.dashed ?? false, + value: entry.format(value), + }, + ] + }), + } + return highlighted === undefined ? ChartTooltip.Bucket(base) : ChartTooltip.Bucket({ ...base, highlighted }) + }) + +const throughputChart = (spec: FixedMetricSpec): BuiltChart => { + const model = fixedMetricModel(spec.rows, spec.timeZone, spec.nowMs) + const focus = tooltipFocusCapture() + const plot = throughputPlot(model, spec.perSecond) + const { rows, rateLabel, hasSampling, hasErrors, hasTraced } = plot + const throughputColor = colorOf(spec, "throughput") + const errorColor = colorOf(spec, "error") + const chrome = chromeOf(spec) + const { solid, dashed } = splitAtFirstPartial(rows, [THROUGHPUT_KEY], model.trimOptions) + const hasDashed = dashed.length > 0 + const gradientId = `${spec.gradientPrefix}-fill` + const fadedGradientId = `${spec.gradientPrefix}-fill-partial` + const partialDash = roundCapDasharray(4, 4, STROKE_WIDTH) + const errorDash = roundCapDasharray(3, 3, 1.5) + const tracedDash = roundCapDasharray(4, 4, 1) + + const band = (slice: ReadonlyArray, fill: string, partial: boolean) => + areaY(slice, { + id: partial ? "throughput-area-partial" : "throughput-area", + x: at, + y: valueOf(THROUGHPUT_KEY), + y1: () => 0, + fill: `url(#${fill})`, + fillOpacity: FILL_OPACITY, + stroke: "none", + }) + const edge = (slice: ReadonlyArray, partial: boolean) => + lineY(slice, { + id: partial ? "throughput-partial" : "throughput", + x: at, + y: valueOf(THROUGHPUT_KEY), + stroke: throughputColor, + strokeWidth: STROKE_WIDTH, + strokeDasharray: partial ? partialDash : undefined, + }) + const errorLine = (slice: ReadonlyArray, partial: boolean) => + lineY(slice, { + id: partial ? "error-throughput-partial" : "error-throughput", + x: at, + y: valueOf(ERROR_KEY), + stroke: errorColor, + strokeWidth: 1.5, + strokeDasharray: errorDash, + strokeOpacity: partial ? 0.5 : undefined, + }) + const tracedLine = lineY(rows, { + id: "traced-throughput", + x: at, + y: valueOf(TRACED_KEY), + stroke: throughputColor, + strokeWidth: 1, + strokeDasharray: tracedDash, + strokeOpacity: 0.5, + }) + + const definition = defineChart({ + margin: marginOf(spec), + gradients: [ + verticalGradient(gradientId, throughputColor), + ...(hasDashed ? [verticalGradient(fadedGradientId, throughputColor, 0.15, 0)] : []), + ], + marks: [ + band(solid, gradientId, false), + ...(hasDashed ? [band(dashed, fadedGradientId, true)] : []), + edge(solid, false), + ...(hasDashed ? [edge(dashed, true)] : []), + ...(hasErrors ? [errorLine(solid, false)] : []), + ...(hasErrors && hasDashed ? [errorLine(dashed, true)] : []), + ...(hasTraced ? [tracedLine] : []), + focusDot(rows, at, valueOf(THROUGHPUT_KEY), throughputColor, chrome), + ...(hasErrors ? [focusDot(rows, at, valueOf(ERROR_KEY), errorColor, chrome)] : []), + focusCrosshair(chrome), + ], + scales: { + x: timeseriesXAxis(model.axisContext), + y: timeseriesYAxis({ + rows, + visibleKeys: [THROUGHPUT_KEY, ERROR_KEY, TRACED_KEY], + format: (tick: number) => formatThroughput(tick, rateLabel), + }).y, + }, + focus: "group-x", + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip(focus.anchor) : false, + }) + + const withSuffix = (value: number) => formatThroughput(value, rateLabel) + const tooltipSeries: ReadonlyArray = [ + { + label: hasSampling ? "Estimated" : "Throughput", + color: throughputColor, + value: read(THROUGHPUT_KEY), + format: (value) => `${hasSampling ? "~" : ""}${withSuffix(value)}`, + }, + ...(hasErrors + ? [ + { + label: "Errors", + color: errorColor, + dashed: true, + value: (row: TimeseriesRow) => { + const value = row[ERROR_KEY] + return typeof value === "number" && value > 0 ? value : null + }, + format: withSuffix, + }, + ] + : []), + ...(hasTraced + ? [ + { + label: "Traced", + color: throughputColor, + dashed: true, + value: read(TRACED_KEY), + format: withSuffix, + }, + ] + : []), + ] + return { + definition, + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: bucketTooltip(model, tooltipSeries, focus), + } +} + +/** `errorRateCeiling`: the worst bucket plus 20% headroom, never below 1% nor above 100%. */ +const errorRateCeiling = (rows: ReadonlyArray>): number => { + const dataMax = rows.reduce((max, row) => { + const value = row.errorRate + return typeof value === "number" && Number.isFinite(value) && value > max ? value : max + }, 0) + return Math.min(1, Math.max(dataMax * 1.2, 0.01)) +} + +const errorRateChart = (spec: FixedMetricSpec): BuiltChart => { + const model = fixedMetricModel(spec.rows, spec.timeZone, spec.nowMs) + const focus = tooltipFocusCapture() + const { plotRows } = model + const color = colorOf(spec, "errorRate") + const chrome = chromeOf(spec) + const { solid, dashed } = splitAtFirstPartial(plotRows, ["errorRate"], model.trimOptions) + const partialDash = roundCapDasharray(4, 4, STROKE_WIDTH) + const gradientId = `${spec.gradientPrefix}-fill` + const fadedGradientId = `${spec.gradientPrefix}-fill-partial` + const value = valueOf("errorRate") + const band = (slice: ReadonlyArray, fill: string) => + areaY(slice, { + id: fill === gradientId ? "error-rate-area" : "error-rate-area-partial", + x: at, + y: value, + y1: () => 0, + fill: `url(#${fill})`, + fillOpacity: FILL_OPACITY, + stroke: "none", + }) + const edge = (slice: ReadonlyArray, partial: boolean) => + lineY(slice, { + id: partial ? "error-rate-partial" : "error-rate", + x: at, + y: value, + stroke: color, + strokeWidth: STROKE_WIDTH, + strokeDasharray: partial ? partialDash : undefined, + }) + const hasDashed = dashed.length > 0 + const definition = defineChart({ + margin: marginOf(spec), + gradients: [ + verticalGradient(gradientId, color), + ...(hasDashed ? [verticalGradient(fadedGradientId, color, 0.15, 0)] : []), + ], + marks: [ + band(solid, gradientId), + ...(hasDashed ? [band(dashed, fadedGradientId)] : []), + edge(solid, false), + ...(hasDashed ? [edge(dashed, true)] : []), + focusDot(plotRows, at, value, color, chrome), + focusCrosshair(chrome), + ], + scales: { + x: timeseriesXAxis(model.axisContext), + y: timeseriesYAxis({ + rows: plotRows, + visibleKeys: ["errorRate"], + softMin: 0, + softMax: errorRateCeiling(plotRows), + format: formatErrorRate, + }).y, + }, + focus: "group-x", + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip(focus.anchor) : false, + }) + const series = [{ label: "Error rate", color, value: read("errorRate"), format: formatErrorRate }] + return { definition, focusKeyOf: (point) => point.datum.bucket, tooltipOf: bucketTooltip(model, series, focus) } +} + +const latencyChart = (spec: FixedMetricSpec): BuiltChart => { + const model = fixedMetricModel(spec.rows, spec.timeZone, spec.nowMs) + const focus = tooltipFocusCapture() + const { plotRows } = model + const chrome = chromeOf(spec) + const series = LATENCY_SERIES.map((entry) => ({ ...entry, color: colorOf(spec, entry.key) })) + const keys = series.map((entry) => entry.key) + const { solid, dashed } = splitAtFirstPartial(plotRows, keys, model.trimOptions) + const partialDash = roundCapDasharray(4, 4, STROKE_WIDTH) + const line = (slice: ReadonlyArray, entry: (typeof series)[number], dash: boolean) => + lineY(slice, { + id: dash ? `${entry.key}-partial` : entry.key, + x: at, + y: valueOf(entry.key), + stroke: entry.color, + strokeWidth: STROKE_WIDTH, + strokeDasharray: dash ? partialDash : undefined, + }) + const definition = defineChart({ + margin: marginOf(spec), + marks: [ + ...series.map((entry) => line(solid, entry, false)), + ...(dashed.length > 0 ? series.map((entry) => line(dashed, entry, true)) : []), + ...series.map((entry) => focusDot(plotRows, at, valueOf(entry.key), entry.color, chrome)), + focusCrosshair(chrome), + ], + scales: { + x: timeseriesXAxis(model.axisContext), + y: timeseriesYAxis({ rows: plotRows, visibleKeys: keys, format: formatLatency }).y, + }, + focus: "group-x", + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip(focus.anchor) : false, + }) + const tooltipSeries = series.map((entry) => ({ + label: entry.label, + color: entry.color, + value: read(entry.key), + format: formatLatency, + })) + return { + definition, + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: bucketTooltip(model, tooltipSeries, focus), + } +} + +/** `ApdexAreaChart`: the band and its top edge on a `[0, 1]` axis, the in-flight tail dashed. */ +const apdexChart = (spec: FixedMetricSpec): BuiltChart => { + const model = fixedMetricModel(spec.rows, spec.timeZone, spec.nowMs) + const focus = tooltipFocusCapture() + const { plotRows } = model + const color = colorOf(spec, "apdexScore") + const chrome = chromeOf(spec) + const { solid, dashed } = splitAtFirstPartial(plotRows, ["apdexScore"], model.trimOptions) + const partialDash = roundCapDasharray(4, 4, STROKE_WIDTH) + const gradientId = `${spec.gradientPrefix}-fill` + const fadedGradientId = `${spec.gradientPrefix}-fill-partial` + const value = valueOf("apdexScore") + const band = (slice: ReadonlyArray, fill: string) => + areaY(slice, { + id: fill === gradientId ? "apdex-area" : "apdex-area-partial", + x: at, + y: value, + y1: () => 0, + fill: `url(#${fill})`, + fillOpacity: FILL_OPACITY, + stroke: "none", + }) + const edge = (slice: ReadonlyArray, partial: boolean) => + lineY(slice, { + id: partial ? "apdex-partial" : "apdex", + x: at, + y: value, + stroke: color, + strokeWidth: STROKE_WIDTH, + strokeDasharray: partial ? partialDash : undefined, + }) + const hasDashed = dashed.length > 0 + const definition = defineChart({ + margin: marginOf(spec), + gradients: [ + verticalGradient(gradientId, color), + ...(hasDashed ? [verticalGradient(fadedGradientId, color, 0.15, 0)] : []), + ], + marks: [ + band(solid, gradientId), + ...(hasDashed ? [band(dashed, fadedGradientId)] : []), + edge(solid, false), + ...(hasDashed ? [edge(dashed, true)] : []), + focusDot(plotRows, at, value, color, chrome), + focusCrosshair(chrome), + ], + scales: { + x: timeseriesXAxis(model.axisContext), + y: timeseriesYAxis({ rows: plotRows, visibleKeys: ["apdexScore"], softMin: 0, softMax: 1 }).y, + }, + focus: "group-x", + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip(focus.anchor) : false, + }) + const format = (score: number) => score.toFixed(2) + const series = [{ label: "Apdex", color, value: read("apdexScore"), format }] + return { definition, focusKeyOf: (point) => point.datum.bucket, tooltipOf: bucketTooltip(model, series, focus) } +} + +export const buildFixedMetric = (spec: FixedMetricSpec): BuiltChart => { + switch (spec.chart) { + case "Apdex": + return apdexChart(spec) + case "Throughput": + return throughputChart(spec) + case "ErrorRate": + return errorRateChart(spec) + case "Latency": + return latencyChart(spec) + } +} diff --git a/apps/web-foldkit/src/charts/definitions/heatmap.ts b/apps/web-foldkit/src/charts/definitions/heatmap.ts new file mode 100644 index 0000000000..b3bc4bd936 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/heatmap.ts @@ -0,0 +1,78 @@ +import { createSequentialColorScale } from "@maple/ui/components/plot/color-scale" +import { cell, defineChart, type ChartPoint } from "@tanstack/charts" +import { scaleBand } from "@tanstack/charts-scales/band" +import { tooltip } from "@tanstack/charts/tooltip" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" + +// The `definition` memo of `QueryBuilderHeatmapChart` (packages/ui/src/components/charts/heatmap/ +// query-builder-heatmap-chart.tsx): grout holes under the coloured cells, one hover cascade for both. + +type HeatmapSpec = Extract +type Slot = HeatmapSpec["cells"][number] + +const UNMATCHED_OPACITY = 0.28 + +const slotTooltip = (points: ReadonlyArray>): Option.Option => + Option.map(Option.fromNullishOr(points[0]?.datum), (slot) => + ChartTooltip.HeatmapSlot({ x: slot.x, y: slot.y, value: slot.value }), + ) + +export const buildHeatmap = (spec: HeatmapSpec): BuiltChart => { + const colors = createSequentialColorScale({ + stops: spec.stops, + domain: spec.domain, + scaleType: spec.scaleType, + }) + const focusStates = [ + { when: { focus: "unmatched" }, style: { opacity: UNMATCHED_OPACITY } }, + { when: { focus: "x" }, style: { opacity: 1 } }, + { when: { focus: "y" }, style: { opacity: 1 } }, + { when: { focus: "primary" }, style: { stroke: spec.foreground, strokeWidth: 1.5 } }, + ] as const + const holes = cell(spec.holes, { + x: (slot: Slot) => slot.x, + y: (slot: Slot) => slot.y, + fill: spec.grout, + radius: spec.radius, + states: focusStates, + }) + const cells = cell(spec.cells, { + x: (slot: Slot) => slot.x, + y: (slot: Slot) => slot.y, + color: (slot: Slot) => slot.value, + radius: spec.radius, + states: focusStates, + }) + return { + definition: defineChart({ + marks: [holes, cells], + margin: spec.margin ?? undefined, + scales: { + x: { + scale: scaleBand([...spec.xDomain], [0, 1]).paddingInner(spec.paddingInnerX), + grid: false, + axis: { line: false, ticks: { size: 0, padding: 6 } }, + }, + y: { + scale: scaleBand([...spec.yDomain], [0, 1]).paddingInner(spec.paddingInnerY), + grid: false, + axis: { + line: false, + ticks: { size: 0, padding: 6, format: (value: string) => spec.yLabels[value] ?? value }, + tickLabels: { thin: { priority: "ends" } }, + }, + }, + }, + color: { scale: colors }, + focus: "nearest", + focusRing: false, + tooltip: spec.showTooltip + ? { use: tooltip, className: "maple-plot-tooltip", anchor: "point", placement: "top", offset: 6 } + : false, + }), + focusKeyOf: (point) => `${point.datum.x}\u0000${point.datum.y}`, + tooltipOf: slotTooltip, + } +} diff --git a/apps/web-foldkit/src/charts/definitions/histogram.ts b/apps/web-foldkit/src/charts/definitions/histogram.ts new file mode 100644 index 0000000000..d49c3f41d5 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/histogram.ts @@ -0,0 +1,212 @@ +import { formatNumber } from "@maple/ui/lib/format" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { + integerTickValues, + logYScale, + minBarLength, + niceLinearDomain, +} from "@maple/ui/components/plot/plot-scales" +import { barY, defineChart, rect, type ChartPoint } from "@tanstack/charts" +import { binX } from "@tanstack/charts/transform/bin" +import { scaleBand } from "@tanstack/charts-scales/band" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { scaleLog } from "d3-scale" +import { Option } from "effect" +import { ChartTooltip, type CategoricalBin, type ChartSpec, type NumericBin } from "../chart-spec" +import { binLowerBoundLabel, formatBound } from "../histogram-format" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip, UNBOUNDED_FOCUS_DISTANCE } from "./plot-constants" + +// Port of packages/ui/src/components/charts/histogram/query-builder-histogram-chart.tsx (definition +// half) and apps/web/src/lab/charts/histogram-spike.tsx. Constants and comments as there. + +type NumericSpec = Extract +type CategoricalSpec = Extract +type BinnedSpec = Extract + +const DIMMED_BIN_OPACITY = 0.5 +const SPIKE_BIN_COUNT = 30 + +/** `useCountAxis`: the count axis shared by both production histogram shapes. */ +const countAxis = (maxCount: number, useLogY: boolean) => { + if (useLogY) { + return { + baseline: 1, + liftCount: (value: number) => value, + y: { + grid: DASHED_Y_GRID, + scale: logYScale(maxCount), + axis: { line: false, ticks: { size: 0, padding: 6, format: formatNumber } }, + }, + } + } + const domain = niceLinearDomain([0, Math.max(maxCount, 1)]) + const ticks = integerTickValues(domain) + const lift = minBarLength(domain) + return { + baseline: 0, + liftCount: (value: number) => lift(value) ?? value, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(domain), + axis: { line: false, ticks: { size: 0, padding: 6, values: ticks, format: formatNumber } }, + }, + } +} + +const binTooltip = ( + points: ReadonlyArray>, +): Option.Option => + Option.map(Option.fromNullishOr(points[0]?.datum), (bin) => + ChartTooltip.NumericBin({ x1: bin.x1, x2: bin.x2, value: bin.value }), + ) + +/** `NumericHistogram`'s `definition` memo. */ +export const buildNumericHistogram = (spec: NumericSpec): BuiltChart => { + const { bins, color, unit } = spec + const maxCount = bins.reduce((max, bin) => Math.max(max, bin.value), 0) + const { baseline, liftCount, y } = countAxis(maxCount, spec.useLogY) + return { + definition: defineChart({ + marks: [ + rect(bins, { + x1: (bin: NumericBin) => bin.x1, + x2: (bin: NumericBin) => bin.x2, + y1: () => baseline, + y2: (bin: NumericBin) => Math.max(liftCount(bin.value), baseline), + fill: color, + fillOpacity: 1, + inset: 0.5, + radius: 2, + states: [{ when: { focus: "unmatched" }, style: { fillOpacity: DIMMED_BIN_OPACITY } }], + }), + ], + scales: { + x: { + scale: scaleLinear, + grid: false, + axis: { + line: false, + ticks: { + size: 0, + padding: 8, + spacing: 56, + format: (value: number) => formatBound(value, unit), + }, + }, + }, + y, + }, + focus: "group-x", + maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE, + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip("pointer") : false, + }), + focusKeyOf: (point) => `${point.datum.x1}`, + tooltipOf: binTooltip, + } +} + +/** `CategoricalHistogram`'s `definition` memo. */ +export const buildCategoricalHistogram = ( + spec: CategoricalSpec, +): BuiltChart => { + const { bins, color } = spec + const maxCount = bins.reduce((max, bin) => Math.max(max, bin.value), 0) + const { baseline, liftCount, y } = countAxis(maxCount, spec.useLogY) + return { + definition: defineChart({ + marks: [ + barY(bins, { + x: (bin: CategoricalBin) => bin.name, + y1: baseline, + y2: (bin: CategoricalBin) => Math.max(liftCount(bin.value), baseline), + fill: color, + fillOpacity: 1, + radius: 2, + states: [{ when: { focus: "unmatched" }, style: { fillOpacity: DIMMED_BIN_OPACITY } }], + }), + ], + scales: { + x: { + scale: scaleBand( + bins.map((bin) => bin.name), + [0, 1], + ).paddingInner(0.05), + grid: false, + axis: { + line: false, + ticks: { size: 0, padding: 8, format: binLowerBoundLabel }, + tickLabels: { thin: { minGap: 32, priority: "ends" } }, + }, + }, + y, + }, + focus: "group-x", + maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE, + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip("pointer") : false, + }), + focusKeyOf: (point) => point.datum.name, + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]?.datum), (bin) => + ChartTooltip.CategoricalBin({ name: bin.name, value: bin.value }), + ), + } +} + +/** `HistogramSpike`'s `bins` and `definition` memos: binX over raw observations. */ +export const buildBinnedHistogram = (spec: BinnedSpec) => { + const { logScaleY, color } = spec + const bins = binX( + spec.values.map((value) => ({ value })), + { value: (row: { value: number }) => row.value, thresholds: SPIKE_BIN_COUNT }, + ) + const baseline = logScaleY ? 1 : 0 + const maxCount = bins.reduce((max, bin) => Math.max(max, bin.value), 0) + + const definition = defineChart({ + marks: [ + rect(bins, { + x1: (bin) => bin.x1, + x2: (bin) => bin.x2, + y1: () => baseline, + y2: (bin) => Math.max(bin.value, baseline), + fill: color, + fillOpacity: 0.85, + inset: 0.5, + radius: 2, + states: [{ when: { focus: "primary" }, style: { fillOpacity: 1 } }], + }), + ], + scales: { + x: { + scale: scaleLinear, + grid: false, + axis: { line: false, ticks: { size: 0, padding: 8, spacing: 56, format: formatNumber } }, + }, + y: logScaleY + ? { + scale: scaleLog().domain([1, Math.max(maxCount, 10)]), + grid: true, + axis: { line: false, ticks: { size: 0, padding: 6, format: formatNumber } }, + } + : { + scale: scaleLinear().domain([0, Math.max(maxCount, 1)]), + nice: true, + grid: true, + axis: { line: false, ticks: { size: 0, padding: 6, format: formatNumber } }, + }, + }, + focus: "nearest", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + + const built: BuiltChart<(typeof bins)[number], number, number> = { + definition, + focusKeyOf: (point) => `${point.datum.x1}`, + tooltipOf: binTooltip, + } + return built +} diff --git a/apps/web-foldkit/src/charts/definitions/infra-metric.ts b/apps/web-foldkit/src/charts/definitions/infra-metric.ts new file mode 100644 index 0000000000..6ba9054d72 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/infra-metric.ts @@ -0,0 +1,240 @@ +import { + type ChartUnit, + formatValueWithUnit, + type TransformedPoint, + transformRows, + UNNAMED_SERIES_KEY, +} from "@maple/web/components/infra/chart-units" +import { + areaY, + d3Curve, + defineChart, + dot, + lineY, + ruleY, + stack, + text, + type ChartPoint, + type ChartValue, +} from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { linearYDomain, niceLinearDomain } from "@maple/ui/components/plot/plot-scales" +import { formatBytes } from "@maple/ui/lib/format" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { verticalGradient } from "./fixed-metric-axes" +import { chartEventMarkerMarks, placeMarkersInWindow } from "./event-markers" +import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" + +// Port of `InfraMetricChart`'s definition (apps/web/src/components/infra/primitives/infra-metric-chart.tsx), +// code copied, constants unchanged. Colours arrive resolved in the spec. + +type InfraMetricSpec = Extract +type InfraPoint = TransformedPoint + +interface InfraCell { + readonly point: InfraPoint + readonly name: string + readonly value: number | null +} + +type InfraDatum = InfraPoint | InfraCell + +const isCell = (datum: InfraDatum): datum is InfraCell => typeof datum.point === "object" +const rowOf = (datum: InfraDatum): InfraPoint => (isCell(datum) ? datum.point : datum) + +const STROKE_WIDTH = 1.5 +const THRESHOLD_FRACTION = 0.8 +const UNITS: ReadonlyArray = [ + "percent", + "cores", + "seconds", + "load", + "bytes_per_second", + "bytes", + "milliseconds", + "rate", + "count", +] +export const asChartUnit = (unit: string): ChartUnit => UNITS.find((known) => known === unit) ?? "count" + +/** The pivoted rows, with silent buckets of a shared domain added when the chart shows gaps. */ +export const infraPoints = (spec: Pick) => { + const transformed = transformRows(spec.rows, () => "") + if (!spec.gaps || spec.xDomain === null) return transformed + const reported = new Set(transformed.data.map((point) => point.bucket)) + const silent = spec.xDomain + .filter((bucket) => !reported.has(bucket)) + .map((bucket): InfraPoint => ({ bucket, time: "", date: bucketDate(bucket) })) + return { + series: transformed.series, + data: [...transformed.data, ...silent].toSorted((a, b) => a.bucket.localeCompare(b.bucket)), + } +} + +export const labelFor = (seriesLabel: string | null) => (name: string) => + name === UNNAMED_SERIES_KEY ? (seriesLabel ?? name) : name + +const tickFormatter = + (unit: InfraMetricSpec["unit"]) => + (value: number): string => + unit === "cores" + ? value.toLocaleString( + undefined, + Math.abs(value) < 0.1 ? { maximumSignificantDigits: 2 } : { maximumFractionDigits: 2 }, + ) + : unit === "bytes_per_second" + ? formatBytes(value) + : formatValueWithUnit(value, unit) + +export const buildInfraMetric = (spec: InfraMetricSpec): BuiltChart => { + const { data, series } = infraPoints(spec) + const axis = makeBucketAxis(spec.xDomain ?? data.map((point) => point.bucket), spec.timeZone) + const thresholds = spec.showThreshold && spec.unit === "percent" ? [{ value: THRESHOLD_FRACTION }] : [] + const yDomain = niceLinearDomain( + linearYDomain({ rows: data, keys: series, stacked: spec.stacked, thresholds }), + ) + const chrome = spec.chrome + const colorOf = (name: string) => spec.colors[name] ?? chrome.border + const at = (point: InfraPoint) => point.date + const valueOf = (name: string) => (point: InfraPoint) => { + const value = point[name] + return typeof value === "number" ? value : null + } + const gradientFor = (name: string) => `${spec.gradientPrefix}-${name.replace(/\W+/g, "_")}` + const curve = d3Curve(curveMonotoneX) + const cells: ReadonlyArray = spec.stacked + ? data.flatMap((point) => series.map((name) => ({ point, name, value: valueOf(name)(point) }))) + : [] + const bands = spec.stacked + ? [ + areaY(cells, { + x: (cell: InfraCell) => cell.point.date, + y: (cell: InfraCell) => cell.value, + z: (cell: InfraCell) => cell.name, + fill: (cell: InfraCell) => `url(#${gradientFor(cell.name)})`, + stroke: (cell: InfraCell) => colorOf(cell.name), + strokeWidth: STROKE_WIDTH, + curve, + layout: stack({ order: [...series] }), + }), + ] + : series.map((name) => + lineY(data, { + id: name, + x: at, + y: valueOf(name), + stroke: colorOf(name), + strokeWidth: STROKE_WIDTH, + curve, + }), + ) + const silent = (value: (point: InfraPoint) => number | null) => (point: InfraPoint | undefined) => + point === undefined || value(point) === null + const lone = spec.gaps + ? series.map((name) => { + const value = valueOf(name) + return dot( + data.filter( + (point, index) => + value(point) !== null && + silent(value)(data[index - 1]) && + silent(value)(data[index + 1]), + ), + { x: at, y: value, r: STROKE_WIDTH + 1, fill: colorOf(name) }, + ) + }) + : [] + const thresholdColor = spec.thresholdColor + const labelX = axis.domainMs ? new Date(axis.domainMs[1]) : undefined + const thresholdMarks = + thresholds.length === 0 + ? [] + : [ + ruleY(thresholds, { + y: (threshold: { value: number }) => threshold.value, + stroke: thresholdColor, + strokeOpacity: 1, + strokeWidth: 1.5, + strokeDasharray: "4 4", + }), + ...(labelX === undefined + ? [] + : [ + decorative( + text(thresholds, { + x: () => labelX, + y: (threshold: { value: number }) => threshold.value, + text: () => "80%", + fill: thresholdColor, + anchor: "end", + dx: -4, + dy: -6, + fontSize: 10, + }), + ), + ]), + ] + const placed = placeMarkersInWindow(spec.markers, spec.xDomain ?? data.map((point) => point.bucket)) + const focus = tooltipFocusCapture() + const definition = defineChart({ + gradients: spec.stacked + ? series.map((name) => verticalGradient(gradientFor(name), colorOf(name), 0.45, 0.04)) + : [], + marks: [ + ...chartEventMarkerMarks(placed, yDomain, spec.markerColors), + ...thresholdMarks, + ...bands, + ...lone, + ...series.map((name) => focusDot(data, at, valueOf(name), colorOf(name), chrome)), + focusCrosshair(chrome), + ], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { line: false, ticks: { size: 0, padding: 8, format: tickFormatter(spec.unit) } }, + }, + }, + margin: { top: 12, right: 12, left: 56 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip(focus.anchor), + }) + const label = labelFor(spec.seriesLabel) + const tooltipOf = (points: ReadonlyArray>) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => { + const row = rowOf(datum) + // The raw value, stacked or not: legacy's cursor series carry no stacked position. + const highlighted = focus.highlight( + series.map((name): readonly [string, number | null] => [label(name), valueOf(name)(row)]), + ) + const base = { + heading: axis.heading(row.bucket), + rows: series.flatMap((name) => { + const value = row[name] + return typeof value === "number" + ? [ + { + label: label(name), + color: colorOf(name), + dashed: false, + value: formatValueWithUnit(value, spec.unit), + }, + ] + : [] + }), + } + return highlighted === undefined + ? ChartTooltip.Bucket(base) + : ChartTooltip.Bucket({ ...base, highlighted }) + }) + return { definition, focusKeyOf: (point) => rowOf(point.datum).bucket, tooltipOf } +} diff --git a/apps/web-foldkit/src/charts/definitions/logs-volume.test.ts b/apps/web-foldkit/src/charts/definitions/logs-volume.test.ts new file mode 100644 index 0000000000..dbb6956227 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/logs-volume.test.ts @@ -0,0 +1,36 @@ +import { Option } from "effect" +import { describe, expect, test } from "vitest" +import { buildLogsVolume } from "./logs-volume" + +const bucket = "2026-10-01 11:00:00" +const row = { bucket, INFO: 12_345 } + +describe("logs volume tooltip", () => { + test("prints counts compactly, as the y axis does", () => { + const built = buildLogsVolume({ + _tag: "LogsVolume", + buckets: [{ bucket, counts: { INFO: 12_345 } }], + seriesKeys: ["INFO"], + colors: { INFO: "#00f" }, + border: "#ccc", + selection: null, + isSelecting: false, + timeZone: "UTC", + }) + const point = { + key: "INFO", + markId: "bar", + group: null, + groupLabel: "", + datum: { row, bucket, date: new Date(0), severity: "INFO", value: 12_345 }, + datumIndex: 0, + xValue: new Date(0), + yValue: 12_345, + x: 0, + y: 0, + color: "#00f", + } + const tooltip = Option.getOrUndefined(built.tooltipOf([point])) + expect(tooltip?._tag === "Bucket" ? tooltip.rows.map((r) => r.value) : []).toEqual(["12.3K"]) + }) +}) diff --git a/apps/web-foldkit/src/charts/definitions/logs-volume.ts b/apps/web-foldkit/src/charts/definitions/logs-volume.ts new file mode 100644 index 0000000000..d26e34bdce --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/logs-volume.ts @@ -0,0 +1,114 @@ +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { linearYDomain, niceLinearDomain } from "@maple/ui/components/plot/plot-scales" +import { formatNumber } from "@maple/ui/lib/format" +import { barY, defineChart, rect, stack } from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip } from "./plot-constants" + +// Port of `LogsVolumePlot`'s `definition` memo (apps/web/src/components/logs/logs-volume-chart.tsx). + +type VolumeSpec = Extract +type Row = Readonly> & { readonly bucket: string } + +/** One severity's slice of one bucket: `stack()` groups these on `z`. */ +interface SeverityCell { + readonly row: Row + readonly bucket: string + readonly date: Date + readonly severity: string + readonly value: number | null +} + +const numberOrNull = (value: unknown): number | null => (typeof value === "number" ? value : null) + +export const buildLogsVolume = (spec: VolumeSpec): BuiltChart => { + const { seriesKeys, selection } = spec + const chartData: ReadonlyArray = spec.buckets.map((bucket) => ({ + bucket: bucket.bucket, + ...bucket.counts, + })) + const color = (key: string) => spec.colors[key] ?? spec.border + const axis = makeBucketAxis( + chartData.map((row) => row.bucket), + spec.timeZone, + ) + const yDomain = niceLinearDomain(linearYDomain({ rows: chartData, keys: [...seriesKeys], stacked: true })) + const cells: Array = chartData.flatMap((row) => + seriesKeys.map((severity) => ({ + row, + bucket: row.bucket, + date: bucketDate(row.bucket), + severity, + value: numberOrNull(row[severity]), + })), + ) + return { + definition: defineChart({ + marks: [ + barY(cells, { + x: (cell: SeverityCell) => cell.date, + y: (cell: SeverityCell) => cell.value, + z: (cell: SeverityCell) => cell.severity, + fill: (cell: SeverityCell) => color(cell.severity), + radius: 0, + layout: stack({ order: [...seriesKeys] }), + }), + ...(selection + ? [ + decorative( + rect([selection], { + x1: (s: { left: string; right: string }) => bucketDate(s.left), + x2: (s: { left: string; right: string }) => bucketDate(s.right), + y1: () => yDomain[0], + y2: () => yDomain[1], + fill: spec.border, + fillOpacity: 0.25, + stroke: "none", + }), + ), + ] + : []), + ], + scales: { + x: axis.xBand, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { + line: false, + ticks: { size: 0, padding: 4, format: (value: number) => formatNumber(value) }, + }, + }, + }, + margin: { top: 4, right: 0, left: 40 }, + focus: "group-x", + focusRing: false, + tooltip: spec.isSelecting ? false : cursorTooltip("pointer"), + }), + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]?.datum), (cell) => + ChartTooltip.Bucket({ + heading: axis.heading(cell.bucket), + rows: seriesKeys.flatMap((key) => { + const value = numberOrNull(cell.row[key]) + return value === null + ? [] + : [ + { + label: key.toUpperCase(), + color: color(key), + dashed: false, + value: formatNumber(value), + }, + ] + }), + }), + ), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/occurrences.ts b/apps/web-foldkit/src/charts/definitions/occurrences.ts new file mode 100644 index 0000000000..fb1a571713 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/occurrences.ts @@ -0,0 +1,72 @@ +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { + integerTickValues, + linearYDomain, + minBarLength, + niceLinearDomain, +} from "@maple/ui/components/plot/plot-scales" +import { formatNumber } from "@maple/ui/lib/format" +import { toEpochMs } from "@maple/ui/lib/time-format" +import { barY, defineChart } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { Option } from "effect" +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import type { ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip } from "./plot-constants" + +// Port of `IssueOccurrenceChart`'s `definition` memo (apps/web/src/components/errors/ +// issue-occurrence-chart.tsx). TODO(tooltip): the cursor tooltip body (`PlotTooltipBody`). + +type OccurrencesSpec = Extract +type Point = OccurrencesSpec["points"][number] + +/** The points that parse, oldest first: what the chart draws (and empty means the empty message). */ +export const sortedOccurrences = (points: ReadonlyArray): ReadonlyArray => + points + .filter((point) => Number.isFinite(toEpochMs(point.bucket))) + .toSorted((a, b) => toEpochMs(a.bucket) - toEpochMs(b.bucket)) + +export const buildOccurrences = (spec: OccurrencesSpec): BuiltChart => { + const sorted = sortedOccurrences(spec.points) + const axis = makeBucketAxis( + sorted.map((point) => point.bucket), + spec.timeZone, + ) + const yDomain = niceLinearDomain(linearYDomain({ rows: sorted, keys: ["count"] })) + const liftCount = minBarLength(yDomain) + return { + definition: defineChart({ + marks: [ + barY(sorted, { + x: (point: Point) => bucketDate(point.bucket), + y: (point: Point) => liftCount(point.count), + fill: spec.color, + radius: 2, + }), + ], + scales: { + x: axis.xBand, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { + line: false, + ticks: { + size: 0, + padding: 4, + values: integerTickValues(yDomain), + format: (value: number) => formatNumber(value), + }, + }, + }, + }, + margin: { top: 8, right: 4, bottom: 22, left: 44 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }), + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: () => Option.none(), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/pie.ts b/apps/web-foldkit/src/charts/definitions/pie.ts new file mode 100644 index 0000000000..dad505efae --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/pie.ts @@ -0,0 +1,225 @@ +import { formatNumber, formatPercent, formatValueByUnit } from "@maple/ui/lib/format" +import { muteColor } from "@maple/ui/components/plot/color-scale" +import { defineChart, type ChartFocusStrategy, type ChartPoint } from "@tanstack/charts" +import { + focusGroupAngle, + pie, + polar, + radialArc, + radialText, + type PieDatum, + type PolarLayoutContext, +} from "@tanstack/charts/polar" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { tooltip } from "@tanstack/charts/tooltip" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec, type PieSliceRow } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip, MUTED_COLOR_AMOUNT } from "./plot-constants" + +// Port of packages/ui/src/components/charts/pie/query-builder-pie-chart.tsx (definition half) +// and apps/web/src/lab/charts/pie-spike.tsx (`PieFigure`). Constants and comments as there. + +type PieSlice = PieDatum +type PieSpec = Extract +type PieSpikeSpec = Extract + +const LABEL_MIN_FRACTION = 0.06 +const LABEL_RADIUS_DONUT = 0.5 +const LABEL_RADIUS_PIE = 0.62 +const HOVER_GROWTH = 1.035 +const REST_OPACITY = 0.55 +const PLOT_INSET = 8 +const SLICE_STROKE_WIDTH = 1.5 + +const fmtValue = (value: number, unit: string | null): string => + unit ? formatValueByUnit(value, unit) : formatNumber(value) + +/** The donut hole, in pixels; shared by the arc mark, the label scale and the centre total. */ +export const resolveInnerRadius = ( + outerRadius: number, + donut: boolean, + innerRadius: number | null, +): number => { + if (!donut) return 0 + return Math.max(8, Math.min(outerRadius - 6, innerRadius ?? outerRadius * 0.58)) +} + +/** The polar layout the arcs were struck from, recorded for `wedgeFocus` (see the React file). */ +interface PolarLayoutBox { + layout: PolarLayoutContext | null +} + +const pointerAngle = (dx: number, dy: number): number => { + const angle = Math.atan2(dx, -dy) + return angle < 0 ? angle + Math.PI * 2 : angle +} + +/** Focus that engages only over a wedge: containment, not proximity. */ +const wedgeFocus = ( + box: PolarLayoutBox, + donut: boolean, + innerRadius: number | null, +): ChartFocusStrategy => { + const wedgeAt = (points: readonly ChartPoint[], x: number, y: number) => { + const layout = box.layout + if (layout === null) return undefined + const dx = x - layout.centerX + const dy = y - layout.centerY + const distance = Math.hypot(dx, dy) + if (distance < resolveInnerRadius(layout.radius, donut, innerRadius)) return undefined + if (distance > layout.radius * HOVER_GROWTH) return undefined + const angle = pointerAngle(dx, dy) + return points.find((point) => point.datum.startAngle <= angle && angle < point.datum.endAngle) + } + + return { + resolve: (points, context) => { + const point = wedgeAt(points, context.x, context.y) + return point ? [point] : [] + }, + group: (_points, context) => [context.point], + navigation: (points) => { + const seen = new Set() + const unique: ChartPoint[] = [] + for (const point of points) { + if (seen.has(point.datum.name)) continue + seen.add(point.datum.name) + unique.push(point) + } + return unique.sort((left, right) => left.datum.startAngle - right.datum.startAngle) + }, + } +} + +const sliceTooltip = ( + points: ReadonlyArray>, +): Option.Option => + Option.map(Option.fromNullishOr(points[0]?.datum), (slice) => { + const { name, value, fraction, color, collapsedCount } = slice + if (collapsedCount === undefined) return ChartTooltip.PieSlice({ name, value, fraction, color }) + return ChartTooltip.PieSlice({ name, value, fraction, color, collapsedCount }) + }) + +/** `QueryBuilderPieChart`'s `definition` memo. */ +export const buildPie = (spec: PieSpec): BuiltChart => { + const { activeName, highlighted, donut, innerRadius, showLabels, showPercent, unit } = spec + const slices = pie(spec.rows, { value: (row: PieSliceRow) => row.value }) + const active = activeName === null ? [] : slices.filter((slice) => slice.name === activeName) + const rest = activeName === null ? slices : slices.filter((slice) => slice.name !== activeName) + + const fillFor = (slice: PieSlice) => + highlighted === null || slice.name === highlighted + ? slice.color + : muteColor(slice.color, spec.background, MUTED_COLOR_AMOUNT) + + const layoutBox: PolarLayoutBox = { layout: null } + const innerRadiusFor = (context: PolarLayoutContext) => { + layoutBox.layout = context + return resolveInnerRadius(context.radius, donut, innerRadius) + } + + const arcOptions = { + startAngle: (slice: PieSlice) => slice.startAngle, + endAngle: (slice: PieSlice) => slice.endAngle, + innerRadius: innerRadiusFor, + fill: fillFor, + stroke: spec.background, + strokeWidth: SLICE_STROKE_WIDTH, + } + + const arcs = [ + radialArc(rest, { + ...arcOptions, + outerRadius: (context) => context.radius, + fillOpacity: activeName === null ? undefined : REST_OPACITY, + }), + radialArc(active, { ...arcOptions, outerRadius: (context) => context.radius * HOVER_GROWTH }), + ] as const + + const labels = radialText(slices, { + angle: (slice: PieSlice) => slice.angle, + radius: (slice: PieSlice) => + slice.fraction < LABEL_MIN_FRACTION ? null : donut ? LABEL_RADIUS_DONUT : LABEL_RADIUS_PIE, + text: (slice: PieSlice) => + showPercent ? formatPercent(slice.fraction) : fmtValue(slice.value, unit), + fill: "#ffffff", + fontSize: 11, + fontWeight: 600, + }) + + const frame = { radiusRatio: 1, inset: PLOT_INSET } as const + const wedges = showLabels + ? polar({ + ...frame, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { + scale: scaleLinear().domain([0, 1]), + range: [innerRadiusFor, (context) => context.radius], + }, + }, + marks: [...arcs, labels], + }) + : polar({ ...frame, scales: { angle: null, radius: null }, marks: [...arcs] }) + + return { + definition: defineChart({ + marks: [wedges], + scales: { x: null, y: null }, + focus: wedgeFocus(layoutBox, donut, innerRadius), + focusRing: false, + tooltip: spec.showTooltip ? cursorTooltip("pointer") : false, + }), + focusKeyOf: (point) => point.datum.name, + tooltipOf: sliceTooltip, + } +} + +/** `PieFigure`'s `definition` memo. Rows carry the colour `colorFor` resolved. */ +export const buildPieSpike = (spec: PieSpikeSpec): BuiltChart => { + const { donut, activeName } = spec + const slices = pie(spec.rows, { value: (row: PieSliceRow) => row.value, gapAngle: 0.012 }) + // NOTE: legacy leaves `activeName` undefined when no legend wires it, so `=== null` is false and + // every slice takes REST_OPACITY. Ported as is. + const active = activeName === null ? [] : slices.filter((slice) => slice.name === activeName) + const rest = activeName === null ? slices : slices.filter((slice) => slice.name !== activeName) + + const arcOptions = { + startAngle: (slice: PieSlice) => slice.startAngle, + endAngle: (slice: PieSlice) => slice.endAngle, + innerRadius: (context: { radius: number }) => (donut ? Math.max(8, context.radius * 0.58) : 0), + cornerRadius: 2, + } + + return { + definition: defineChart({ + marks: [ + polar({ + radiusRatio: 1, + inset: 8, + scales: { angle: null, radius: null }, + marks: [ + radialArc(rest, { + ...arcOptions, + outerRadius: (context) => context.radius, + fill: (slice) => slice.color, + fillOpacity: activeName === null ? 1 : REST_OPACITY, + }), + radialArc(active, { + ...arcOptions, + outerRadius: (context) => context.radius * HOVER_GROWTH, + fill: (slice) => slice.color, + }), + ], + }), + ], + scales: { x: null, y: null }, + focus: focusGroupAngle, + focusRing: false, + tooltip: { use: tooltip, className: "maple-plot-tooltip" }, + }), + focusKeyOf: (point) => point.datum.name, + tooltipOf: sliceTooltip, + } +} diff --git a/apps/web-foldkit/src/charts/definitions/planetscale-metric.ts b/apps/web-foldkit/src/charts/definitions/planetscale-metric.ts new file mode 100644 index 0000000000..c537ab9383 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/planetscale-metric.ts @@ -0,0 +1,89 @@ +import { d3Curve, defineChart, lineY, type ChartPoint, type ChartValue } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { linearYDomain, niceLinearDomain } from "@maple/ui/components/plot/plot-scales" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { chartEventMarkerMarks, placeMarkersInWindow } from "./event-markers" +import { cursorTooltip } from "./plot-constants" +import { formatMetricValue, METRIC_LABELS, type PlanetScaleMetric } from "../planetscale-metric-series" + +// Port of `PlanetScaleChart`'s definition (apps/web/src/components/infra/planetscale/planetscale-chart.tsx). + +type PlanetScaleSpec = Extract + +interface MetricPoint { + readonly bucket: string + readonly date: Date + readonly value: number | null +} + +const isPercentMetric = (metric: PlanetScaleMetric) => + metric === "cpuMaxPercent" || metric === "memMaxPercent" || metric === "storageUsedPercent" + +export const buildPlanetScaleMetric = ( + spec: PlanetScaleSpec, +): BuiltChart => { + const data: ReadonlyArray = spec.points.map((point) => ({ + bucket: point.bucket, + date: bucketDate(point.bucket), + value: point.value, + })) + const buckets = spec.points.map((point) => point.bucket) + const axis = makeBucketAxis(buckets, spec.timeZone) + const placed = placeMarkersInWindow(spec.markers, buckets) + const yDomain: [number, number] = isPercentMetric(spec.metric) + ? [0, 100] + : niceLinearDomain( + linearYDomain({ rows: data.map((point) => ({ value: point.value ?? 0 })), keys: ["value"] }), + ) + const color = spec.colors[spec.metric] ?? spec.colors.border ?? "" + const chrome = { border: spec.colors.border ?? "", background: spec.colors.background ?? "" } + const at = (point: MetricPoint) => point.date + const value = (point: MetricPoint) => point.value + const definition = defineChart({ + marks: [ + ...chartEventMarkerMarks(placed, yDomain, spec.markerColors), + lineY(data, { x: at, y: value, stroke: color, strokeWidth: 1.5, curve: d3Curve(curveMonotoneX) }), + focusDot(data, at, value, color, chrome), + focusCrosshair(chrome), + ], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { + line: false, + ticks: { size: 0, padding: 8, format: (v: number) => formatMetricValue(v, spec.metric) }, + }, + }, + }, + margin: { left: 52, top: 12, right: 12 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + const tooltipOf = (points: ReadonlyArray>) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => + ChartTooltip.Bucket({ + heading: axis.heading(datum.bucket), + rows: + datum.value === null + ? [] + : [ + { + label: METRIC_LABELS[spec.metric], + color, + dashed: false, + value: formatMetricValue(datum.value, spec.metric), + }, + ], + }), + ) + return { definition, focusKeyOf: (point) => point.datum.bucket, tooltipOf } +} diff --git a/apps/web-foldkit/src/charts/definitions/plot-constants.ts b/apps/web-foldkit/src/charts/definitions/plot-constants.ts new file mode 100644 index 0000000000..6b2eb69ccb --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/plot-constants.ts @@ -0,0 +1,79 @@ +import type { + ChartPoint, + ChartTooltipAnchor, + ChartTooltipAnchorContext, + ChartTooltipPosition, + ChartValue, +} from "@tanstack/charts" +import { findNearestSeriesKey } from "@maple/ui/components/charts/_shared/nearest-series" +import { tooltip } from "@tanstack/charts/tooltip" + +// Copied from React modules of packages/ui/src/components/plot (plot-tooltip.tsx, plot-legend.tsx, +// plot-frame.tsx) so this file stays React-free. Phase 1 moves them to a shared module. + +/** `cursorTooltip` (plot-tooltip.tsx): the shell themed by plot-tooltip.css, beside the cursor. */ +export const cursorTooltip = (anchor: ChartTooltipAnchor | "pointer") => + ({ + use: tooltip, + className: "maple-plot-tooltip", + anchor, + placement: "right", + offset: 12, + }) as const + +/** plot-legend.tsx: how far a non-highlighted series' colour is mixed into the background. */ +export const MUTED_COLOR_AMOUNT = 0.78 + +/** plot-frame.tsx: `maxFocusDistance` for a chart that resolves focus anywhere in the plot. */ +export const UNBOUNDED_FOCUS_DISTANCE = Number.MAX_SAFE_INTEGER + +/** How far from a series' plotted point the cursor may sit and still emphasise it. */ +const HIGHLIGHT_MAX_DISTANCE_PX = 24 + +interface TooltipFocus { + readonly pointerY: number | null + readonly mapY: ((value: number) => number) | null +} + +interface FocusCell { + current: TooltipFocus +} + +/** + * `createTooltipFocusStore`: the tooltip anchor records the pointer and the y scale on every + * update, so the body can emphasise the series nearest the cursor. One per built definition. + */ +export const tooltipFocusCapture = () => { + const cell: FocusCell = { current: { pointerY: null, mapY: null } } + const anchor = ( + _points: ReadonlyArray>, + context: ChartTooltipAnchorContext, + ): ChartTooltipPosition | null => { + const map = context.scales.y?.map + cell.current = { + pointerY: context.pointer?.y ?? null, + mapY: map === undefined ? null : (value) => map(value), + } + return context.pointer + } + /** + * `resolveTooltipHighlight`: the label nearest the cursor among `plotted` (label to where its + * mark sits in y-data space); single-series charts emphasise nothing. + */ + const highlight = ( + plotted: ReadonlyArray, + ): string | undefined => { + const { pointerY, mapY } = cell.current + if (plotted.length <= 1 || mapY === null || pointerY === null) return undefined + const yByLabel = Object.fromEntries( + plotted.flatMap(([label, y]) => (y === null ? [] : [[label, mapY(y)] as const])), + ) + return findNearestSeriesKey( + yByLabel, + plotted.map(([label]) => label), + pointerY, + HIGHLIGHT_MAX_DISTANCE_PX, + ) + } + return { anchor, highlight } +} diff --git a/apps/web-foldkit/src/charts/definitions/sparkline.ts b/apps/web-foldkit/src/charts/definitions/sparkline.ts new file mode 100644 index 0000000000..5e2ac54055 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/sparkline.ts @@ -0,0 +1,70 @@ +import { areaY, d3Curve, defineChart, lineY, type ChartLinearGradient } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import type { ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" + +// Port of `PlotSparkline` (packages/ui/src/components/plot/sparkline.tsx), definition half. + +type SparklineSpec = Extract + +interface SparkPoint { + readonly index: number + readonly value: number +} + +const STROKE_WIDTH = 1.5 + +// copied: `verticalGradient` (plot-paint.ts imports React's `useId`). +const verticalGradient = ( + id: string, + color: string, + startOpacity: number, + endOpacity: number, +): ChartLinearGradient => ({ + id, + x1: 0, + y1: 0, + x2: 0, + y2: 1, + stops: [ + { offset: 0.05, color, opacity: startOpacity }, + { offset: 0.95, color, opacity: endOpacity }, + ], +}) + +export const buildSparkline = (spec: SparklineSpec): BuiltChart => { + const points: ReadonlyArray = spec.values.map((value, index) => ({ index, value })) + const at = (point: SparkPoint) => point.index + const value = (point: SparkPoint) => point.value + const interpolation = spec.curve === "monotone" ? d3Curve(curveMonotoneX) : undefined + const definition = defineChart({ + gradients: [verticalGradient(spec.gradientId, spec.color, spec.fillStart, spec.fillEnd)], + marks: [ + areaY(points, { + x: at, + y: value, + y1: () => 0, + fill: `url(#${spec.gradientId})`, + stroke: "none", + curve: interpolation, + }), + lineY(points, { + x: at, + y: value, + stroke: spec.color, + strokeWidth: STROKE_WIDTH, + curve: interpolation, + }), + ], + scales: { + x: { scale: scaleLinear, axis: false }, + y: { scale: scaleLinear, axis: false }, + }, + tooltip: false, + focus: false, + margin: { top: 2, right: 0, bottom: 0, left: 0 }, + }) + return { definition, focusKeyOf: (point) => String(point.datum.index), tooltipOf: () => Option.none() } +} diff --git a/apps/web-foldkit/src/charts/definitions/spend.ts b/apps/web-foldkit/src/charts/definitions/spend.ts new file mode 100644 index 0000000000..23f932c554 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/spend.ts @@ -0,0 +1,128 @@ +import { formatCurrency } from "@maple/domain/format" +import { makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { formatDateInTimezone } from "@maple/web-data/lib/timezone-format" +import { areaY, d3Curve, defineChart, dot, lineY, ruleX, stack } from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip } from "./plot-constants" + +// Port of `SpendChart`'s `definition` memo (apps/web/src/components/settings/spend-chart.tsx). + +type SpendSpec = Extract +type Point = SpendSpec["points"][number] + +/** One stacked band segment: a day, its band, and its dollars (`stack()` groups on `z`). */ +interface SpendCell { + readonly point: Point + readonly band: string + readonly value: number | null +} + +/** A band segment when the stack is hovered, or a bare day from the projection line. */ +type SpendDatum = Point | SpendCell + +const dayOf = (datum: SpendDatum): Point => ("point" in datum ? datum.point : datum) + +/** Spend days are UTC calendar days: label them on that clock whatever the viewer's zone. */ +const SPEND_TIME_ZONE = "UTC" + +const dateLabel = (value: string) => + formatDateInTimezone(new Date(`${value}T00:00:00Z`), { timeZone: SPEND_TIME_ZONE, withYear: false }) + +export const buildSpendCumulative = (spec: SpendSpec): BuiltChart => { + const points = spec.points + const at = (point: Point) => new Date(point.dayMs) + const keys = spec.bands.map((band) => band.key) + const colorOf = (key: string) => spec.bands.find((band) => band.key === key)?.color ?? spec.chrome.border + const todayPoint = points.findLast((point) => !point.future) + const lastPoint = points[points.length - 1] + const hasFuture = lastPoint !== undefined && lastPoint.future + const axis = makeBucketAxis( + points.map((point) => point.date), + SPEND_TIME_ZONE, + ) + const cells: ReadonlyArray = points.flatMap((point) => + keys.map((band) => ({ point, band, value: point.bands[band] ?? null })), + ) + const projectedPoints = points.filter((point) => point.projected !== null) + + const definition = defineChart({ + marks: [ + areaY(cells, { + x: (cell: SpendCell) => at(cell.point), + y: (cell: SpendCell) => cell.value, + z: (cell: SpendCell) => cell.band, + fill: (cell: SpendCell) => colorOf(cell.band), + fillOpacity: 0.35, + stroke: (cell: SpendCell) => colorOf(cell.band), + strokeWidth: 1, + curve: d3Curve(curveMonotoneX), + layout: stack({ order: [...keys] }), + }), + // The projection: today's actual total joined to where the cycle lands, dashed. + ...(hasFuture + ? [ + lineY(projectedPoints, { + id: "projected", + x: at, + y: (point: Point) => point.projected, + stroke: spec.primary, + strokeWidth: 1.5, + strokeDasharray: "4 4", + }), + ] + : []), + ...(hasFuture && lastPoint !== undefined + ? [decorative(dot([lastPoint], { x: at, y: () => spec.projected, r: 3, fill: spec.primary }))] + : []), + // "Today": a vertical rule. + ...(todayPoint === undefined + ? [] + : [ + decorative( + ruleX([todayPoint], { x: at, stroke: spec.chrome.border, strokeOpacity: 1, strokeWidth: 1 }), + ), + ]), + focusCrosshair(spec.chrome), + ], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain([0, spec.yMax]), + axis: { + line: false, + ticks: { size: 0, padding: 8, format: (value: number) => `$${Math.round(value)}` }, + }, + }, + }, + margin: { top: 8, right: 56, left: 52 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + + return { + definition, + focusKeyOf: (point) => dayOf(point.datum).date, + tooltipOf: (focused) => + Option.map(Option.fromNullishOr(focused[0]?.datum), (datum) => { + const day = dayOf(datum) + return ChartTooltip.Bucket({ + heading: dateLabel(day.date), + rows: spec.bands.flatMap((band) => { + const value = day.bands[band.key] + return value === null || value === undefined + ? [] + : [{ label: band.label, color: band.color, dashed: false, value: formatCurrency(value, spec.currency) }] + }), + }) + }), + } +} diff --git a/apps/web-foldkit/src/charts/definitions/stacked-breakdown.ts b/apps/web-foldkit/src/charts/definitions/stacked-breakdown.ts new file mode 100644 index 0000000000..9fd985a614 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/stacked-breakdown.ts @@ -0,0 +1,106 @@ +import { areaY, d3Curve, defineChart, stack, type ChartPoint, type ChartValue } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { linearYDomain, niceLinearDomain } from "@maple/ui/components/plot/plot-scales" +import { formatNumber } from "@maple/ui/lib/format" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import { type InfraPoint, transformRows } from "../infra-metric-series" +import type { BuiltChart } from "./built-chart" +import { verticalGradient } from "./fixed-metric-axes" +import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" + +// Port of `StackedBreakdownChart`'s definition (components/infra/cloudflare/cloudflare-zone-detail-charts.tsx). + +type StackedBreakdownSpec = Extract + +/** One band segment: a bucket row, the series it belongs to, and its value. */ +interface BreakdownCell { + readonly row: InfraPoint + readonly name: string + readonly value: number +} + +/** A key absent from a bucket row is a zero count: `stack()` would tear the band at a null. */ +const cellValue = (value: unknown): number => (typeof value === "number" ? value : 0) + +export const buildStackedBreakdown = ( + spec: StackedBreakdownSpec, +): BuiltChart => { + const { data } = transformRows(spec.rows) + const series = spec.series + const axis = makeBucketAxis( + data.map((point) => point.bucket), + spec.timeZone, + ) + const yDomain = niceLinearDomain(linearYDomain({ rows: data, keys: series, stacked: true })) + const chrome = { border: spec.colors.border ?? "", background: spec.colors.background ?? "" } + const colorOf = (name: string) => spec.colors[name] ?? chrome.border + const gradientFor = (name: string) => `${spec.gradientPrefix}-${name.replace(/\W+/g, "_")}` + const cells: ReadonlyArray = data.flatMap((row) => + series.map((name) => ({ row, name, value: cellValue(row[name]) })), + ) + const focus = tooltipFocusCapture() + const definition = defineChart({ + gradients: series.map((name) => verticalGradient(gradientFor(name), colorOf(name), 0.4, 0.05)), + marks: [ + areaY(cells, { + x: (cell: BreakdownCell) => cell.row.date, + y: (cell: BreakdownCell) => cell.value, + z: (cell: BreakdownCell) => cell.name, + fill: (cell: BreakdownCell) => `url(#${gradientFor(cell.name)})`, + stroke: (cell: BreakdownCell) => colorOf(cell.name), + strokeWidth: 1.25, + curve: d3Curve(curveMonotoneX), + layout: stack({ order: [...series] }), + }), + focusCrosshair(chrome), + ], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { line: false, ticks: { size: 0, padding: 8, format: (v: number) => formatNumber(v) } }, + }, + }, + margin: { top: 12, right: 12, left: 60 }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip(focus.anchor), + }) + const tooltipOf = (points: ReadonlyArray>) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => { + // The raw value, as legacy's cursor series carry no stacked position. + const highlighted = focus.highlight( + series.map((name): readonly [string, number | null] => { + const value = datum.row[name] + return [spec.labels[name] ?? name, typeof value === "number" ? value : null] + }), + ) + const base = { + heading: axis.heading(datum.row.bucket), + // Off the bucket ROW, so hovering one band still prints every series. + rows: series.flatMap((name) => { + const value = datum.row[name] + return typeof value === "number" + ? [ + { + label: spec.labels[name] ?? name, + color: colorOf(name), + dashed: false, + value: formatNumber(value), + }, + ] + : [] + }), + } + return highlighted === undefined + ? ChartTooltip.Bucket(base) + : ChartTooltip.Bucket({ ...base, highlighted }) + }) + return { definition, focusKeyOf: (point) => point.datum.row.bucket, tooltipOf } +} diff --git a/apps/web-foldkit/src/charts/definitions/threshold-marks.ts b/apps/web-foldkit/src/charts/definitions/threshold-marks.ts new file mode 100644 index 0000000000..93a1d08bb7 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/threshold-marks.ts @@ -0,0 +1,43 @@ +import { ruleY, text, type ChartValue } from "@tanstack/charts" +import { decorative } from "@tanstack/charts/mark/decorative" +import type { TimeseriesAxisSpec } from "../chart-spec" + +// `thresholdRules` (packages/ui/src/components/plot/threshold-rules.ts): one dashed `ruleY` for every +// threshold and, anchored at `labelX`, their labels. Colours arrive resolved (`axisSpecOf`). + +type Threshold = TimeseriesAxisSpec["thresholds"][number] + +const THRESHOLD_STROKE_WIDTH = 1.5 +const THRESHOLD_LABEL_FONT_SIZE = 10 +const THRESHOLD_LABEL_DX = -4 +const THRESHOLD_LABEL_DY = -6 + +export const thresholdMarks = (thresholds: ReadonlyArray, labelX: ChartValue | undefined) => { + const valid = thresholds.filter((threshold) => Number.isFinite(threshold.value)) + if (valid.length === 0) return [] + const rules = ruleY(valid, { + y: (threshold: Threshold) => threshold.value, + stroke: (threshold: Threshold) => threshold.color, + strokeOpacity: 1, + strokeWidth: THRESHOLD_STROKE_WIDTH, + strokeDasharray: "4 4", + }) + const labelled = + labelX === undefined ? [] : valid.filter((threshold) => threshold.label !== null && threshold.label !== "") + if (labelX === undefined || labelled.length === 0) return [rules] + return [ + rules, + decorative( + text(labelled, { + x: () => labelX, + y: (threshold: Threshold) => threshold.value, + text: (threshold: Threshold) => threshold.label ?? "", + fill: (threshold: Threshold) => threshold.color, + anchor: "end", + dx: THRESHOLD_LABEL_DX, + dy: THRESHOLD_LABEL_DY, + fontSize: THRESHOLD_LABEL_FONT_SIZE, + }), + ), + ] +} diff --git a/apps/web-foldkit/src/charts/definitions/tool-series.ts b/apps/web-foldkit/src/charts/definitions/tool-series.ts new file mode 100644 index 0000000000..dcc3e26d55 --- /dev/null +++ b/apps/web-foldkit/src/charts/definitions/tool-series.ts @@ -0,0 +1,125 @@ +import { bucketDate, makeBucketAxis } from "@maple/ui/components/plot/bucket-axis" +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { linearYDomain, niceLinearDomain } from "@maple/ui/components/plot/plot-scales" +import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { formatDurationNs, formatToolMetric } from "@maple/web-data/lib/tool-analytics" +import { type ChartValue, d3Curve, defineChart, lineY } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { curveMonotoneX } from "d3-shape" +import { Option } from "effect" +import { ChartTooltip, type ChartSpec } from "../chart-spec" +import type { BuiltChart } from "./built-chart" +import { cursorTooltip, UNBOUNDED_FOCUS_DISTANCE } from "./plot-constants" + +// Port of `ToolSeriesChart`'s definition memo and `seriesLines` (agent-sessions/tools/ +// tool-series-chart.tsx, tool-chart-marks.ts). TODO(tooltip-anchor): legacy anchors the card on the +// cursor plot's focus store; here it follows the pointer. + +type ToolSeriesSpec = Extract +type Row = ToolSeriesSpec["rows"][number] + +/** A series at a bucket: one vertex of a line. */ +interface Cell { + readonly row: Row + readonly key: string +} + +const LINE_WIDTH = 1.5 + +const valueAt = (row: Row, key: string): number | null => { + const value = row[key] + return typeof value === "number" ? value : null +} + +/** The overview formats in the metric's unit; the detail grid's cells each name their formatter. */ +const formatOf = (spec: ToolSeriesSpec): ((value: number) => string) => { + switch (spec.format) { + case "number": + return formatNumber + case "errorRate": + return formatErrorRate + case "durationNs": + return formatDurationNs + case "oneDecimal": + return (value) => value.toFixed(1) + default: { + const metric = spec.format + return (value) => formatToolMetric(value, metric) + } + } +} + +const bucketOf = (row: Row): string => { + const value = row["bucket"] + return typeof value === "string" ? value : "" +} + +export const buildToolSeries = (spec: ToolSeriesSpec): BuiltChart => { + const axis = makeBucketAxis(spec.rows.map(bucketOf), spec.timeZone) + const yDomain = niceLinearDomain( + linearYDomain({ rows: spec.rows, keys: spec.series.map((entry) => entry.key) }), + ) + const at = (cell: Cell) => bucketDate(bucketOf(cell.row)) + const valueOf = (cell: Cell) => valueAt(cell.row, cell.key) + const marks = spec.series.flatMap((entry) => { + const cells = spec.rows.map((row) => ({ row, key: entry.key })) + return [ + lineY(cells, { + id: entry.key, + x: at, + y: valueOf, + stroke: entry.color, + strokeWidth: LINE_WIDTH, + curve: d3Curve(curveMonotoneX), + }), + focusDot(cells, at, valueOf, entry.color, spec.chrome), + ] + }) + return { + definition: defineChart({ + marks: [...marks, focusCrosshair(spec.chrome)], + scales: { + x: axis.x, + y: { + grid: DASHED_Y_GRID, + scale: scaleLinear().domain(yDomain), + axis: { + line: false, + ticks: { + size: 0, + padding: 8, + count: spec.tickCount ?? undefined, + format: (value: number) => (value === 0 ? "0" : formatOf(spec)(value)), + }, + }, + }, + }, + margin: { right: 8, top: spec.marginTop }, + focus: "group-x", + maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE, + focusRing: false, + tooltip: cursorTooltip("pointer"), + }), + focusKeyOf: (point) => bucketOf(point.datum.row), + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => + ChartTooltip.Bucket({ + heading: axis.heading(bucketOf(datum.row)), + rows: spec.series.flatMap((entry) => { + const value = valueAt(datum.row, entry.key) + return value === null + ? [] + : [ + { + label: entry.label, + color: entry.color, + dashed: false, + value: formatOf(spec)(value), + }, + ] + }), + }), + ), + } +} diff --git a/apps/web-foldkit/src/charts/event-marker-spec.ts b/apps/web-foldkit/src/charts/event-marker-spec.ts new file mode 100644 index 0000000000..c0133b40f0 --- /dev/null +++ b/apps/web-foldkit/src/charts/event-marker-spec.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect" + +// The chart event markers as data (`ChartEventMarker`), light enough for the view bundle. + +export const EventTone = Schema.Literals(["neutral", "warn", "crit"]) +export type EventTone = typeof EventTone.Type + +export const ChartEventMarker = Schema.Struct({ + id: Schema.String, + at: Schema.String, + label: Schema.String, + tone: EventTone, + endsAt: Schema.optionalKey(Schema.String), +}) +export type ChartEventMarker = typeof ChartEventMarker.Type + +/** Each tone's colour plus the label fill (`--muted-foreground`), as literals. */ +export const MarkerColors = Schema.Struct({ + neutral: Schema.String, + warn: Schema.String, + crit: Schema.String, + label: Schema.String, +}) +export type MarkerColors = typeof MarkerColors.Type + diff --git a/apps/web-foldkit/src/charts/fixed-metric-chart.ts b/apps/web-foldkit/src/charts/fixed-metric-chart.ts new file mode 100644 index 0000000000..3f5b5cb93e --- /dev/null +++ b/apps/web-foldkit/src/charts/fixed-metric-chart.ts @@ -0,0 +1,40 @@ +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html } from "foldkit/html" +import type { Message, Model } from "./chart" +import type { ChartSpec } from "./chart-spec" +import { bucketTooltipBody } from "./bucket-tooltip" +import { plotFrame } from "./plot-frame" + +// The `PlotFrame` half of `ThroughputAreaChart`, `ErrorRateAreaChart` and `LatencyLineChart`, with +// `PlotTooltipBody` as the tooltip. Embed it with `h.submodel`; the definition is built in the Mount. + +export interface FixedMetricChartInputs { + readonly chartId: string + readonly ariaLabel: string + readonly spec: Extract + readonly className?: string + /** `legend="visible"`: the `FixedMetricLegend` strip under the plot. */ + readonly legend?: Html + /** Drawn over the plot (deploy markers); `reportPlot` makes the chart report its geometry for it. */ + readonly overlay?: Html + readonly reportPlot?: boolean +} + +export const view = Submodel.defineView((model, inputs, h) => + plotFrame(h, { + chartId: inputs.chartId, + spec: inputs.spec, + ariaLabel: inputs.ariaLabel, + className: inputs.className, + legend: inputs.legend, + overlay: inputs.overlay, + reportPlot: inputs.reportPlot, + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + tooltip._tag === "Bucket" + ? Option.some(bucketTooltipBody(h, tooltip, portal)) + : Option.none(), + ), + }), +) diff --git a/apps/web-foldkit/src/charts/fixed-metric-legend.ts b/apps/web-foldkit/src/charts/fixed-metric-legend.ts new file mode 100644 index 0000000000..ec77b26cc6 --- /dev/null +++ b/apps/web-foldkit/src/charts/fixed-metric-legend.ts @@ -0,0 +1,38 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { type FixedMetricSeries, LATENCY_SERIES } from "./fixed-metric-series" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// Port of `FixedMetricLegend` (packages/ui/src/components/plot/fixed-metrics.tsx). + +/** `FixedMetricLegend`: the always-on swatch strip under a fixed-metric plot. */ +export const fixedMetricLegend = (h: HtmlBuilder, series: ReadonlyArray): Html => + h.div( + [h.Class("flex flex-wrap gap-x-3 gap-y-0.5 pt-2 text-xs")], + series.map((entry) => + h.span( + [h.Key(entry.key), h.Class("flex items-center gap-1.5 px-1 py-0.5")], + [ + h.span([ + h.Class( + cn("size-2 shrink-0 rounded-xs", entry.dashed === true && "border border-dashed"), + ), + h.Style( + entry.dashed === true + ? { "border-color": entry.color } + : { "background-color": entry.color }, + ), + ]), + h.span([h.Class("truncate")], [entry.label]), + ], + ), + ), + ) + +/** The P99/P95/P50 series `LatencyLineChart` draws, coloured off the theme. */ +export const latencySeries = (theme: ThemeColors): ReadonlyArray => + LATENCY_SERIES.map((entry) => ({ + key: entry.key, + label: entry.label, + color: resolveColor(theme, entry.token), + })) diff --git a/apps/web-foldkit/src/charts/fixed-metric-series.ts b/apps/web-foldkit/src/charts/fixed-metric-series.ts new file mode 100644 index 0000000000..938bf6eef2 --- /dev/null +++ b/apps/web-foldkit/src/charts/fixed-metric-series.ts @@ -0,0 +1,144 @@ +import { findFirstPartialIndex, trimEmptyTrailingBuckets } from "@maple/ui/components/plot/partial-buckets" +import { bucketIntervalLabel, inferBucketSeconds, inferRangeMs, parseBucketMs } from "@maple/ui/lib/format" + +// copied: the React-free halves of `useFixedMetricModel` (packages/ui/src/components/plot/fixed-metrics.tsx) +// No `@tanstack/charts` import: views read this too. + +import { rowsDomainMs, type TimeseriesAxisContext, type TimeseriesRow } from "./timeseries-rows" + +/** `normaliseFixedRows`: rows with their bucket parsed; a row with no parseable bucket is dropped. */ +export const normaliseFixedRows = (data: ReadonlyArray>): Array => + data.flatMap((row) => { + const bucket = typeof row.bucket === "string" ? row.bucket : "" + const epochMs = parseBucketMs(bucket) + return epochMs == null ? [] : [{ ...row, bucket, date: new Date(epochMs) }] + }) + +/** `reportedNothingPredicate`: a bucket with `totalCount === 0` reported nothing. */ +const reportedNothingPredicate = ( + rows: ReadonlyArray, +): ((row: TimeseriesRow) => boolean) | undefined => + rows.some((row) => typeof row.totalCount === "number") + ? (row) => (typeof row.totalCount === "number" ? row.totalCount === 0 : true) + : undefined + +export interface FixedMetricModel { + readonly rows: ReadonlyArray + readonly plotRows: ReadonlyArray + readonly trimOptions: { isEmpty?: (row: TimeseriesRow) => boolean; now: number } + readonly bucketSeconds: number | undefined + readonly axisContext: TimeseriesAxisContext +} + +/** `useFixedMetricModel` without the hooks; `nowMs` stands in for the render-time `Date.now()`. */ +export const fixedMetricModel = ( + data: ReadonlyArray>, + timeZone: string | null, + nowMs: number, +): FixedMetricModel => { + const rows = normaliseFixedRows(data) + const isEmpty = reportedNothingPredicate(rows) + const trimOptions = isEmpty === undefined ? { now: nowMs } : { isEmpty, now: nowMs } + const first = findFirstPartialIndex(rows, { now: nowMs }) + const plotRows = + first === -1 || isEmpty === undefined ? rows : trimEmptyTrailingBuckets(rows, [], first, trimOptions) + const bucketSeconds = inferBucketSeconds(plotRows) + return { + rows, + plotRows, + trimOptions, + bucketSeconds, + axisContext: { + rangeMs: inferRangeMs(plotRows), + bucketSeconds, + domainMs: rowsDomainMs(plotRows), + timeZone: timeZone ?? undefined, + }, + } +} + +/** One fixed series, as the marks, tooltip and legend all see it (`FixedMetricSeries`). */ +export interface FixedMetricSeries { + readonly key: string + readonly label: string + readonly color: string + readonly dashed?: boolean +} + +// copied: `ThroughputAreaChart`'s keys and `deriveRows` (throughput-area-chart.tsx). +export const THROUGHPUT_KEY = "throughput" +export const ERROR_KEY = "errorThroughput" +export const TRACED_KEY = "tracedThroughput" + +export const deriveThroughputRows = ( + rows: ReadonlyArray, + divisor: number, +): Array => + rows.map((row) => { + const errorRate = typeof row.errorRate === "number" ? row.errorRate : 0 + const rawThroughput = row[THROUGHPUT_KEY] + const rawTraced = row[TRACED_KEY] + const throughput = typeof rawThroughput === "number" ? rawThroughput / divisor : null + const traced = typeof rawTraced === "number" ? rawTraced / divisor : null + return { + ...row, + [THROUGHPUT_KEY]: throughput, + [TRACED_KEY]: traced, + [ERROR_KEY]: throughput == null ? null : throughput * errorRate, + } + }) + +export interface ThroughputPlot { + readonly rows: ReadonlyArray + readonly rateLabel: string + readonly hasSampling: boolean + readonly hasTraced: boolean + readonly hasErrors: boolean +} + +/** What `ThroughputAreaChart` derives before it builds marks: the rate rows and which series exist. */ +export const throughputPlot = (model: FixedMetricModel, perSecond: boolean): ThroughputPlot => { + const rows = deriveThroughputRows( + model.plotRows, + perSecond && model.bucketSeconds ? model.bucketSeconds : 1, + ) + return { + rows, + rateLabel: perSecond ? "/s" : bucketIntervalLabel(model.bucketSeconds), + hasSampling: rows.some((row) => row.hasSampling === true), + hasTraced: rows.some((row) => typeof row[TRACED_KEY] === "number"), + hasErrors: rows.some((row) => { + const value = row[ERROR_KEY] + return typeof value === "number" && value > 0 + }), + } +} + +/** The throughput chart's legend series (the traced line is a reference, not a key). */ +export const throughputLegend = ( + plot: ThroughputPlot, + colors: Readonly<{ throughput: string; error: string }>, +): ReadonlyArray => { + const { rateLabel } = plot + const throughputLabel = `${plot.hasSampling ? "~" : ""}Throughput${rateLabel ? ` (${rateLabel})` : ""}` + return [ + { key: THROUGHPUT_KEY, label: throughputLabel, color: colors.throughput }, + ...(plot.hasErrors + ? [ + { + key: ERROR_KEY, + label: rateLabel ? `Errors (${rateLabel})` : "Errors", + color: colors.error, + dashed: true, + }, + ] + : []), + ] +} + +// copied: `LATENCY_TOKENS` / `LATENCY_LABELS` (latency-line-chart.tsx), paint order p99 first. +export const LATENCY_SERIES = [ + { key: "p99LatencyMs", label: "P99", token: "--chart-p99" }, + { key: "p95LatencyMs", label: "P95", token: "--chart-p95" }, + { key: "p50LatencyMs", label: "P50", token: "--chart-p50" }, +] as const diff --git a/apps/web-foldkit/src/charts/funnel-chart.ts b/apps/web-foldkit/src/charts/funnel-chart.ts new file mode 100644 index 0000000000..aee420163e --- /dev/null +++ b/apps/web-foldkit/src/charts/funnel-chart.ts @@ -0,0 +1,217 @@ +import { formatNumber, formatPercent, formatValueByUnit } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message, type Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" +import { funnelStages, type Stage } from "./funnel-stages" + +// Port of `FunnelBarsChart` (packages/ui/src/components/charts/funnel/query-builder-funnel-chart.tsx). +// The hovered stage is `activeKey` (its index), the hovered breakdown group `activeGroup`. +// TODO(funnel-legend): the legend's "+N" chip for chips that wrapped out of its one line. + +export interface FunnelChartInputs { + readonly data: ReadonlyArray> + readonly className?: string | undefined + readonly unit?: string | undefined + readonly showStepPercent?: boolean | undefined +} + +type H = HtmlBuilder + +export const fmtValue = (value: number, unit?: string): string => + unit !== undefined && unit !== "" ? formatValueByUnit(value, unit) : formatNumber(value) + +const ROW_GAP = 6 +const ROW_MIN_H = 22 +const ROW_FULL_H = ROW_MIN_H + ROW_GAP +const MORE_ROW_H = 16 +const GROUP_BAR_H = 8 +export const PERCENT_FLOOR = { floor: 0.0005 } +const LEGEND_H = 18 + +const sep = (h: H, glyph: string): Html => h.span([h.Class("px-1 text-muted-foreground/50")], [glyph]) + +const swatch = (h: H, className: string, color: string): Html => + h.span([h.Class(className), h.Style({ "background-color": color })]) + +const legendRow = (h: H, legend: ReadonlyArray<{ name: string; color: string }>, hoverGroup: string | null): Html => + h.div( + [h.Class("flex shrink-0 items-start gap-x-3 text-3xs leading-none text-muted-foreground")], + [ + h.div( + [ + h.Class( + "relative flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5 overflow-hidden", + ), + h.Style({ "max-height": `${LEGEND_H}px` }), + h.DataAttribute("slot", "funnel-legend"), + ], + legend.map((entry) => + h.button( + [ + h.Type("button"), + h.Class( + cn( + "flex min-w-0 items-center gap-1 transition-opacity", + hoverGroup !== null && hoverGroup !== entry.name && "opacity-50", + ), + ), + h.OnMouseEnter(Message.ChangedActiveGroup({ maybeGroup: Option.some(entry.name) })), + h.OnMouseLeave(Message.ChangedActiveGroup({ maybeGroup: Option.none() })), + h.Title(entry.name), + ], + [ + swatch(h, "size-2 shrink-0 rounded-xs", entry.color), + h.span([h.Class("truncate")], [entry.name]), + ], + ), + ), + ), + ], + ) + +type Flags = Readonly<{ unit: string | undefined; showShareOfFirst: boolean; showStepConversion: boolean }> + +const stageNumbers = (h: H, stage: Stage, hoverGroup: string | null, flags: Flags): ReadonlyArray => { + const hovered = hoverGroup === null ? undefined : stage.groups.find((group) => group.name === hoverGroup) + if (hovered !== undefined) + return [ + swatch(h, "mr-1 inline-block size-2 rounded-xs align-middle", hovered.color), + h.span([h.Class("text-foreground/90")], [fmtValue(hovered.value, flags.unit)]), + ...(flags.showShareOfFirst + ? [sep(h, "·"), h.span([], [formatPercent(hovered.pctOfFirst, PERCENT_FLOOR)])] + : []), + ] + return [ + h.span([h.Class("text-foreground/90")], [fmtValue(stage.value, flags.unit)]), + ...(flags.showShareOfFirst + ? [sep(h, "·"), h.span([], [formatPercent(stage.pctOfFirst, PERCENT_FLOOR)])] + : []), + ...(flags.showStepConversion && stage.pctOfPrev !== null + ? [sep(h, "↓"), h.span([], [formatPercent(stage.pctOfPrev, PERCENT_FLOOR)])] + : []), + ] +} + +const groupBars = (h: H, stage: Stage, hoverGroup: string | null, fade: number, unit: string | undefined): Html => + h.div( + [h.Class("flex w-full flex-col gap-0.5"), h.DataAttribute("slot", "funnel-group-bars")], + stage.groups.map((group) => + h.div( + [ + h.Class("relative h-1.5 w-full overflow-hidden rounded-xs bg-foreground/5"), + h.OnMouseEnter(Message.ChangedActiveGroup({ maybeGroup: Option.some(group.name) })), + h.OnMouseLeave(Message.ChangedActiveGroup({ maybeGroup: Option.none() })), + h.Title(`${group.name} · ${fmtValue(group.value, unit)}`), + ], + [ + h.div([ + h.Class("absolute inset-y-0 left-0 rounded-xs"), + h.Style({ + width: `${group.widthPct * 100}%`, + "background-color": group.color, + opacity: String((hoverGroup !== null && hoverGroup !== group.name ? 0.35 : 1) * fade), + transition: "opacity 140ms ease, width 220ms ease", + }), + ]), + ], + ), + ), + ) + +export const view = Submodel.defineView((model, inputs, h) => { + const { stages, legend } = funnelStages(inputs.data) + if (stages.length === 0) + return h.div( + [h.Class(cn("relative h-full w-full grid place-items-center", inputs.className))], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ) + const isGrouped = legend.length > 0 + const rowFullH = isGrouped ? ROW_MIN_H + legend.length * GROUP_BAR_H + ROW_GAP : ROW_FULL_H + const chromeH = MORE_ROW_H + (isGrouped ? LEGEND_H : 0) + const height = model.containerHeight + const maxRows = height > 0 ? Math.max(1, Math.floor((height - chromeH) / rowFullH)) : stages.length + const truncated = stages.length > maxRows + const visible = truncated ? stages.slice(0, maxRows) : stages + const hiddenCount = stages.length - visible.length + const hover = Option.getOrNull(model.activeKey) + const hoverGroup = Option.getOrNull(model.activeGroup) + const flags: Flags = { + unit: inputs.unit, + showShareOfFirst: inputs.showStepPercent !== false, + showStepConversion: inputs.showStepPercent === true, + } + return h.div( + [ + h.Class( + cn( + "flex h-full w-full flex-col gap-1.5 overflow-hidden px-1 select-none", + truncated ? "justify-start" : "justify-center", + inputs.className, + ), + ), + h.Style({ "row-gap": `${ROW_GAP}px` }), + h.OnMount(MeasureChartContainer()), + h.OnMouseLeave(Message.LeftChart()), + ], + [ + ...(isGrouped ? [legendRow(h, legend, hoverGroup)] : []), + ...visible.map((stage, i) => { + const key = String(i) + const fade = hover !== null && hover !== key ? 0.55 : 1 + return h.div( + [ + h.Class("flex min-h-0 flex-col justify-center gap-0.5"), + h.Style({ "min-height": `${ROW_MIN_H}px` }), + h.OnMouseEnter(Message.EnteredLegendItem({ key })), + ], + [ + h.div( + [h.Class("flex items-baseline justify-between gap-2 text-2xs leading-none")], + [ + h.span( + [ + h.Class( + cn( + "truncate", + stage.unnamed ? "italic text-muted-foreground" : "text-foreground/90", + ), + ), + h.Title(stage.name), + ], + [stage.name], + ), + h.span( + [h.Class("shrink-0 tabular-nums text-muted-foreground")], + stageNumbers(h, stage, hoverGroup, flags), + ), + ], + ), + isGrouped ? groupBars(h, stage, hoverGroup, fade, inputs.unit) : stageBar(h, stage, fade), + ], + ) + }), + ...(hiddenCount > 0 + ? [h.div([h.Class("shrink-0 text-3xs leading-none text-muted-foreground")], [`+${hiddenCount} more`])] + : []), + ], + ) +}) + +const stageBar = (h: H, stage: Stage, fade: number): Html => + h.div( + [h.Class("relative h-2.5 w-full overflow-hidden rounded-[3px] bg-foreground/5")], + [ + h.div([ + h.Class("absolute inset-y-0 left-0 rounded-[3px]"), + h.Style({ + width: `${stage.widthPct * 100}%`, + "background-color": stage.color, + opacity: String(fade), + transition: "opacity 140ms ease, width 220ms ease", + }), + ]), + ], + ) diff --git a/apps/web-foldkit/src/charts/funnel-dropoff-chart.ts b/apps/web-foldkit/src/charts/funnel-dropoff-chart.ts new file mode 100644 index 0000000000..3a52a16405 --- /dev/null +++ b/apps/web-foldkit/src/charts/funnel-dropoff-chart.ts @@ -0,0 +1,425 @@ +import { pickValueField } from "@maple/ui/components/charts/_shared/breakdown-rows" +import { EMPTY_VALUE, formatPercent } from "@maple/ui/lib/format" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "../ui/eyebrow" +import { icon } from "../ui/icon" +import { Message, type Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" +import type { FunnelChartInputs } from "./funnel-chart" +import { fmtValue } from "./funnel-chart" +import { fmtSpan, leaverRows, type Step, toSteps } from "./funnel-dropoff-steps" + +// Port of `FunnelDropoffChart` (packages/ui/src/components/charts/funnel/funnel-dropoff-chart.tsx). +// The hovered column is the chart Model's `activeKey` (its index). + +type H = HtmlBuilder + +const STEP_MIN_W = 76 +const TIP_W = 264 +const LOST_CLASS = "bg-foreground/[0.06]" +const STEP_GAP = 12 +const PILL_TIME_MIN_W = 150 +const PILL_LOSS_MIN_W = 104 + +const tipRow = ( + h: H, + glyph: Html, + label: string, + value: string, + share: string | undefined, + shareClass: string, +): Html => + h.div( + [h.Class("flex items-center justify-between gap-3 py-px tabular-nums")], + [ + h.span([h.Class("flex items-center gap-1.5 text-muted-foreground")], [glyph, label]), + h.span( + [h.Class("flex items-baseline gap-1.5 truncate")], + [ + h.span([h.Class("font-semibold text-foreground")], [value]), + ...(share === undefined ? [] : [h.span([h.Class(cn("text-3xs", shareClass))], [share])]), + ], + ), + ], + ) + +const leaversBlock = (h: H, hovered: Step): ReadonlyArray => + hovered.leavers.length > 0 && hovered.dropped > 0 + ? [ + h.div([h.Class("-mx-2.5 my-1.5 h-px bg-border")]), + eyebrow(h, { as: "div", className: "mb-1 flex items-center gap-1.5" }, [ + icon(h, "ArrowDownIcon", { size: 10, className: TONE_TEXT.crit }), + "Dropped here went to", + ]), + ...leaverRows(hovered.leavers, hovered.dropped).map((entry) => + h.div( + [h.Class("my-0.5")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.span( + [ + h.Class( + cn( + "min-w-0 flex-1 truncate", + entry.ended ? "text-muted-foreground" : "text-foreground/90", + ), + ), + ], + [entry.name], + ), + h.span([h.Class("tabular-nums text-muted-foreground")], [formatPercent(entry.share)]), + ], + ), + h.div( + [h.Class("relative mt-0.5 h-1 rounded-sm bg-foreground/5")], + [ + h.div([ + h.Class( + cn( + "absolute inset-y-0 left-0 rounded-sm", + entry.ended ? "bg-foreground/25" : "bg-[var(--chart-2)]", + ), + ), + h.Style({ width: `${entry.share * 100}%` }), + ]), + ], + ), + ], + ), + ), + ] + : [] + +const tooltip = (h: H, hovered: Step, prev: Step, left: number, unit: string | undefined): Html => + h.div( + [ + h.Class( + "pointer-events-none absolute top-5 z-10 rounded-lg border bg-popover px-2.5 py-2 text-2xs shadow-md", + ), + h.Style({ left: `${left}px`, width: `${TIP_W}px` }), + h.DataAttribute("slot", "funnel-dropoff-tooltip"), + ], + [ + h.div( + [h.Class("mb-1.5 flex min-w-0 items-center gap-1.5 font-medium text-foreground")], + [ + h.span([h.Class("truncate")], [prev.name]), + icon(h, "ArrowRightIcon", { size: 12, className: "shrink-0 text-muted-foreground" }), + h.span([h.Class("truncate")], [hovered.name]), + ], + ), + h.div( + [h.Class("mb-1.5 flex h-1.5 gap-px overflow-hidden rounded-sm")], + [ + h.div([h.Class("bg-severity-info"), h.Style({ width: `${(hovered.ofPrev ?? 0) * 100}%` })]), + h.div([h.Class("flex-1 bg-severity-error/70")]), + ], + ), + tipRow( + h, + icon(h, "ArrowRightIcon", { size: 12, className: "text-severity-info" }), + "converted", + fmtValue(hovered.value, unit), + formatPercent(hovered.ofPrev ?? 0), + "text-success-foreground", + ), + tipRow( + h, + icon(h, "ArrowDownIcon", { size: 12, className: TONE_TEXT.crit }), + "dropped", + fmtValue(hovered.dropped, unit), + prev.value > 0 ? formatPercent(hovered.dropped / prev.value) : EMPTY_VALUE, + TONE_TEXT.crit, + ), + ...(hovered.p50Ms === undefined + ? [] + : [ + tipRow( + h, + icon(h, "ClockIcon", { size: 12, className: "text-muted-foreground" }), + "time between", + `p50 ${fmtSpan(hovered.p50Ms)}`, + hovered.p90Ms === undefined ? undefined : `p90 ${fmtSpan(hovered.p90Ms)}`, + "text-muted-foreground", + ), + ]), + ...leaversBlock(h, hovered), + ], + ) + +type ColumnContext = Readonly<{ + steps: ReadonlyArray + visible: ReadonlyArray + hover: number | null + isGrouped: boolean + showPercent: boolean + pillTime: boolean + pillLoss: boolean + unit: string | undefined +}> + +const groupBars = (h: H, ctx: ColumnContext, step: Step, index: number): Html => + h.div( + [h.Class("flex h-full items-end gap-0.5")], + step.groups.map((group) => { + const max = ctx.steps[0]?.value ?? 1 + const height = group.value / max + const capH = Math.max(0, (group.prev - group.value) / max) + return h.div( + [h.Class("relative h-full flex-1"), h.Title(`${group.name} · ${fmtValue(group.value, ctx.unit)}`)], + [ + ...(index > 0 && capH > 0 + ? [ + h.div([ + h.Class(cn("absolute inset-x-0 rounded-[3px]", LOST_CLASS)), + h.Style({ bottom: `${height * 100}%`, height: `${capH * 100}%` }), + ]), + ] + : []), + h.div([ + h.Class("absolute inset-x-0 bottom-0 rounded-[3px]"), + h.Style({ height: `${height * 100}%`, "background-color": group.color }), + ]), + ], + ) + }), + ) + +const plainBars = (h: H, step: Step, prev: Step | undefined): ReadonlyArray => { + const capH = prev !== undefined ? Math.max(0, prev.ofFirst - step.ofFirst) : 0 + return [ + ...(prev !== undefined && capH > 0 + ? [ + h.div([ + h.Class(cn("absolute inset-x-0 rounded-sm", LOST_CLASS)), + h.Style({ bottom: `${step.ofFirst * 100}%`, height: `${capH * 100}%` }), + h.DataAttribute("slot", "funnel-dropoff-lost"), + ]), + ] + : []), + h.div([ + h.Class("absolute inset-x-0 bottom-0 rounded-sm bg-[var(--chart-2)]"), + h.Style({ + height: `${Math.max(step.ofFirst * 100, step.value > 0 ? 1.5 : 0)}%`, + transition: "height 220ms ease", + }), + h.DataAttribute("slot", "funnel-dropoff-bar"), + ]), + ] +} + +const pill = (h: H, ctx: ColumnContext, step: Step, prev: Step, isLast: boolean): Html => { + const lostShare = prev.value > 0 ? step.dropped / prev.value : 0 + return h.span( + [ + h.Class( + "absolute bottom-1.5 left-1.5 flex items-center gap-2 rounded-md bg-card/95 px-1.5 py-1 text-2xs leading-none tabular-nums whitespace-nowrap shadow-xs ring-1 ring-border/60", + ), + h.DataAttribute("slot", "funnel-dropoff-pill"), + ], + [ + h.span( + [h.Class("flex items-center gap-1 font-semibold text-foreground")], + [ + icon(h, isLast ? "CircleCheckIcon" : "ArrowRightIcon", { + size: 12, + className: "text-severity-info", + }), + formatPercent(step.ofPrev ?? 0), + ], + ), + ...(ctx.pillLoss && lostShare > 0 + ? [ + h.span( + [h.Class("flex items-center gap-1 text-foreground/80")], + [icon(h, "ArrowDownIcon", { size: 12, className: TONE_TEXT.crit }), formatPercent(lostShare)], + ), + ] + : []), + ...(ctx.pillTime && step.p50Ms !== undefined + ? [ + h.span( + [h.Class("text-muted-foreground"), h.Title("Median time from the previous step")], + [fmtSpan(step.p50Ms)], + ), + ] + : []), + ], + ) +} + +const column = (h: H, ctx: ColumnContext, step: Step, index: number): Html => { + const prev = ctx.visible[index - 1] + const fade = ctx.hover !== null && ctx.hover !== index ? 0.6 : 1 + const isLast = index === ctx.steps.length - 1 + return h.div( + [ + h.Class("flex min-w-0 flex-col"), + h.OnMouseEnter(Message.EnteredLegendItem({ key: String(index) })), + h.DataAttribute("slot", "funnel-dropoff-step"), + ], + [ + h.div( + [h.Class("flex flex-col gap-1 leading-none")], + [ + h.span( + [ + h.Class( + cn( + "truncate text-2xs font-medium", + step.unnamed ? "italic text-muted-foreground" : "text-foreground/90", + ), + ), + h.Title(step.name), + ], + [step.name], + ), + h.span( + [h.Class("truncate text-base font-semibold tabular-nums text-foreground")], + [ctx.showPercent ? formatPercent(step.ofFirst) : fmtValue(step.value, ctx.unit)], + ), + h.span( + [h.Class("truncate text-2xs tabular-nums text-muted-foreground")], + [ + ctx.showPercent + ? fmtValue(step.value, ctx.unit) + : index === 0 + ? "entered" + : "reached", + ], + ), + ], + ), + h.div( + [ + h.Class("relative mt-2.5 min-h-0 flex-1"), + h.Style({ opacity: String(fade), transition: "opacity 140ms ease" }), + ], + [ + ...(ctx.isGrouped ? [groupBars(h, ctx, step, index)] : plainBars(h, step, prev)), + ...(prev !== undefined && ctx.showPercent && step.ofPrev !== null + ? [pill(h, ctx, step, prev, isLast)] + : []), + ], + ), + ], + ) +} + +const header = (h: H, steps: ReadonlyArray, legend: ReadonlyArray<{ name: string; color: string }>, showPercent: boolean, unit: string | undefined): Html => { + const first = steps[0] + const last = steps[steps.length - 1] + return h.div( + [h.Class("flex shrink-0 items-baseline justify-between gap-3 pb-2 text-3xs leading-none")], + [ + legend.length > 0 + ? h.div( + [h.Class("flex min-w-0 flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground")], + legend.map((entry) => + h.span( + [h.Class("flex min-w-0 items-center gap-1"), h.Title(entry.name)], + [ + h.span([ + h.Class("size-2 shrink-0 rounded-xs"), + h.Style({ "background-color": entry.color }), + ]), + h.span([h.Class("truncate")], [entry.name]), + ], + ), + ), + ) + : h.span([], []), + ...(first !== undefined && last !== undefined && steps.length > 1 + ? [ + h.span( + [ + h.Class("flex shrink-0 items-baseline gap-1.5 tabular-nums whitespace-nowrap"), + h.DataAttribute("slot", "funnel-dropoff-summary"), + ], + [ + h.span([h.Class("text-muted-foreground")], [showPercent ? "Conversion rate" : "Converted"]), + h.span( + [h.Class("text-2xs font-semibold text-foreground")], + [ + showPercent + ? formatPercent(last.ofFirst) + : `${fmtValue(last.value, unit)} of ${fmtValue(first.value, unit)}`, + ], + ), + ], + ), + ] + : []), + ], + ) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const { steps, legend } = toSteps(inputs.data, pickValueField(inputs.data)) + if (steps.length === 0 || (steps[0]?.value ?? 0) <= 0) + return h.div( + [h.Class(cn("relative grid h-full w-full place-items-center", inputs.className))], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ) + const width = model.containerWidth + const isGrouped = legend.length > 0 + const showPercent = inputs.showStepPercent !== false + const maxSteps = + width > 0 ? Math.max(1, Math.floor((width + STEP_GAP) / (STEP_MIN_W + STEP_GAP))) : steps.length + const visible = steps.slice(0, maxSteps) + const hidden = steps.length - visible.length + const hover = Option.match(model.activeKey, { onNone: () => null, onSome: Number }) + const hovered = hover !== null && hover > 0 ? visible[hover] : undefined + const hoveredPrev = hover !== null && hover > 0 ? visible[hover - 1] : undefined + const columnW = width > 0 ? (width - STEP_GAP * (visible.length - 1)) / visible.length : 0 + const hoverLeft = hover === null ? 0 : hover * (columnW + STEP_GAP) + const tipLeft = + hover === null + ? 0 + : hoverLeft + columnW + 8 + TIP_W <= width + ? hoverLeft + columnW + 8 + : Math.max(0, hoverLeft - TIP_W - 8) + const ctx: ColumnContext = { + steps, + visible, + hover, + isGrouped, + showPercent, + pillTime: columnW === 0 || columnW >= PILL_TIME_MIN_W, + pillLoss: columnW === 0 || columnW >= PILL_LOSS_MIN_W, + unit: inputs.unit, + } + return h.div( + [ + h.Class(cn("relative flex h-full w-full flex-col overflow-hidden px-1 select-none", inputs.className)), + h.OnMount(MeasureChartContainer()), + h.OnMouseLeave(Message.LeftChart()), + h.DataAttribute("slot", "funnel-dropoff"), + ], + [ + header(h, steps, legend, showPercent, inputs.unit), + h.div( + [ + h.Class("grid min-h-0 flex-1"), + h.Style({ + "grid-template-columns": `repeat(${visible.length}, minmax(0, 1fr))`, + "column-gap": `${STEP_GAP}px`, + }), + ], + visible.map((step, index) => column(h, ctx, step, index)), + ), + ...(hidden > 0 + ? [h.div([h.Class("shrink-0 pt-1 text-3xs leading-none text-muted-foreground")], [`+${hidden} more`])] + : []), + ...(hovered !== undefined && hoveredPrev !== undefined && !isGrouped + ? [tooltip(h, hovered, hoveredPrev, tipLeft, inputs.unit)] + : []), + ], + ) +}) diff --git a/apps/web-foldkit/src/charts/funnel-dropoff-steps.ts b/apps/web-foldkit/src/charts/funnel-dropoff-steps.ts new file mode 100644 index 0000000000..0a0f685ffd --- /dev/null +++ b/apps/web-foldkit/src/charts/funnel-dropoff-steps.ts @@ -0,0 +1,145 @@ +import { asFiniteNumber, toBreakdownRows } from "@maple/ui/components/charts/_shared/breakdown-rows" +import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" + +// The step derivation of `FunnelDropoffChart` (packages/ui/src/components/charts/funnel/ +// funnel-dropoff-chart.tsx): one column per step, with timing and leavers when the route sends them. + +export interface Leaver { + readonly name: string + readonly count: number +} + +export interface Step { + readonly name: string + readonly unnamed: boolean + readonly value: number + readonly ofFirst: number + readonly ofPrev: number | null + readonly dropped: number + readonly p50Ms?: number | undefined + readonly p90Ms?: number | undefined + readonly leavers: ReadonlyArray + readonly groups: ReadonlyArray<{ name: string; color: string; value: number; prev: number }> +} + +const LEAVERS_SHOWN = 4 +const ENDED_LABEL = "Nothing after" + +type Row = Readonly> + +/** `fmtSpan`: coarse, human durations. */ +export const fmtSpan = (ms: number): string => { + if (!Number.isFinite(ms) || ms <= 0) return EMPTY_VALUE + const s = Math.round(ms / 1000) + if (s < 60) return `${s}s` + const m = Math.round(s / 60) + if (m < 60) return `${m}m` + const h = Math.floor(m / 60) + if (h < 24) return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m` + const d = Math.floor(h / 24) + return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h` +} + +const isRecord = (entry: unknown): entry is Row => typeof entry === "object" && entry !== null + +const toLeavers = (value: unknown): ReadonlyArray => + Array.isArray(value) + ? value + .filter(isRecord) + .map((entry) => ({ + name: typeof entry.name === "string" ? entry.name : "", + count: asFiniteNumber(entry.count), + })) + .filter((entry) => entry.count > 0) + : [] + +const optionalNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined + +const groupedSteps = (rows: ReadonlyArray, valueField: string) => { + const groups = [...new Set(rows.map((row) => String(row.group)))] + const order: Array = [] + const byName = new Map }>() + rows.forEach((row) => { + const raw = row.name === null || row.name === undefined ? "" : String(row.name).trim() + const name = raw === "" ? "(no value)" : raw + const existing = byName.get(name) + const step = existing ?? { value: 0, byGroup: new Map() } + if (existing === undefined) { + byName.set(name, step) + order.push(name) + } + const value = asFiniteNumber(row[valueField]) + const group = String(row.group) + step.byGroup.set(group, (step.byGroup.get(group) ?? 0) + value) + step.value += value + }) + const colors = resolveSeriesColors(groups) + const first = byName.get(order[0] ?? "")?.value ?? 0 + const steps = order.map((name, index): Step => { + const step = byName.get(name) ?? { value: 0, byGroup: new Map() } + const prev = index > 0 ? byName.get(order[index - 1] ?? "") : undefined + return { + name, + unnamed: name === "(no value)", + value: step.value, + ofFirst: first > 0 ? step.value / first : 0, + ofPrev: prev !== undefined && prev.value > 0 ? step.value / prev.value : null, + dropped: prev !== undefined ? Math.max(0, prev.value - step.value) : 0, + leavers: [], + groups: groups.map((group) => ({ + name: group, + color: colors.get(group) ?? "", + value: step.byGroup.get(group) ?? 0, + prev: prev?.byGroup.get(group) ?? step.byGroup.get(group) ?? 0, + })), + } + }) + return { steps, legend: groups.map((name) => ({ name, color: colors.get(name) ?? "" })) } +} + +export type DropoffSteps = Readonly<{ + steps: ReadonlyArray + legend: ReadonlyArray<{ readonly name: string; readonly color: string }> +}> + +/** `toSteps`: grouped or plain. */ +export const toSteps = (source: ReadonlyArray, valueField: string): DropoffSteps => { + const grouped = source.filter((row) => typeof row.group === "string") + if (grouped.length > 0 && grouped.length === source.length) return groupedSteps(grouped, valueField) + const rows = toBreakdownRows(source, valueField) + const first = rows[0]?.value ?? 0 + return { + legend: [], + steps: rows.map((row, index): Step => { + const raw = source[index] ?? {} + const prev = rows[index - 1] + return { + ...row, + ofFirst: first > 0 ? row.value / first : 0, + ofPrev: prev !== undefined && prev.value > 0 ? row.value / prev.value : null, + dropped: prev !== undefined ? Math.max(0, prev.value - row.value) : 0, + p50Ms: optionalNumber(raw.p50Ms), + p90Ms: optionalNumber(raw.p90Ms), + leavers: toLeavers(raw.leavers), + groups: [], + } + }), + } +} + +/** `leaverRows`: the top leavers as shares of everyone who dropped, the remainder as "Other". */ +export const leaverRows = (leavers: ReadonlyArray, dropped: number) => { + const shown = leavers.slice(0, LEAVERS_SHOWN) + const named = shown.reduce((acc, entry) => acc + entry.count, 0) + const rows = shown.map((entry) => ({ + name: entry.name === "" ? ENDED_LABEL : entry.name, + ended: entry.name === "", + share: Math.min(1, entry.count / dropped), + })) + const rest = dropped - named + return rest > 0 && leavers.length > LEAVERS_SHOWN + ? [...rows, { name: "Other", ended: true, share: rest / dropped }] + : rows +} diff --git a/apps/web-foldkit/src/charts/funnel-stages.ts b/apps/web-foldkit/src/charts/funnel-stages.ts new file mode 100644 index 0000000000..5647ccc9e3 --- /dev/null +++ b/apps/web-foldkit/src/charts/funnel-stages.ts @@ -0,0 +1,134 @@ +import { + asFiniteNumber, + type BreakdownRow, + pickValueField, + toBreakdownRows, +} from "@maple/ui/components/charts/_shared/breakdown-rows" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" + +// The stage derivation of `FunnelBarsChart` (packages/ui/src/components/charts/funnel/ +// query-builder-funnel-chart.tsx): rows folded into stages, grouped rows into one bar per group. + +export interface StageGroup { + readonly name: string + readonly color: string + readonly value: number + readonly widthPct: number + readonly pctOfFirst: number +} + +export interface Stage extends BreakdownRow { + readonly color: string + readonly widthPct: number + readonly pctOfFirst: number + readonly pctOfPrev: number | null + readonly groups: ReadonlyArray +} + +const BAR_MIN_PCT = 0.04 + +/** `dropTrailingZeroRows`: keep one zero stage right after the last non-zero one. */ +export const dropTrailingZeroRows = (rows: ReadonlyArray): ReadonlyArray => { + const lastNonZero = rows.findLastIndex((row) => row.value > 0) + return rows.slice(0, Math.min(rows.length, lastNonZero + 2)) +} + +type Row = Readonly> +type GroupedStage = BreakdownRow & { readonly byGroup: ReadonlyMap } + +const isGroupedRow = (row: Row): boolean => typeof row.group === "string" + +const toGroupedStages = (source: ReadonlyArray, valueField: string) => { + const groups = [...new Set(source.map((row) => String(row.group)))] + const order: Array = [] + const byName = new Map }>() + source.forEach((row) => { + const raw = row.name === null || row.name === undefined ? "" : String(row.name).trim() + const name = raw === "" ? "(no value)" : raw + const existing = byName.get(name) + const stage = existing ?? { unnamed: raw === "", value: 0, byGroup: new Map() } + if (existing === undefined) { + byName.set(name, stage) + order.push(name) + } + const value = asFiniteNumber(row[valueField]) + const group = String(row.group) + stage.byGroup.set(group, (stage.byGroup.get(group) ?? 0) + value) + stage.value += value + }) + const stages: ReadonlyArray = order.flatMap((name) => { + const stage = byName.get(name) + return stage === undefined ? [] : [{ name, ...stage }] + }) + return { stages, groups } +} + +export type FunnelStages = Readonly<{ + stages: ReadonlyArray + legend: ReadonlyArray<{ readonly name: string; readonly color: string }> +}> + +const groupedFunnel = (source: ReadonlyArray, valueField: string): FunnelStages => { + const { stages: folded, groups } = toGroupedStages(source, valueField) + const rows = dropTrailingZeroRows(folded) + const max = rows.reduce((acc, r) => Math.max(acc, r.value), 0) + const first = rows[0] + if (max <= 0 || first === undefined) return { stages: [], legend: [] } + const groupMax = rows.reduce( + (acc, r) => Math.max(acc, ...groups.map((group) => r.byGroup.get(group) ?? 0)), + 0, + ) + const colors = resolveSeriesColors(groups) + return { + legend: groups.map((name) => ({ name, color: colors.get(name) ?? "" })), + stages: rows.map((row, idx): Stage => { + const prev = rows[idx - 1]?.value + return { + name: row.name, + unnamed: row.unnamed, + value: row.value, + color: "", + widthPct: Math.max(BAR_MIN_PCT, row.value / max), + pctOfFirst: first.value > 0 ? row.value / first.value : 0, + pctOfPrev: idx === 0 || prev === undefined || prev <= 0 ? null : row.value / prev, + groups: groups.map((group): StageGroup => { + const value = row.byGroup.get(group) ?? 0 + const groupFirst = first.byGroup.get(group) ?? 0 + return { + name: group, + color: colors.get(group) ?? "", + value, + widthPct: groupMax > 0 ? Math.max(value > 0 ? BAR_MIN_PCT : 0, value / groupMax) : 0, + pctOfFirst: groupFirst > 0 ? value / groupFirst : 0, + } + }), + } + }), + } +} + +/** The `stages` / `legend` memo. */ +export const funnelStages = (source: ReadonlyArray): FunnelStages => { + const valueField = pickValueField(source) + const grouped = source.filter(isGroupedRow) + if (grouped.length > 0 && grouped.length === source.length) return groupedFunnel(grouped, valueField) + const rows = dropTrailingZeroRows(toBreakdownRows(source, valueField)) + const max = rows.reduce((acc, r) => Math.max(acc, r.value), 0) + const first = rows[0]?.value ?? 0 + if (max <= 0) return { stages: [], legend: [] } + const colors = resolveSeriesColors(rows.map((row) => row.name)) + return { + legend: [], + stages: rows.map((row, idx): Stage => { + const prev = rows[idx - 1]?.value + return { + ...row, + color: colors.get(row.name) ?? "", + widthPct: Math.max(BAR_MIN_PCT, row.value / max), + pctOfFirst: first > 0 ? row.value / first : 0, + pctOfPrev: idx === 0 || prev === undefined || prev <= 0 ? null : row.value / prev, + groups: [], + } + }), + } +} diff --git a/apps/web-foldkit/src/charts/hbar-chart.ts b/apps/web-foldkit/src/charts/hbar-chart.ts new file mode 100644 index 0000000000..3f3c9fe704 --- /dev/null +++ b/apps/web-foldkit/src/charts/hbar-chart.ts @@ -0,0 +1,157 @@ +import { pickValueField, toBreakdownRows } from "@maple/ui/components/charts/_shared/breakdown-rows" +import { formatNumber, formatPercent, formatValueByUnit } from "@maple/ui/lib/format" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import { truncatedText } from "../ui/truncated-text" +import { Message, type Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" + +// Port of `QueryBuilderHbarChart` (packages/ui/src/components/charts/hbar/query-builder-hbar-chart.tsx): +// ranked DOM bars sized from the measured card. The hovered row is the chart Model's `activeKey`. + +export interface HbarChartInputs { + readonly data: ReadonlyArray> + readonly className?: string | undefined + readonly unit?: string | undefined +} + +const fmtValue = (value: number, unit?: string): string => + unit !== undefined && unit !== "" ? formatValueByUnit(value, unit) : formatNumber(value) + +const PCT_FLOOR = { floor: 0.001 } +const ROW_GAP = 6 +const ROW_MIN_H = 18 +const ROW_FULL_H = ROW_MIN_H + ROW_GAP +const ROW_MAX_H = 56 +const BAR_MIN_THICKNESS = 10 +const BAR_MAX_THICKNESS = 20 +const BAR_MIN_PCT = 0.02 +const MORE_ROW_H = 16 + +export const hbarBars = (source: ReadonlyArray>) => { + const rows = toBreakdownRows(source, pickValueField(source)) + .filter((row) => row.value > 0) + .sort((a, b) => b.value - a.value) + const max = rows[0]?.value ?? 0 + const total = rows.reduce((acc, row) => acc + row.value, 0) + if (max <= 0) return [] + const colors = resolveSeriesColors(rows.map((row) => row.name)) + return rows.map((row) => ({ + ...row, + color: colors.get(row.name) ?? "", + widthPct: Math.max(BAR_MIN_PCT, row.value / max), + pctOfTotal: total > 0 ? row.value / total : 0, + })) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const bars = hbarBars(inputs.data) + if (bars.length === 0) + return h.div( + [h.Class(cn("relative h-full w-full grid place-items-center", inputs.className))], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ) + const height = model.containerHeight + const maxRows = height > 0 ? Math.max(1, Math.floor((height - MORE_ROW_H) / ROW_FULL_H)) : bars.length + const truncated = bars.length > maxRows + // TODO(hbar-scroll): the "+N more" count does not follow the inner scroll position. + const hiddenCount = truncated + ? Math.max(0, bars.length - Math.floor((height - MORE_ROW_H + ROW_GAP) / ROW_FULL_H)) + : 0 + const rowH = + height > 0 && !truncated + ? Math.min(ROW_MAX_H, Math.max(ROW_MIN_H, (height - ROW_GAP * (bars.length - 1)) / bars.length)) + : ROW_MIN_H + const barH = Math.round( + BAR_MIN_THICKNESS + + ((BAR_MAX_THICKNESS - BAR_MIN_THICKNESS) * (rowH - ROW_MIN_H)) / (ROW_MAX_H - ROW_MIN_H), + ) + const hover = Option.getOrNull(model.activeKey) + return h.div( + [ + h.Class(cn("relative h-full w-full select-none", inputs.className)), + h.OnMount(MeasureChartContainer()), + h.OnMouseLeave(Message.LeftChart()), + ], + [ + h.div( + [ + h.Class( + cn( + "flex h-full w-full flex-col justify-start px-1", + truncated + ? "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + : "overflow-hidden", + ), + ), + h.Style({ + "row-gap": `${ROW_GAP}px`, + "padding-bottom": `${truncated ? MORE_ROW_H : 0}px`, + }), + h.DataAttribute("slot", "hbar-rows"), + ], + bars.map((bar, i) => { + const key = String(i) + const fade = hover !== null && hover !== key ? 0.55 : 1 + return h.div( + [ + h.Class("grid min-h-0 shrink-0 items-center gap-2 text-2xs leading-none"), + h.Style({ + "grid-template-columns": "minmax(0, 38%) 1fr max-content", + height: `${rowH}px`, + }), + h.OnMouseEnter(Message.EnteredLegendItem({ key })), + ], + [ + truncatedText(h, { + text: bar.name, + className: bar.unnamed ? "italic text-muted-foreground" : "text-foreground/90", + }), + h.div( + [ + h.Class("relative w-full overflow-hidden rounded-[3px] bg-foreground/5"), + h.Style({ height: `${barH}px` }), + ], + [ + h.div([ + h.Class("absolute inset-y-0 left-0 rounded-[3px]"), + h.Style({ + width: `${bar.widthPct * 100}%`, + "background-color": bar.color, + opacity: String(fade), + transition: "opacity 140ms ease, width 220ms ease", + }), + ]), + ], + ), + h.span( + [h.Class("shrink-0 text-right tabular-nums text-muted-foreground")], + [ + h.span([h.Class("text-foreground/90")], [fmtValue(bar.value, inputs.unit)]), + h.span([h.Class("px-1 text-muted-foreground/50")], ["·"]), + h.span([], [formatPercent(bar.pctOfTotal, PCT_FLOOR)]), + ], + ), + ], + ) + }), + ), + ...(hiddenCount > 0 + ? [ + h.div( + [ + h.Class( + "pointer-events-none absolute inset-x-0 bottom-0 flex items-end bg-gradient-to-t from-card via-card/90 to-transparent px-1 pt-4 text-3xs leading-none text-muted-foreground", + ), + h.Style({ height: `${MORE_ROW_H + 16}px` }), + h.DataAttribute("slot", "hbar-more"), + ], + [h.span([h.Class("pb-px")], [`+${hiddenCount} more · scroll`])], + ), + ] + : []), + ], + ) +}) diff --git a/apps/web-foldkit/src/charts/heatmap-chart.ts b/apps/web-foldkit/src/charts/heatmap-chart.ts new file mode 100644 index 0000000000..5729f6fef6 --- /dev/null +++ b/apps/web-foldkit/src/charts/heatmap-chart.ts @@ -0,0 +1,248 @@ +import { DEFAULT_HEATMAP_COLOR_SCALE, type HeatmapColorScale } from "@maple/domain/http" +import { countLabel } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Message, Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" +import { ChartSpec, ChartTooltip } from "./chart-spec" +import { FOOTNOTE_BLOCK_H, solveHeatmapLayout } from "./heatmap-layout" +import { heatmapLegend } from "./heatmap-legend" +import { + buildHeatmapModel, + deriveHeatmapPoints, + formatScalar, + ISO_RE, + shortenYLabel, + truncateYLabel, +} from "./heatmap-model" +import { plotFrame } from "./plot-frame" +import { sequentialColorScale } from "./sequential-colors" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// Port of `QueryBuilderHeatmapChart` (packages/ui/src/components/charts/heatmap/ +// query-builder-heatmap-chart.tsx). The definition is ./definitions/heatmap.ts. + +export interface HeatmapChartInputs { + readonly chartId: string + readonly data: ReadonlyArray> + readonly theme: ThemeColors + readonly className?: string | undefined + readonly tooltip?: "visible" | "hidden" | undefined + readonly unit?: string | undefined + readonly colorScale?: string | undefined + readonly scaleType?: "linear" | "log" | undefined + readonly timeZone?: string | undefined +} + +type H = HtmlBuilder + +const SCALES = ["amber", "blues", "reds", "viridis", "magma", "cividis"] as const +const FALLBACKS = { + amber: ["oklch(0.32 0.035 70)", "oklch(0.45 0.075 65)", "oklch(0.58 0.115 60)", "oklch(0.71 0.15 57)", "oklch(0.85 0.13 78)"], + blues: ["oklch(0.32 0.04 255)", "oklch(0.45 0.08 252)", "oklch(0.58 0.12 250)", "oklch(0.71 0.15 248)", "oklch(0.85 0.11 238)"], + reds: ["oklch(0.32 0.045 28)", "oklch(0.45 0.09 28)", "oklch(0.58 0.14 28)", "oklch(0.71 0.185 30)", "oklch(0.85 0.135 45)"], + viridis: ["oklch(0.32 0.055 285)", "oklch(0.45 0.095 265)", "oklch(0.58 0.09 200)", "oklch(0.71 0.135 150)", "oklch(0.85 0.17 112)"], + magma: ["oklch(0.32 0.06 300)", "oklch(0.45 0.12 312)", "oklch(0.58 0.175 350)", "oklch(0.71 0.16 35)", "oklch(0.85 0.115 80)"], + cividis: ["oklch(0.32 0.045 262)", "oklch(0.45 0.055 250)", "oklch(0.58 0.03 130)", "oklch(0.71 0.075 95)", "oklch(0.85 0.13 95)"], +} satisfies Readonly> +const GROUT_FALLBACK = "oklch(0.175 0.008 62)" + +/** Every custom property the heatmap reads; a page resolves them with `ResolveThemeColors`. */ +export const HEATMAP_TOKENS: ReadonlyArray = [ + ...SCALES.flatMap((scale) => [0, 1, 2, 3, 4].map((index) => `--heatmap-${scale}-${index}`)), + "--heatmap-grout", + "--foreground", +] + +const isKnownScale = (value: string): value is HeatmapColorScale & (typeof SCALES)[number] => + SCALES.some((scale) => scale === value) + +const rampStops = (theme: ThemeColors, scale: (typeof SCALES)[number]): ReadonlyArray => + FALLBACKS[scale].map((fallback, index) => resolveColor(theme, `--heatmap-${scale}-${index}`, fallback)) + +const grout = (theme: ThemeColors) => resolveColor(theme, "--heatmap-grout", GROUT_FALLBACK) + +/** The quiet placeholder: a tiny suggestive grid with three tinted cells. */ +const emptyState = (h: H, inputs: HeatmapChartInputs): Html => { + const amber = rampStops(inputs.theme, "amber") + return h.div( + [h.Class(cn("relative h-full w-full", inputs.className))], + [ + h.div( + [h.Class("absolute inset-0 grid place-items-center")], + [ + h.div( + [h.Class("flex flex-col items-center gap-2.5")], + [ + h.div( + [ + h.Class("grid grid-cols-8 gap-[2px] rounded-sm p-[3px]"), + h.Style({ background: grout(inputs.theme) }), + ], + Array.from({ length: 32 }, (_, i) => + h.div([ + h.Class("size-1.5 rounded-[1px]"), + h.Style({ + background: + i === 11 + ? (amber[1] ?? "") + : i === 12 + ? (amber[3] ?? "") + : i === 20 + ? (amber[2] ?? "") + : "color-mix(in oklch, var(--foreground) 8%, transparent)", + }), + ]), + ), + ), + h.div([h.Class("text-2xs text-muted-foreground")], ["No data"]), + ], + ), + ], + ), + ], + ) +} + +const tooltipBody = ( + h: H, + tooltip: ChartTooltip, + colorAt: (value: number) => string, + label: (y: string) => string, + unit: string | undefined, + portal: Attribute, +): Option.Option => + tooltip._tag !== "HeatmapSlot" + ? Option.none() + : Option.some( + h.div( + [h.Class("contents"), portal], + [ + h.div( + [h.Class("text-muted-foreground")], + [ + h.span([], [tooltip.x]), + h.span([h.Class("px-1 text-muted-foreground/50")], ["·"]), + h.span([], [label(tooltip.y)]), + ], + ), + h.div( + [h.Class("mt-1 flex items-center gap-1.5")], + tooltip.value === null + ? [h.span([h.Class("italic text-muted-foreground")], ["no data"])] + : [ + h.span([ + h.Class("size-2 shrink-0 rounded-xs"), + h.Style({ "background-color": colorAt(tooltip.value) }), + ]), + h.span( + [h.Class("font-medium tabular-nums text-foreground")], + [formatScalar(tooltip.value, unit)], + ), + ], + ), + ], + ), + ) + +const footnoteOf = (hiddenX: number, hiddenY: number): string | null => + hiddenX > 0 || hiddenY > 0 + ? `${[ + hiddenX > 0 ? countLabel(hiddenX, "empty column") : null, + hiddenY > 0 ? countLabel(hiddenY, "empty row") : null, + ] + .filter((part) => part !== null) + .join(" · ")} hidden` + : null + +export const view = Submodel.defineView((chart, inputs, h) => { + const model = buildHeatmapModel(deriveHeatmapPoints(inputs.data)) + if (model.xDomain.length === 0 || model.yDomain.length === 0) return emptyState(h, inputs) + const requested = inputs.colorScale ?? DEFAULT_HEATMAP_COLOR_SCALE + const palette = isKnownScale(requested) ? requested : "amber" + const scaleType = inputs.scaleType ?? "linear" + const stops = rampStops(inputs.theme, palette) + const colorAt = sequentialColorScale({ stops, domain: model.domain, scaleType }) + const allYIso = model.yDomain.every((value) => ISO_RE.test(value)) + const label = (y: string) => shortenYLabel(y, allYIso, inputs.timeZone) + const footnote = footnoteOf(model.hiddenX, model.hiddenY) + const layout = solveHeatmapLayout({ + containerW: chart.containerWidth, + containerH: chart.containerHeight, + columns: model.xDomain.length, + rows: model.yDomain.length, + longestYLabelChars: model.yDomain.reduce((longest, value) => Math.max(longest, label(value).length), 0), + hasFootnote: footnote !== null, + }) + const hoveredKey = Option.getOrNull(chart.activeKey) + const hovered = + hoveredKey === null + ? null + : ([...model.cells, ...model.holes].find((slot) => `${slot.x}\u0000${slot.y}` === hoveredKey)?.value ?? + null) + const spec = ChartSpec.Heatmap({ + cells: model.cells, + holes: model.holes, + xDomain: model.xDomain, + yDomain: model.yDomain, + yLabels: Object.fromEntries( + model.yDomain.map((y) => [y, truncateYLabel(label(y), layout.yLabelChars)]), + ), + margin: layout.margin, + paddingInnerX: layout.paddingInnerX, + paddingInnerY: layout.paddingInnerY, + radius: layout.radius, + stops, + domain: model.domain, + scaleType, + grout: grout(inputs.theme), + foreground: resolveColor(inputs.theme, "--foreground"), + showTooltip: inputs.tooltip !== "hidden", + }) + return h.div( + [ + h.Class(cn("relative h-full w-full select-none", inputs.className)), + h.Style({ animation: "tile-in 0.35s ease both" }), + h.OnMount(MeasureChartContainer()), + ], + [ + plotFrame(h, { + chartId: inputs.chartId, + className: "h-full w-full", + ariaLabel: "Heatmap", + spec, + legend: heatmapLegend(h, { + domain: model.domain, + span: model.span, + scaleType, + colorAt, + unit: inputs.unit, + hovered, + insetLeft: layout.gutter, + insetRight: layout.rightInset, + }), + ...(footnote === null + ? undefined + : { + footer: h.p( + [ + h.Class("pt-1 text-right text-3xs leading-3 tabular-nums text-muted-foreground/70"), + h.Style({ + height: `${FOOTNOTE_BLOCK_H}px`, + "padding-left": `${layout.gutter}px`, + "padding-right": `${layout.rightInset}px`, + }), + ], + [footnote], + ), + }), + tooltipBody: (portal) => + Option.flatMap(chart.tooltip, (tooltip) => + tooltipBody(h, tooltip, colorAt, label, inputs.unit, portal), + ), + }), + ], + ) +}) diff --git a/apps/web-foldkit/src/charts/heatmap-layout.ts b/apps/web-foldkit/src/charts/heatmap-layout.ts new file mode 100644 index 0000000000..730512d8ce --- /dev/null +++ b/apps/web-foldkit/src/charts/heatmap-layout.ts @@ -0,0 +1,92 @@ +// `solveHeatmapLayout` (packages/ui/src/components/charts/heatmap/query-builder-heatmap-chart.tsx): +// the bounded y gutter, the locked margins and the band padding, in one pass. Constants as there. + +const MAX_CELL_W = 72 +const MAX_CELL_H = 40 +const CELL_GAP_WIDE = 2 +const CELL_GAP_TIGHT = 1 +const TIGHT_CELL_THRESHOLD = 14 +const Y_LABEL_CHAR_PX = 6.3 +const Y_LABEL_INSET_PX = 10 +const Y_GUTTER_MIN = 36 +const Y_GUTTER_MAX = 96 +const X_AXIS_BAND_PX = 20 +const PLOT_TOP_PAD = 2 +export const LEGEND_BLOCK_H = 34 +export const FOOTNOTE_BLOCK_H = 16 +const UNMEASURED_PADDING_INNER = 0.05 + +export interface HeatmapMargin { + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number +} + +export interface HeatmapLayout { + readonly margin: HeatmapMargin | null + readonly paddingInnerX: number + readonly paddingInnerY: number + readonly radius: number + readonly gutter: number + readonly rightInset: number + readonly yLabelChars: number +} + +export interface HeatmapLayoutInput { + readonly containerW: number + readonly containerH: number + readonly columns: number + readonly rows: number + readonly longestYLabelChars: number + readonly hasFootnote: boolean +} + +const clamp = (value: number, lo: number, hi: number): number => + Number.isFinite(value) ? Math.max(lo, Math.min(hi, value)) : lo + +const seamPaddingInner = (length: number, count: number, gapPx: number): number => + clamp((gapPx * count) / (length + gapPx), 0, 1) + +const cappedGridLength = (count: number, maxCellPx: number, gapPx: number): number => + count * maxCellPx + Math.max(0, count - 1) * gapPx + +export const solveHeatmapLayout = (input: HeatmapLayoutInput): HeatmapLayout => { + const { containerW, containerH, columns, rows, longestYLabelChars, hasFootnote } = input + const gutter = clamp(longestYLabelChars * Y_LABEL_CHAR_PX + Y_LABEL_INSET_PX, Y_GUTTER_MIN, Y_GUTTER_MAX) + const yLabelChars = Math.max(1, Math.floor((gutter - Y_LABEL_INSET_PX) / Y_LABEL_CHAR_PX)) + const chartBoxH = containerH - LEGEND_BLOCK_H - (hasFootnote ? FOOTNOTE_BLOCK_H : 0) + const availW = containerW - gutter + const availH = chartBoxH - PLOT_TOP_PAD - X_AXIS_BAND_PX + if (!(availW > 0) || !(availH > 0) || columns <= 0 || rows <= 0) + return { + margin: null, + paddingInnerX: UNMEASURED_PADDING_INNER, + paddingInnerY: UNMEASURED_PADDING_INNER, + radius: 2, + gutter, + rightInset: 0, + yLabelChars, + } + const roughW = Math.min(availW / columns, MAX_CELL_W) + const roughH = Math.min(availH / rows, MAX_CELL_H) + const gap = roughW >= TIGHT_CELL_THRESHOLD && roughH >= TIGHT_CELL_THRESHOLD ? CELL_GAP_WIDE : CELL_GAP_TIGHT + const gridW = Math.min(availW, cappedGridLength(columns, MAX_CELL_W, gap)) + const gridH = Math.min(availH, cappedGridLength(rows, MAX_CELL_H, gap)) + const cellW = (gridW - Math.max(0, columns - 1) * gap) / columns + const cellH = (gridH - Math.max(0, rows - 1) * gap) / rows + return { + margin: { + top: PLOT_TOP_PAD, + right: Math.max(0, containerW - gutter - gridW), + bottom: Math.max(X_AXIS_BAND_PX, chartBoxH - PLOT_TOP_PAD - gridH), + left: gutter, + }, + paddingInnerX: seamPaddingInner(gridW, columns, gap), + paddingInnerY: seamPaddingInner(gridH, rows, gap), + radius: cellW >= 10 && cellH >= 10 ? 2 : 1, + gutter, + rightInset: Math.max(0, containerW - gutter - gridW), + yLabelChars, + } +} diff --git a/apps/web-foldkit/src/charts/heatmap-legend.ts b/apps/web-foldkit/src/charts/heatmap-legend.ts new file mode 100644 index 0000000000..051e0c809f --- /dev/null +++ b/apps/web-foldkit/src/charts/heatmap-legend.ts @@ -0,0 +1,97 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { LEGEND_BLOCK_H } from "./heatmap-layout" +import { formatScalar, rampPositionOf, roundTick, valueAtRampPosition } from "./heatmap-model" +import type { SequentialScaleType } from "./sequential-domain" + +// `HeatmapLegend`: five stepped swatches with values read back through the ramp, a marker at the +// hovered value, and insets that line the bar up with the plot's columns. + +const LEGEND_STEPS = 5 +const LEGEND_BAR_H = 8 +const LEGEND_FONT_PX = 10 + +export type HeatmapLegendInputs = Readonly<{ + domain: readonly [number, number] + span: number + scaleType: SequentialScaleType + colorAt: (value: number) => string + unit: string | undefined + hovered: number | null + insetLeft: number + insetRight: number +}> + +const translateFor = (anchor: "start" | "middle" | "end") => + anchor === "start" ? "translateX(0)" : anchor === "end" ? "translateX(-100%)" : "translateX(-50%)" + +export const heatmapLegend = (h: HtmlBuilder, inputs: HeatmapLegendInputs): Html => { + const { domain, span, scaleType } = inputs + const ticks: ReadonlyArray<{ t: number; anchor: "start" | "middle" | "end" }> = + span <= 0 + ? [{ t: 0, anchor: "start" }] + : [ + { t: 0, anchor: "start" }, + { t: 0.5, anchor: "middle" }, + { t: 1, anchor: "end" }, + ] + return h.div( + [ + h.DataAttribute("heatmap-legend", ""), + h.Class("pt-2"), + h.Style({ + height: `${LEGEND_BLOCK_H}px`, + "padding-left": `${inputs.insetLeft}px`, + "padding-right": `${inputs.insetRight}px`, + }), + ], + [ + h.div( + [h.Class("relative flex gap-px"), h.Style({ height: `${LEGEND_BAR_H}px` })], + [ + ...Array.from({ length: LEGEND_STEPS }, (_, index) => + h.div([ + h.Class("flex-1 rounded-xs"), + h.Style({ + "background-color": inputs.colorAt( + valueAtRampPosition((index + 0.5) / LEGEND_STEPS, domain, scaleType), + ), + }), + ]), + ), + ...(inputs.hovered === null + ? [] + : [ + h.div([ + h.AriaHidden(true), + h.Class("pointer-events-none absolute"), + h.Style({ + left: `${rampPositionOf(inputs.hovered, domain, scaleType) * 100}%`, + top: "-1px", + width: "1px", + height: `${LEGEND_BAR_H + 2}px`, + background: "var(--foreground)", + }), + ]), + ]), + ], + ), + h.div( + [h.Class("relative mt-1.5"), h.Style({ height: "12px" })], + ticks.map(({ t, anchor }) => + h.div( + [ + h.Class("absolute tabular-nums text-muted-foreground"), + h.Style({ + left: `${t * 100}%`, + transform: translateFor(anchor), + "font-size": `${LEGEND_FONT_PX}px`, + "line-height": "1", + }), + ], + [formatScalar(roundTick(valueAtRampPosition(t, domain, scaleType), domain[0], span), inputs.unit)], + ), + ), + ), + ], + ) +} diff --git a/apps/web-foldkit/src/charts/heatmap-model.ts b/apps/web-foldkit/src/charts/heatmap-model.ts new file mode 100644 index 0000000000..c6e791f44b --- /dev/null +++ b/apps/web-foldkit/src/charts/heatmap-model.ts @@ -0,0 +1,160 @@ +import { DateTime, Option } from "effect" +import { formatNumber, formatValueByUnit } from "@maple/ui/lib/format" +import { resolveSequentialDomain, type SequentialScaleType } from "./sequential-domain" + +// The model half of `QueryBuilderHeatmapChart` (packages/ui/src/components/charts/heatmap/ +// query-builder-heatmap-chart.tsx): points, the pruned grid, holes, ramp positions and labels. + +export interface HeatmapPoint { + readonly x: string + readonly y: string + readonly value: number +} + +/** A cell with a value, or a hole with none (holes are focusable data). */ +export interface HeatmapSlot { + readonly x: string + readonly y: string + readonly value: number | null +} + +type Row = Readonly> + +const asFiniteNumber = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +export const deriveHeatmapPoints = (rows: ReadonlyArray): ReadonlyArray => { + const first = rows[0] + if (first === undefined) return [] + if ("x" in first && "y" in first && "value" in first) + return rows.map((row) => ({ + x: String(row.x ?? ""), + y: String(row.y ?? ""), + value: asFiniteNumber(row.value), + })) + const numericKeys = Object.keys(first).filter( + (k) => k !== "name" && k !== "bucket" && typeof first[k] === "number", + ) + const labelKey = "name" in first ? "name" : "bucket" in first ? "bucket" : null + if (labelKey === null || numericKeys.length === 0) return [] + return rows.flatMap((row) => + numericKeys.map((xKey) => ({ x: xKey, y: String(row[labelKey] ?? ""), value: asFiniteNumber(row[xKey]) })), + ) +} + +export interface HeatmapModel { + readonly xDomain: ReadonlyArray + readonly yDomain: ReadonlyArray + readonly cells: ReadonlyArray + readonly holes: ReadonlyArray + readonly domain: readonly [number, number] + readonly span: number + readonly hiddenX: number + readonly hiddenY: number +} + +const EMPTY_MODEL: HeatmapModel = { + xDomain: [], + yDomain: [], + cells: [], + holes: [], + domain: [0, 1], + span: 0, + hiddenX: 0, + hiddenY: 0, +} + +/** `buildModel`: all-zero axes pruned (and counted), y reversed, the cross-product split into cells and holes. */ +export const buildHeatmapModel = (points: ReadonlyArray): HeatmapModel => { + if (points.length === 0) return EMPTY_MODEL + const xs = [...new Set(points.map((p) => p.x))] + const ys = [...new Set(points.map((p) => p.y))] + const live = points.filter((p) => p.value !== 0) + const liveX = new Set(live.map((p) => p.x)) + const liveY = new Set(live.map((p) => p.y)) + const keptX = xs.filter((x) => liveX.has(x)) + const keptY = ys.filter((y) => liveY.has(y)) + const pruned = keptX.length > 0 && keptY.length > 0 + const xDomain = pruned ? keptX : xs + const yDomain = [...(pruned ? keptY : ys)].reverse() + const slotKey = (x: string, y: string) => `${x}\u0000${y}` + const lookup = new Map(points.map((point) => [slotKey(point.x, point.y), point.value])) + const slots = xDomain.flatMap((x) => + yDomain.map((y): HeatmapSlot => ({ x, y, value: lookup.get(slotKey(x, y)) ?? null })), + ) + const cells = slots.filter((slot) => slot.value !== null) + const values = cells.flatMap((slot) => (slot.value === null ? [] : [slot.value])) + const min = values.length > 0 ? Math.min(...values) : 0 + const max = values.length > 0 ? Math.max(...values) : 1 + const span = max - min + return { + xDomain, + yDomain, + cells, + holes: slots.filter((slot) => slot.value === null), + domain: span > 0 ? [min, max] : [min, min + 1], + span, + hiddenX: pruned ? xs.length - keptX.length : 0, + hiddenY: pruned ? ys.length - keptY.length : 0, + } +} + +const clamp = (value: number, lo: number, hi: number): number => + Number.isFinite(value) ? Math.max(lo, Math.min(hi, value)) : lo + +const clamp01 = (value: number): number => clamp(value, 0, 1) + +/** The data value at parametric position `t` along the ramp (linear in value, or in log value). */ +export const valueAtRampPosition = ( + t: number, + domain: readonly [number, number], + scaleType: SequentialScaleType, +): number => { + const [lo, hi] = resolveSequentialDomain(domain, scaleType) + if (hi <= lo) return lo + return scaleType === "log" ? lo * (hi / lo) ** clamp01(t) : lo + clamp01(t) * (hi - lo) +} + +export const rampPositionOf = ( + value: number, + domain: readonly [number, number], + scaleType: SequentialScaleType, +): number => { + const [lo, hi] = resolveSequentialDomain(domain, scaleType) + if (hi <= lo) return 0 + return scaleType === "log" + ? clamp01(Math.log(Math.max(value, lo) / lo) / Math.log(hi / lo)) + : clamp01((value - lo) / (hi - lo)) +} + +export const formatScalar = (value: number, unit?: string): string => + unit !== undefined && unit !== "" ? formatValueByUnit(value, unit) : formatNumber(value) + +/** `roundTick`: integers when the range is integral, three significant figures otherwise. */ +export const roundTick = (value: number, min: number, span: number): number => { + if (Number.isInteger(min) && Number.isInteger(min + span)) return Math.round(value) + if (value === 0) return 0 + const magnitude = Math.floor(Math.log10(Math.abs(value))) + const factor = 10 ** (2 - magnitude) + return Math.round(value * factor) / factor +} + +export const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/ + +/** `clockLabel`: `HH:MM` of an ISO timestamp in `timeZone`. */ +const clockLabel = (iso: string, timeZone: string | undefined): string => + Option.match(DateTime.make(iso), { + onNone: () => iso, + onSome: (date) => + new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hour12: false }) + .format(DateTime.toEpochMillis(date)) + .replace(/^24:/, "00:"), + }) + +export const shortenYLabel = (raw: string, allIso: boolean, timeZone: string | undefined): string => + allIso ? clockLabel(raw, timeZone) : raw + +export const truncateYLabel = (label: string, maxChars: number): string => + label.length <= maxChars ? label : `${label.slice(0, Math.max(1, maxChars - 1))}…` diff --git a/apps/web-foldkit/src/charts/histogram-chart.ts b/apps/web-foldkit/src/charts/histogram-chart.ts new file mode 100644 index 0000000000..d1b7a5d7a1 --- /dev/null +++ b/apps/web-foldkit/src/charts/histogram-chart.ts @@ -0,0 +1,173 @@ +import { EMPTY_VALUE, formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Message, Model } from "./chart" +import { type CategoricalBin, ChartSpec, ChartTooltip, type NumericBin } from "./chart-spec" +import { formatBound } from "./histogram-format" +import { plotFrame } from "./plot-frame" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// Port of `QueryBuilderHistogramChart` (packages/ui/src/components/charts/histogram/ +// query-builder-histogram-chart.tsx), view half. Definitions are in ./definitions/histogram.ts. + +export interface HistogramChartInputs { + readonly chartId: string + readonly data: ReadonlyArray> + readonly theme: ThemeColors + readonly className?: string + readonly tooltip?: "visible" | "hidden" + readonly unit?: string + readonly logScale?: boolean + readonly logScaleY?: boolean + readonly bucketCount?: number + readonly bucketWidth?: number +} + +const DEFAULT_BIN_COUNT = 30 +export const HISTOGRAM_BAR_TOKEN = "--chart-1" + +const asFiniteNumber = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +const isPrebucketed = (rows: ReadonlyArray>): boolean => + rows.length > 0 && + rows.every((row) => typeof row.name === "string" && typeof row.value === "number") && + rows.length <= 200 + +const pickValueField = (rows: ReadonlyArray>): string => { + const first = rows[0] + if (first === undefined) return "value" + return ( + Object.keys(first).find( + (key) => key !== "name" && key !== "bucket" && typeof first[key] === "number", + ) ?? "value" + ) +} + +/** The `numeric` memo: raw observations into `binCount` equal-width bins. */ +const numericBins = ( + source: ReadonlyArray>, + binCount: number, + binWidth: number | undefined, +): ReadonlyArray => { + const valueField = pickValueField(source) + const values = source + .map((row) => row[valueField]) + .filter((value): value is number => typeof value === "number" && Number.isFinite(value)) + if (values.length === 0) return [] + const min = Math.min(...values) + const max = Math.max(...values) + const span = max - min + const width = binWidth ?? (span > 0 ? span / binCount : 1) + const safeWidth = width > 0 ? width : 1 + const counts = new Map() + for (const value of values) { + const index = Math.min(Math.floor((value - min) / safeWidth), binCount - 1) + counts.set(index, (counts.get(index) ?? 0) + 1) + } + return Array.from({ length: binCount }, (_, index) => { + const lower = min + index * safeWidth + return { x1: lower, x2: lower + safeWidth, value: counts.get(index) ?? 0 } + }) +} + +/** `CountTooltipBody`. Shared with the histogram spike, whose body is the same markup. */ +export const countTooltipBody = ( + h: HtmlBuilder, + heading: string, + count: number, + color: string, + portal: Attribute, +): Html => + h.div( + [h.Class("grid min-w-[9rem] items-start gap-1.5"), portal], + [ + h.div( + [h.Class("border-border/50 border-b pb-1 font-medium text-muted-foreground tracking-tight")], + [heading], + ), + h.div( + [h.Class("flex w-full items-center gap-2")], + [ + h.span([h.Class("size-2.5 shrink-0 rounded-xs"), h.Style({ "background-color": color })]), + h.div( + [h.Class("flex flex-1 items-center justify-between gap-3 leading-none")], + [ + h.span([h.Class("text-muted-foreground")], ["Count"]), + h.span( + [h.Class("font-mono font-semibold text-foreground tabular-nums")], + [formatNumber(count)], + ), + ], + ), + ], + ), + ], + ) + +const histogramTooltipBody = ( + h: HtmlBuilder, + tooltip: ChartTooltip, + color: string, + unit: string | undefined, + portal: Attribute, +): Option.Option => + ChartTooltip.match(tooltip, { + NumericBin: (bin) => + Option.some( + countTooltipBody( + h, + `${formatBound(bin.x1, unit ?? null)} – ${formatBound(bin.x2, unit ?? null)}`, + bin.value, + color, + portal, + ), + ), + CategoricalBin: (bin) => Option.some(countTooltipBody(h, bin.name, bin.value, color, portal)), + HeatmapSlot: () => Option.none(), + PieSlice: () => Option.none(), + Bucket: () => Option.none(), + }) + +export const view = Submodel.defineView((model, inputs, h) => { + const source = inputs.data + const binCount = inputs.bucketCount ?? DEFAULT_BIN_COUNT + const useLogY = inputs.logScaleY ?? inputs.logScale ?? false + const showTooltip = inputs.tooltip !== "hidden" + const color = resolveColor(inputs.theme, HISTOGRAM_BAR_TOKEN) + const frame = (spec: ChartSpec) => + plotFrame(h, { + chartId: inputs.chartId, + className: inputs.className, + ariaLabel: "Value distribution", + spec, + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + histogramTooltipBody(h, tooltip, color, inputs.unit, portal), + ), + }) + + if (isPrebucketed(source)) { + const bins: ReadonlyArray = source.map((row) => ({ + name: String(row.name ?? EMPTY_VALUE), + value: asFiniteNumber(row.value), + })) + return frame(ChartSpec.CategoricalHistogram({ bins, color, useLogY, showTooltip })) + } + + const bins = numericBins(source, binCount, inputs.bucketWidth) + if (bins.length > 0) { + return frame( + ChartSpec.NumericHistogram({ bins, color, unit: inputs.unit ?? null, useLogY, showTooltip }), + ) + } + + return h.div( + [h.Class(cn("relative grid h-full w-full place-items-center", inputs.className))], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ) +}) diff --git a/apps/web-foldkit/src/charts/histogram-format.ts b/apps/web-foldkit/src/charts/histogram-format.ts new file mode 100644 index 0000000000..061f0b165d --- /dev/null +++ b/apps/web-foldkit/src/charts/histogram-format.ts @@ -0,0 +1,28 @@ +import { formatNumber, formatValueByUnit } from "@maple/ui/lib/format" + +// Copied from packages/ui/src/components/charts/histogram/query-builder-histogram-chart.tsx (a React +// module). Shared by the definition (tick labels) and the view (tooltip heading). + +/** Index of the dash separating a bin label's two bounds, or -1 ("-50--20" -> 3). */ +const splitBoundsAt = (name: string): number => { + for (let index = 1; index < name.length; index += 1) { + if (name[index] !== "-") continue + const previous = name[index - 1] + if (previous === "-" || previous === "e" || previous === "E") continue + return index + } + return -1 +} + +/** The lower bound of a bin label, for an axis tick that has room for one number. */ +export const binLowerBoundLabel = (name: string): string => { + const separator = splitBoundsAt(name) + return separator === -1 ? name : name.slice(0, separator) +} + +export const formatBound = (value: number, unit: string | null): string => + unit?.startsWith("duration_") ? formatValueByUnit(value, unit) : formatNumber(value) + +/** `formatInterval` of the histogram spike. */ +export const formatInterval = (from: number, to: number): string => + `${formatNumber(from)} – ${formatNumber(to)}` diff --git a/apps/web-foldkit/src/charts/infra-metric-chart.ts b/apps/web-foldkit/src/charts/infra-metric-chart.ts new file mode 100644 index 0000000000..53b3d9a215 --- /dev/null +++ b/apps/web-foldkit/src/charts/infra-metric-chart.ts @@ -0,0 +1,204 @@ +import { validateCssColor } from "@maple/ui/lib/sanitizers" +import { refreshingClass } from "@maple/ui/lib/refreshing" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { chartEmpty } from "../ui/chart-state" +import { bucketTooltipBody } from "./bucket-tooltip" +import type { Message, Model } from "./chart" +import type { ChartSpec } from "./chart-spec" +import { asChartUnit, infraPoints, labelFor } from "./definitions/infra-metric" +import type { ChartEventMarker } from "./event-marker-spec" +import { type ChartUnit, hasPlottableValue, type LongRow, seriesColorTokens } from "./infra-metric-series" +import { linkedCursorOverlay } from "./linked-cursor" +import { plotFrame } from "./plot-frame" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// `InfraMetricChart` (apps/web/src/components/infra/primitives/infra-metric-chart.tsx): the spec +// with every colour resolved, the header's series info, the linked-cursor marker and overlay, and the +// `CursorPlot` box. Embed `view` (a built spec) or `metricView` (rows) with `h.submodel`. + +export const INFRA_METRIC_CHART_HEIGHT = 220 +/** `CHART_EMPTY_MESSAGE` (components/common/chart-card.tsx). */ +export const CHART_EMPTY_MESSAGE = "No data for this metric in the selected window." + +export type InfraMetricSpec = Extract + +/** + * `canvasSafeThresholdColor` (plot/threshold-rules.ts): a bare `--token` is not a CSS colour, so the + * 80% rule legacy asks to paint `--severity-warn` falls back to `--destructive`. + */ +const canvasSafeThresholdColor = (theme: ThemeColors, color: string): string => { + const fallback = resolveColor(theme, "--destructive") + const validated = validateCssColor(color) + if (validated === null) return fallback + const resolved = resolveColor(theme, validated, fallback) + return /var\(|currentcolor/i.test(resolved) ? fallback : resolved +} + +export type InfraMetricOptions = Readonly<{ + rows: ReadonlyArray + unit: ChartUnit + seriesLabel?: string + stacked?: boolean + showThreshold?: boolean + xDomain?: ReadonlyArray + gaps?: boolean + /** Fixed colours (tokens or literals) for series the name hash cannot see. */ + seriesColors?: Readonly> + theme: ThemeColors + timeZone: string + gradientPrefix: string + /** Deploys, drawn on every strip at the same instant. */ + markers?: ReadonlyArray +}> + +export type InfraSeriesInfo = Readonly<{ + series: ReadonlyArray + colors: ReadonlyMap + lastValues: Readonly> + labelFor: (name: string) => string + unit: ChartUnit +}> + +/** The spec the Mount builds the definition from, and the header's series info, as legacy derives them. */ +export const infraMetricModel = (options: InfraMetricOptions) => { + const points = infraPoints({ + rows: options.rows, + gaps: options.gaps ?? false, + xDomain: options.xDomain ?? null, + }) + const tokens = seriesColorTokens(points.series) + const colors = new Map( + points.series.map((name) => [ + name, + resolveColor(options.theme, options.seriesColors?.[name] ?? tokens.get(name) ?? "--border"), + ]), + ) + const latest = points.data[points.data.length - 1] + const lastValues = Object.fromEntries( + points.series.flatMap((name) => { + const value = latest?.[name] + return typeof value === "number" ? [[name, value]] : [] + }), + ) + const spec: InfraMetricSpec = { + _tag: "InfraMetric", + rows: options.rows, + unit: options.unit, + seriesLabel: options.seriesLabel ?? null, + stacked: options.stacked ?? false, + showThreshold: options.showThreshold ?? false, + xDomain: options.xDomain ?? null, + gaps: options.gaps ?? false, + colors: Object.fromEntries(colors), + chrome: { + border: resolveColor(options.theme, "--border"), + background: resolveColor(options.theme, "--background"), + }, + thresholdColor: canvasSafeThresholdColor(options.theme, "--severity-warn"), + gradientPrefix: options.gradientPrefix, + timeZone: options.timeZone, + markers: options.markers ?? [], + markerColors: { + neutral: resolveColor(options.theme, "--muted-foreground"), + warn: resolveColor(options.theme, "--severity-warn"), + crit: resolveColor(options.theme, "--severity-error"), + label: resolveColor(options.theme, "--muted-foreground"), + }, + } + const info: InfraSeriesInfo = { + series: points.series, + colors, + lastValues, + labelFor: labelFor(options.seriesLabel ?? null), + unit: options.unit, + } + return { spec, info } +} + +export const infraMetricSpec = (options: InfraMetricOptions): InfraMetricSpec => + infraMetricModel(options).spec + +export interface InfraMetricChartInputs { + readonly chartId: string + readonly spec: InfraMetricSpec + readonly linkedChartId?: string + readonly isWaiting?: boolean + /** The plot's height, and the empty state's (else it fills its plot area). */ + readonly height?: number + /** The surrounding `ChartPlotArea`'s height (`useChartPlotHeight`). */ + readonly plotAreaHeight?: number + readonly className?: string + /** The last-value strip above the plot (`header`). */ + readonly header?: Html +} + +const chartBody = (h: HtmlBuilder, model: Model, inputs: InfraMetricChartInputs): Html => { + if (!hasPlottableValue(inputs.spec.rows)) { + return chartEmpty(h, { height: inputs.height }, [CHART_EMPTY_MESSAGE]) + } + const isWaiting = inputs.isWaiting === true + const linked = inputs.linkedChartId + return h.div( + [h.Class(cn(refreshingClass(isWaiting), inputs.className)), ...(isWaiting ? [h.AriaBusy(true)] : [])], + [ + ...(inputs.header === undefined ? [] : [inputs.header]), + h.div( + [ + h.Class("relative"), + ...(linked === undefined ? [] : [h.DataAttribute("linked-cursor-chart", linked)]), + ], + [ + ...(linked === undefined ? [] : [linkedCursorOverlay(h, linked)]), + h.div( + [ + h.Class("w-full"), + h.Style({ + height: `${inputs.height ?? inputs.plotAreaHeight ?? INFRA_METRIC_CHART_HEIGHT}px`, + }), + ], + [ + plotFrame(h, { + chartId: inputs.chartId, + spec: inputs.spec, + ariaLabel: inputs.spec.seriesLabel ?? "Utilization", + className: "h-full w-full", + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + tooltip._tag === "Bucket" + ? Option.some(bucketTooltipBody(h, tooltip, portal)) + : Option.none(), + ), + }), + ], + ), + ], + ), + ], + ) +} + +/** `InfraMetricChart` over a built spec. */ +export const view = Submodel.defineView((model, inputs, h) => + chartBody(h, model, inputs), +) + +export type MetricChartInputs = Readonly< + Omit & + Omit & { + unit: string + header?: (h: HtmlBuilder, info: InfraSeriesInfo) => Html + } +> + +/** `InfraMetricChart` over raw rows: the spec and the header's series info derived here. */ +export const metricView = Submodel.defineView((model, inputs, h) => { + const { spec, info } = infraMetricModel({ + ...inputs, + unit: asChartUnit(inputs.unit), + gradientPrefix: `infra-${inputs.chartId}`, + }) + return chartBody(h, model, { ...inputs, spec, header: inputs.header?.(h, info) }) +}) diff --git a/apps/web-foldkit/src/charts/infra-metric-series.ts b/apps/web-foldkit/src/charts/infra-metric-series.ts new file mode 100644 index 0000000000..e87e2f220b --- /dev/null +++ b/apps/web-foldkit/src/charts/infra-metric-series.ts @@ -0,0 +1,52 @@ +import { + transformRows as transformRowsWith, + type TransformedPoint, +} from "@maple/web/components/infra/chart-units" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" + +// What `InfraMetricChart`'s callers read besides `chart-units`: the long-form pivot, colours and +// the tokens they resolve. + +export { + type ChartUnit, + chartBucketSeconds, + formatValueWithUnit, + UNNAMED_SERIES_KEY, +} from "@maple/web/components/infra/chart-units" + +export type InfraPoint = TransformedPoint + +export type LongRow = Readonly<{ bucket: string; attributeValue: string; value: number }> + +/** `transformRows`: long-form rows pivoted into one point per bucket, keyed by series. */ +export const transformRows = (rows: ReadonlyArray) => transformRowsWith(rows, () => "") + +/** Every series' colour token: semantic names first, then the stable identity hash. */ +export const seriesColorTokens = (series: ReadonlyArray): ReadonlyMap => + resolveSeriesColors(series) + +/** Tokens `resolveSeriesColors` and the chart chrome can name, resolved once per theme by the page. */ +export const INFRA_CHART_TOKENS: ReadonlyArray = [ + "--border", + "--background", + "--destructive", + "--chart-1", + "--chart-p50", + "--chart-p95", + "--chart-p99", + "--severity-info", + "--severity-warn", + "--severity-error", + "--muted-foreground", + "--color-severity-trace", + "--color-severity-debug", + "--color-severity-info", + "--color-severity-warn", + "--color-severity-error", + "--color-severity-fatal", + "--color-muted-foreground", +] + +/** Buckets with no finite value for any series plot nothing, so they count as empty too. */ +export const hasPlottableValue = (rows: ReadonlyArray): boolean => + rows.some((row) => Number.isFinite(row.value)) diff --git a/apps/web-foldkit/src/charts/linked-cursor.ts b/apps/web-foldkit/src/charts/linked-cursor.ts new file mode 100644 index 0000000000..308f6cd0e7 --- /dev/null +++ b/apps/web-foldkit/src/charts/linked-cursor.ts @@ -0,0 +1,153 @@ +import { Effect, Queue, Stream } from "effect" +import { Mount } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" + +// `useLinkedCursor` / `LinkedCursorOverlay` (apps/web/src/hooks/use-linked-cursor.tsx): hovering one +// chart paints a cursor at the same x ratio on every sibling, through CSS variables written +// imperatively, never through state, exactly as legacy does. + +/** The container's resting cursor variables. */ +export const LINKED_CURSOR_STYLE = { "--linked-cursor-ratio": "0", "--linked-cursor-visible": "0" } + +const CHART_SELECTOR = "[data-linked-cursor-chart]" +const OVERLAY_SELECTOR = "[data-linked-cursor-overlay]" +const PLOT_SELECTOR = "[data-chart-plot], .recharts-cartesian-grid" +const TOOLTIP_UPDATE_INTERVAL_MS = 1000 / 30 + +export const LinkedCursorMessage = defineMessageUnion({ CompletedMountLinkedCursor: {} }) +export type LinkedCursorMessage = typeof LinkedCursorMessage.Type + +/** The hovered chart, the last tooltip-forwarding move, and whether overlays sit on their plots. */ +interface CursorState { + active: HTMLElement | null + lastTooltip: number + aligned: boolean +} + +const alignOverlays = (container: HTMLElement) => { + const placements = [...container.querySelectorAll(CHART_SELECTOR)].flatMap((chart) => { + const overlay = chart.querySelector(OVERLAY_SELECTOR) + const plot = chart.querySelector(PLOT_SELECTOR) + const host = overlay?.offsetParent + if (!overlay || !plot || !(host instanceof HTMLElement)) return [] + const plotBounds = plot.getBoundingClientRect() + if (plotBounds.width === 0) return [] + const hostBounds = host.getBoundingClientRect() + return [ + { + overlay, + left: plotBounds.left - hostBounds.left - host.clientLeft, + top: plotBounds.top - hostBounds.top - host.clientTop, + width: plotBounds.width, + height: plotBounds.height, + }, + ] + }) + placements.forEach(({ overlay, left, top, width, height }) => { + overlay.style.left = `${left}px` + overlay.style.top = `${top}px` + overlay.style.width = `${width}px` + overlay.style.height = `${height}px` + }) +} + +/** Pointer tracking on the container; emits `attached` once listening. A page with its own Message wraps this in its Mount. */ +export const linkedCursorStream =
(element: Element, attached: A): Stream.Stream => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + if (!(element instanceof HTMLElement)) return () => undefined + const container = element + // Mutable listener state lives only as long as this Mount. + const state: CursorState = { active: null, lastTooltip: 0, aligned: false } + const setSource = (next: HTMLElement | null) => { + if (state.active === next) return + state.active?.querySelector(OVERLAY_SELECTOR)?.removeAttribute("hidden") + state.active?.removeAttribute("data-linked-cursor-source") + state.active = next + next?.setAttribute("data-linked-cursor-source", "") + next?.querySelector(OVERLAY_SELECTOR)?.setAttribute("hidden", "") + } + const hide = () => { + container.style.setProperty("--linked-cursor-visible", "0") + setSource(null) + } + const onMouseMove = (event: MouseEvent) => { + const elapsed = event.timeStamp - state.lastTooltip + if (state.lastTooltip === 0 || elapsed >= TOOLTIP_UPDATE_INTERVAL_MS) { + state.lastTooltip = event.timeStamp + return + } + event.stopPropagation() + } + const onPointerMove = (event: PointerEvent) => { + const target = event.target + const chart = + target instanceof Element ? target.closest(CHART_SELECTOR) : null + const plot = chart?.querySelector(PLOT_SELECTOR) + const bounds = plot?.getBoundingClientRect() + const isInside = + chart !== null && + bounds !== undefined && + container.contains(chart) && + bounds.width !== 0 && + event.clientX >= bounds.left && + event.clientX <= bounds.right && + event.clientY >= bounds.top && + event.clientY <= bounds.bottom + if (!isInside || chart === null || bounds === undefined) { + state.aligned = false + hide() + return + } + if (!state.aligned) { + state.aligned = true + alignOverlays(container) + } + const ratio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)) + setSource(chart) + container.style.setProperty("--linked-cursor-ratio", String(ratio)) + container.style.setProperty("--linked-cursor-visible", "1") + } + const onPointerLeave = () => { + state.lastTooltip = 0 + state.aligned = false + hide() + } + container.addEventListener("mousemove", onMouseMove, true) + container.addEventListener("pointermove", onPointerMove) + container.addEventListener("pointerleave", onPointerLeave) + Queue.offerUnsafe(queue, attached) + return () => { + container.removeEventListener("mousemove", onMouseMove, true) + container.removeEventListener("pointermove", onPointerMove) + container.removeEventListener("pointerleave", onPointerLeave) + } + }), + (release) => Effect.sync(release), + ).pipe(Effect.flatMap(() => Effect.never)), + ) + +/** `linkedCursorStream` as a Mount resolving `CompletedMountLinkedCursor`. */ +export const MountLinkedCursor = Mount.defineStream("MountLinkedCursor", { + messages: [LinkedCursorMessage.CompletedMountLinkedCursor], + execute: ({ element }) => linkedCursorStream(element, LinkedCursorMessage.CompletedMountLinkedCursor()), +}) + +/** `LinkedCursorOverlay`: positioned and shown by `MountLinkedCursor`. */ +export const linkedCursorOverlay = (h: HtmlBuilder, chartId: string): Html => + h.div( + [ + h.AriaHidden(true), + h.Class("pointer-events-none absolute"), + h.DataAttribute("linked-cursor-overlay", chartId), + h.Style({ "container-type": "inline-size", opacity: "var(--linked-cursor-visible)" }), + ], + [ + h.div([ + h.Class("absolute inset-y-0 left-0 w-px bg-muted-foreground/45 will-change-transform"), + h.Style({ transform: "translateX(calc(var(--linked-cursor-ratio) * (100cqw - 1px)))" }), + ]), + ], + ) diff --git a/apps/web-foldkit/src/charts/paths-chart.ts b/apps/web-foldkit/src/charts/paths-chart.ts new file mode 100644 index 0000000000..14ca195882 --- /dev/null +++ b/apps/web-foldkit/src/charts/paths-chart.ts @@ -0,0 +1,224 @@ +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message, type Model } from "./chart" +import { MeasureChartContainer } from "./chart-mounts" +import { pathsTooltip } from "./paths-tooltip" +import { + labelOf, + layoutPaths, + NODE_W, + type PathLink, + type PathsLayout, + type PlacedNode, + ribbon, + toGraph, +} from "./paths-layout" + +// Port of `PathsChart` (packages/ui/src/components/charts/paths/paths-chart.tsx). The hovered node +// id is the chart Model's `activeKey`; the measured box is its container size. + +export interface PathsChartInputs { + readonly data: ReadonlyArray> + readonly className?: string | undefined + readonly direction?: "after" | "before" | undefined +} + +type H = HtmlBuilder + +const TWO_LINE_H = 26 +const HALO = { "paint-order": "stroke", stroke: "var(--card)", "stroke-width": "3", "stroke-linejoin": "round" } +const CHAR_W = 6.6 + +const attrs = (h: H, entries: Readonly>) => + Object.entries(entries).map(([name, value]) => h.Attribute(name, String(value))) + +const fillOpacity = (kind: PlacedNode["kind"], lit: boolean, dim: boolean): number => + dim ? 0.05 : kind === "event" ? (lit ? 0.65 : 0.2) : kind === "end" ? (lit ? 0.3 : 0.07) : lit ? 0.4 : 0.12 + +type Labels = Readonly<{ truncate: (label: string) => string; showCounts: boolean }> + +const columnHeaders = (h: H, graph: PathsLayout, reverse: boolean): ReadonlyArray => + graph.columns.map((column, index) => { + const last = index === graph.columns.length - 1 + const firstId = [...graph.nodes.keys()].find((id) => id.startsWith(`${column}:`)) ?? "" + const x = graph.nodes.get(firstId)?.x ?? 0 + const label = column === 0 ? "Anchor" : reverse ? `−${column}` : `Step ${column}` + const atRightEdge = reverse ? column === 0 : last + return h.text( + [ + ...attrs(h, { x: atRightEdge ? x + NODE_W : x, y: 10, "text-anchor": atRightEdge ? "end" : "start" }), + h.Class("fill-muted-foreground text-3xs font-medium uppercase tracking-wider"), + ], + [label], + ) + }) + +const ribbons = (h: H, graph: PathsLayout, isLit: (link: PathLink) => boolean, hover: string | null) => + graph.links.map((link) => { + const kind = graph.nodes.get(link.target)?.kind ?? "event" + const lit = isLit(link) + return h.path([ + h.Attribute("d", ribbon(link.x0, link.sy, link.x1, link.ty, link.h)), + h.Class( + cn( + "pointer-events-none", + kind === "event" && "fill-[var(--chart-2)]", + kind === "end" && "fill-foreground", + kind === "other" && "fill-foreground", + ), + ), + h.Style({ + "fill-opacity": String(fillOpacity(kind, lit, hover !== null && !lit)), + transition: "fill-opacity 140ms ease", + }), + ]) + }) + +const nodeGroup = ( + h: H, + node: PlacedNode, + reverse: boolean, + dim: boolean, + labels: Labels, +): Html => { + const labelLeft = reverse ? node.column !== 0 : node.column === 0 + const textX = labelLeft ? node.x + NODE_W + 6 : node.x - 6 + const showText = node.h >= 9 || node.kind !== "event" + const twoLine = node.h >= TWO_LINE_H + const countInline = labels.showCounts && !twoLine + const label = labels.truncate(labelOf(node)) + const hitLabelW = showText ? (label.length + (countInline ? 7 : 0)) * CHAR_W + 6 : 0 + return h.g( + [ + h.OnMouseEnter(Message.EnteredLegendItem({ key: node.id })), + h.Style({ opacity: dim ? "0.45" : "1", transition: "opacity 140ms ease" }), + h.DataAttribute("slot", "paths-node"), + ], + [ + h.rect([ + ...attrs(h, { x: node.x, y: node.y, width: NODE_W, height: node.h, rx: 2 }), + h.Class( + cn( + node.kind === "event" && "fill-[var(--chart-2)]", + node.kind === "end" && "fill-foreground/20", + node.kind === "other" && "fill-foreground/35", + ), + ), + ]), + h.rect([ + ...attrs(h, { + x: labelLeft ? node.x - 6 : node.x - 6 - hitLabelW, + y: node.y - 2, + width: NODE_W + 12 + hitLabelW, + height: Math.max(node.h, 12) + 4, + fill: "transparent", + }), + h.DataAttribute("slot", "paths-node-hit"), + ]), + ...(showText + ? [ + h.text( + [ + ...attrs(h, { + x: textX, + y: node.y + Math.min(node.h, 12) / 2 + 3.5, + "text-anchor": labelLeft ? "start" : "end", + }), + h.Class( + cn( + "text-2xs font-medium", + node.kind === "event" ? "fill-foreground" : "fill-muted-foreground", + ), + ), + h.Style(HALO), + ], + [ + label, + ...(countInline || twoLine + ? [ + " ", + h.tspan( + [ + ...attrs(h, twoLine ? { x: textX, dy: 13 } : {}), + h.Class("fill-muted-foreground text-3xs font-normal tabular-nums"), + ], + [formatNumber(node.count)], + ), + ] + : []), + ], + ), + ] + : []), + ], + ) +} + +export const view = Submodel.defineView((model, inputs, h) => { + const { nodes, links } = toGraph(inputs.data) + const { containerWidth: width, containerHeight: height } = model + const reverse = inputs.direction === "before" + const graph = width > 0 && height > 0 ? layoutPaths(nodes, links, width, height, reverse) : undefined + const anchor = nodes.find((node) => node.column === 0) + if (anchor === undefined || anchor.count <= 0) + return h.div( + [h.Class(cn("relative grid h-full w-full place-items-center", inputs.className))], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ) + const columnSpan = + graph !== undefined && graph.columns.length > 1 ? (width - NODE_W) / (graph.columns.length - 1) : width + const roomChars = Math.floor((columnSpan * 0.7 - 20) / CHAR_W) + const showCounts = roomChars >= 14 + const maxChars = Math.max(6, showCounts ? roomChars - 6 : roomChars) + const labels: Labels = { + showCounts, + truncate: (label) => (label.length > maxChars ? `${label.slice(0, maxChars - 1)}…` : label), + } + const hover = Option.getOrNull(model.activeKey) + const hoveredNode = hover === null ? undefined : graph?.nodes.get(hover) + const isLit = (link: PathLink) => hover !== null && (link.source === hover || link.target === hover) + return h.div( + [ + h.Class(cn("relative h-full w-full select-none", inputs.className)), + h.OnMount(MeasureChartContainer()), + h.OnMouseLeave(Message.LeftChart()), + h.DataAttribute("slot", "paths-chart"), + ], + [ + ...(graph === undefined + ? [] + : [ + h.svg( + [ + h.Attribute("viewBox", `0 0 ${width} ${height}`), + h.Class("block h-full w-full overflow-visible"), + ], + [ + ...columnHeaders(h, graph, reverse), + ...ribbons(h, graph, isLit, hover), + ...[...graph.nodes.values()].map((node) => + nodeGroup( + h, + node, + reverse, + hover !== null && + hover !== node.id && + !links.some( + (link) => + isLit(link) && (link.source === node.id || link.target === node.id), + ), + labels, + ), + ), + ], + ), + ]), + ...(hoveredNode !== undefined && graph !== undefined + ? [pathsTooltip(h, { node: hoveredNode, anchor, graph, links, width, height, reverse })] + : []), + ], + ) +}) diff --git a/apps/web-foldkit/src/charts/paths-layout.ts b/apps/web-foldkit/src/charts/paths-layout.ts new file mode 100644 index 0000000000..defc978fa6 --- /dev/null +++ b/apps/web-foldkit/src/charts/paths-layout.ts @@ -0,0 +1,149 @@ +import { asFiniteNumber } from "@maple/ui/components/charts/_shared/breakdown-rows" + +// The graph and layout of `PathsChart` (packages/ui/src/components/charts/paths/paths-chart.tsx): +// rows are hops, column 0 is the anchor, `''` ended and `$other` the folded remainder. + +const OTHER = "$other" +const ENDED = "" + +export type NodeKind = "event" | "other" | "end" + +export interface PathNode { + readonly id: string + readonly column: number + readonly name: string + readonly count: number + readonly kind: NodeKind +} + +export interface PathLink { + readonly source: string + readonly target: string + readonly count: number +} + +export type PlacedNode = PathNode & { readonly x: number; readonly y: number; readonly h: number } +export type PlacedLink = PathLink & { + readonly sy: number + readonly ty: number + readonly h: number + readonly x0: number + readonly x1: number +} + +export interface PathsLayout { + readonly nodes: ReadonlyMap + readonly links: ReadonlyArray + readonly columns: ReadonlyArray +} + +export const NODE_W = 10 +const NODE_GAP = 8 +const HEADER_H = 18 +const FOOTER_H = 4 + +const kindOf = (name: string): NodeKind => (name === ENDED ? "end" : name === OTHER ? "other" : "event") + +export const labelOf = (node: PathNode): string => + node.kind === "end" ? "Ended" : node.kind === "other" ? "Other" : node.name + +const KIND_RANK = { event: 0, other: 1, end: 2 } satisfies Record + +/** `toGraph`: rows into nodes and links. A node's count is what arrives; the anchor's what leaves. */ +export const toGraph = ( + source: ReadonlyArray>>, +): { nodes: ReadonlyArray; links: ReadonlyArray } => { + const nodes = new Map() + const links: Array = [] + const nodeId = (column: number, name: string) => `${column}:${name}` + const touch = (column: number, name: string, count: number) => { + const id = nodeId(column, name) + const node = nodes.get(id) + nodes.set(id, node === undefined ? { id, column, name, count, kind: kindOf(name) } : { ...node, count: node.count + count }) + } + source.forEach((row) => { + const hop = asFiniteNumber(row.hop) + const count = asFiniteNumber(row.count) + if (hop < 1 || count <= 0) return + const from = typeof row.fromNode === "string" ? row.fromNode : "" + const to = typeof row.toNode === "string" ? row.toNode : "" + if (hop === 1) touch(0, from, count) + touch(hop, to, count) + links.push({ source: nodeId(hop - 1, from), target: nodeId(hop, to), count }) + }) + return { nodes: [...nodes.values()], links } +} + +const placeNodes = ( + nodes: ReadonlyArray, + columns: ReadonlyArray, + columnX: (column: number) => number, + scale: number, +): Map => { + const placed = new Map() + columns.forEach((column) => { + const ordered = nodes + .filter((node) => node.column === column) + .toSorted( + (a, b) => + KIND_RANK[a.kind] - KIND_RANK[b.kind] || b.count - a.count || a.name.localeCompare(b.name), + ) + ordered.reduce((y, node) => { + const h = Math.max(2, node.count * scale) + placed.set(node.id, { ...node, x: columnX(column), y, h }) + return y + h + NODE_GAP + }, HEADER_H) + }) + return placed +} + +/** `layout`: columns spread across the width, nodes stacked by kind then count, ribbons offset. */ +export const layoutPaths = ( + nodes: ReadonlyArray, + links: ReadonlyArray, + width: number, + height: number, + reverse: boolean, +): PathsLayout => { + const columns = [...new Set(nodes.map((node) => node.column))].toSorted((a, b) => a - b) + const columnX = (column: number): number => { + const index = columns.indexOf(column) + const x = columns.length === 1 ? 0 : (index * (width - NODE_W)) / (columns.length - 1) + return reverse ? width - NODE_W - x : x + } + const inColumn = (column: number) => nodes.filter((node) => node.column === column) + const maxTotal = Math.max( + 1, + ...columns.map((column) => inColumn(column).reduce((acc, node) => acc + node.count, 0)), + ) + const maxNodes = Math.max(1, ...columns.map((column) => inColumn(column).length)) + const plotH = Math.max(0, height - HEADER_H - FOOTER_H - NODE_GAP * (maxNodes - 1)) + const scale = plotH / maxTotal + const placed = placeNodes(nodes, columns, columnX, scale) + const yOf = (id: string) => placed.get(id)?.y ?? 0 + const outOffset = new Map() + const inOffset = new Map() + const withSy = links + .toSorted((a, b) => yOf(a.source) - yOf(b.source) || yOf(a.target) - yOf(b.target)) + .map((link) => { + const h = link.count * scale + const sy = yOf(link.source) + (outOffset.get(link.source) ?? 0) + outOffset.set(link.source, (outOffset.get(link.source) ?? 0) + h) + return { ...link, sy, h } + }) + const laid = withSy + .toSorted((a, b) => yOf(a.target) - yOf(b.target) || yOf(a.source) - yOf(b.source)) + .map((link): PlacedLink => { + const ty = yOf(link.target) + (inOffset.get(link.target) ?? 0) + inOffset.set(link.target, (inOffset.get(link.target) ?? 0) + link.h) + const sx = placed.get(link.source)?.x ?? 0 + const tx = placed.get(link.target)?.x ?? 0 + return { ...link, ty, x0: reverse ? sx : sx + NODE_W, x1: reverse ? tx + NODE_W : tx } + }) + return { nodes: placed, links: laid, columns } +} + +export const ribbon =(x0: number, sy: number, x1: number, ty: number, h: number): string => { + const cx = (x0 + x1) / 2 + return `M${x0},${sy} C${cx},${sy} ${cx},${ty} ${x1},${ty} L${x1},${ty + h} C${cx},${ty + h} ${cx},${sy + h} ${x0},${sy + h} Z` +} diff --git a/apps/web-foldkit/src/charts/paths-tooltip.ts b/apps/web-foldkit/src/charts/paths-tooltip.ts new file mode 100644 index 0000000000..704ec04d78 --- /dev/null +++ b/apps/web-foldkit/src/charts/paths-tooltip.ts @@ -0,0 +1,130 @@ +import { formatPercent } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { badge } from "../ui/display" +import { eyebrow } from "../ui/eyebrow" +import { icon } from "../ui/icon" +import type { Message } from "./chart" +import { labelOf, NODE_W, type PathLink, type PathNode, type PathsLayout, type PlacedNode } from "./paths-layout" + +// The hover card of `PathsChart`: who reached the node, and the top three flows into it. + +const TIP_W = 250 + +type H = HtmlBuilder + +export type PathsTooltipInputs = Readonly<{ + node: PlacedNode + anchor: PathNode + graph: PathsLayout + links: ReadonlyArray + width: number + height: number + reverse: boolean +}> + +const cameFrom = (h: H, inputs: PathsTooltipInputs): ReadonlyArray => { + const { node, graph, links } = inputs + return [ + h.div([h.Class("-mx-2.5 my-1.5 h-px bg-border")]), + eyebrow(h, { as: "div", className: "mb-1" }, ["Came from"]), + ...links + .filter((link) => link.target === node.id) + .toSorted((a, b) => b.count - a.count) + .slice(0, 3) + .map((link) => { + const from = graph.nodes.get(link.source) + const share = node.count > 0 ? link.count / node.count : 0 + return h.div( + [h.Class("my-0.5")], + [ + h.div( + [h.Class("flex items-center gap-2 tabular-nums")], + [ + icon(h, "ArrowLeftIcon", { size: 10, className: "shrink-0 text-muted-foreground" }), + h.span( + [h.Class("min-w-0 flex-1 truncate text-foreground/90")], + [from === undefined ? "" : labelOf(from)], + ), + h.span([h.Class("text-muted-foreground")], [link.count.toLocaleString("en-US")]), + ], + ), + h.div( + [h.Class("relative mt-0.5 ml-4 h-1 rounded-sm bg-foreground/5")], + [ + h.div([ + h.Class("absolute inset-y-0 left-0 rounded-sm bg-[var(--chart-2)]/70"), + h.Style({ width: `${share * 100}%` }), + ]), + ], + ), + ], + ) + }), + ] +} + +export const pathsTooltip = (h: H, inputs: PathsTooltipInputs): Html => { + const { node, anchor, width, height, reverse } = inputs + return h.div( + [ + h.Class("pointer-events-none absolute z-10 rounded-lg border bg-popover px-2.5 py-2 text-2xs shadow-md"), + h.Style({ + left: `${Math.min(Math.max(0, node.x + NODE_W + 10), Math.max(0, width - TIP_W))}px`, + top: `${Math.min(node.y + 4, Math.max(0, height - 96))}px`, + width: `${TIP_W}px`, + }), + h.DataAttribute("slot", "paths-tooltip"), + ], + [ + h.div( + [h.Class("mb-1.5 flex min-w-0 items-center gap-1.5")], + [ + h.span([ + h.Class( + cn( + "size-2 shrink-0 rounded-xs", + node.kind === "event" && "bg-[var(--chart-2)]", + node.kind === "end" && "bg-foreground/20", + node.kind === "other" && "bg-foreground/35", + ), + ), + ]), + h.span([h.Class("truncate font-medium text-foreground")], [labelOf(node)]), + badge(h, { variant: "muted", size: "xs" }, [ + node.column === 0 ? "anchor" : reverse ? `${node.column} before` : `step ${node.column}`, + ]), + ], + ), + h.div( + [h.Class("mb-1.5 h-1.5 overflow-hidden rounded-sm bg-foreground/5")], + [ + h.div([ + h.Class("h-full rounded-sm bg-[var(--chart-2)]"), + h.Style({ width: `${Math.min(100, (node.count / anchor.count) * 100)}%` }), + ]), + ], + ), + h.div( + [h.Class("flex items-center justify-between gap-3 tabular-nums")], + [ + h.span( + [h.Class("flex shrink-0 items-center gap-1.5 whitespace-nowrap text-muted-foreground")], + [icon(h, "UserIcon", { size: 12 }), node.column === 0 ? "started here" : "reached"], + ), + h.span( + [h.Class("flex items-baseline gap-1.5 whitespace-nowrap")], + [ + h.span([h.Class("font-semibold text-foreground")], [node.count.toLocaleString("en-US")]), + h.span( + [h.Class("text-3xs whitespace-nowrap text-muted-foreground")], + [`${formatPercent(node.count / anchor.count)} of anchor`], + ), + ], + ), + ], + ), + ...(node.column > 0 ? cameFrom(h, inputs) : []), + ], + ) +} diff --git a/apps/web-foldkit/src/charts/pie-chart.ts b/apps/web-foldkit/src/charts/pie-chart.ts new file mode 100644 index 0000000000..dd1da536cf --- /dev/null +++ b/apps/web-foldkit/src/charts/pie-chart.ts @@ -0,0 +1,275 @@ +import { + bucketCategorical, + MAX_CATEGORICAL, + OTHER_COLOR, + OTHER_LABEL, + type CategoricalRow, +} from "@maple/ui/components/charts/_shared/bucket-series" +import { EMPTY_VALUE, formatNumber, formatPercent, formatValueByUnit } from "@maple/ui/lib/format" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Message, Model } from "./chart" +import { ChartSpec, ChartTooltip, type PieSliceRow } from "./chart-spec" +import { MeasureChartContainer } from "./chart-mounts" +import { plotFrame } from "./plot-frame" +import { legendRow, type PlotLegendSeries, statsLegend } from "./plot-legend" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// Port of `QueryBuilderPieChart` (packages/ui/src/components/charts/pie/query-builder-pie-chart.tsx), +// view half. The definition half is `buildPie` in ./definitions/pie.ts. + +export interface PieChartInputs { + readonly chartId: string + readonly data: ReadonlyArray> + readonly theme: ThemeColors + readonly className?: string + readonly legend?: "visible" | "hidden" | "right" + readonly tooltip?: "visible" | "hidden" + readonly unit?: string + readonly donut?: boolean + readonly innerRadius?: number + readonly showLabels?: boolean + readonly showPercent?: boolean +} + +const TABLE_LEGEND_RATIO = 0.45 +const TABLE_FIXED_W = 116 +const TABLE_LEGEND_MIN_W = TABLE_FIXED_W + 52 +const TABLE_LEGEND_MAX_W = 240 +const TABLE_MIN_PIE_W = 120 +const CHIP_STRIP_CLASS = "max-h-[50px] justify-center overflow-hidden" +const SLICE_FALLBACK = "#a1a1aa" +const LEGEND_LABEL = "Share by category" + +const asFiniteNumber = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +const pickValueField = (rows: ReadonlyArray>): string => { + const first = rows[0] + if (first === undefined) return "value" + return Object.keys(first).find((key) => key !== "name" && typeof first[key] === "number") ?? "value" +} + +const fmtValue = (value: number, unit: string | undefined): string => + unit ? formatValueByUnit(value, unit) : formatNumber(value) + +const clamp = (value: number, lo: number, hi: number): number => + Number.isFinite(value) ? Math.max(lo, Math.min(hi, value)) : lo + +/** The `rows`/`colorTokens`/`total` memo: parsed, positive, long tail bucketed into "Other". */ +const pieRowsOf = (source: ReadonlyArray>) => { + const valueField = pickValueField(source) + const parsed: CategoricalRow[] = source + .map((row) => { + const raw = row.name == null ? "" : String(row.name).trim() + return { name: raw === "" ? "(no value)" : raw, value: asFiniteNumber(row[valueField]) } + }) + .filter((row) => row.value > 0) + const rows = bucketCategorical(parsed, MAX_CATEGORICAL) + const total = rows.reduce((sum, row) => sum + row.value, 0) + const byName = resolveSeriesColors(rows.map((row) => row.name).filter((name) => name !== OTHER_LABEL)) + const colorTokens = new Map( + rows.map((row) => [ + row.name, + row.name === OTHER_LABEL ? OTHER_COLOR : (byName.get(row.name) ?? OTHER_COLOR), + ]), + ) + return { rows, colorTokens, total } +} + +/** Every token the pie reads, for `ResolveThemeColors`. */ +export const pieChartTokens = (data: ReadonlyArray>): ReadonlyArray => [ + "--background", + ...pieRowsOf(data).colorTokens.values(), +] + +const tooltipBody = ( + h: HtmlBuilder, + tooltip: ChartTooltip, + unit: string | undefined, + portal: Attribute, +): Option.Option => + ChartTooltip.match(tooltip, { + PieSlice: (slice) => + Option.some( + h.div( + [h.Class("whitespace-nowrap text-2xs"), portal], + [ + h.div( + [h.Class("flex items-center gap-1.5 font-medium text-foreground")], + [ + h.span([ + h.Class("size-2 rounded-xs"), + h.Style({ "background-color": slice.color }), + ]), + h.span([], [slice.name]), + ...(slice.collapsedCount + ? [ + h.span( + [h.Class("font-normal text-muted-foreground")], + [`(${slice.collapsedCount} categories)`], + ), + ] + : []), + ], + ), + h.div( + [h.Class("mt-0.5 text-muted-foreground tabular-nums")], + [ + h.span([h.Class("text-foreground/90")], [fmtValue(slice.value, unit)]), + h.span([h.Class("px-1 text-muted-foreground/60")], ["·"]), + h.span([], [formatPercent(slice.fraction)]), + ], + ), + ], + ), + ), + NumericBin: () => Option.none(), + CategoricalBin: () => Option.none(), + HeatmapSlot: () => Option.none(), + Bucket: () => Option.none(), + }) + +const PLOT_INSET = 8 +const CENTER_TOTAL_MIN_INNER_R = 18 + +/** `resolveInnerRadius` for a donut (definitions/pie.ts, kept out of the view's imports). */ +const holeRadiusOf = (outerRadius: number, innerRadius: number | undefined): number => + Math.max(8, Math.min(outerRadius - 6, innerRadius ?? outerRadius * 0.58)) + +/** `fmtCenterTotal`: small integers spelled out, the rest formatted like the slices. */ +const fmtCenterTotal = (value: number, unit: string | undefined): string => + (unit === undefined || unit === "") && Number.isInteger(value) && Math.abs(value) < 10_000 + ? value.toLocaleString("en-US") + : fmtValue(value, unit) + +/** `DonutCenterTotal`: the total in the hole once the plot rect is known and the hole holds it. */ +const donutCenterTotal = ( + h: HtmlBuilder, + model: Model, + total: number, + unit: string | undefined, + innerRadius: number | undefined, +): Html => + Option.match(model.plot, { + onNone: () => h.empty, + onSome: (rect) => { + const holeRadius = holeRadiusOf(Math.min(rect.width, rect.height) / 2 - PLOT_INSET, innerRadius) + if (holeRadius <= CENTER_TOTAL_MIN_INNER_R) return h.empty + return h.div( + [ + h.Class("absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center leading-none"), + h.Style({ left: `${rect.x + rect.width / 2}px`, top: `${rect.y + rect.height / 2}px` }), + ], + [ + h.span( + [ + h.Class("font-semibold text-foreground tabular-nums"), + h.Style({ "font-size": `${Math.min(22, holeRadius * 0.6)}px`, "letter-spacing": "-0.02em" }), + ], + [fmtCenterTotal(total, unit)], + ), + h.span( + [ + h.Class("pt-1 text-muted-foreground uppercase"), + h.Style({ "font-size": `${Math.min(10, holeRadius * 0.28)}px`, "letter-spacing": "0.04em" }), + ], + ["total"], + ), + ], + ) + }, + }) + +export const view = Submodel.defineView((model, inputs, h) => { + const { rows, colorTokens, total } = pieRowsOf(inputs.data) + const { unit } = inputs + const background = resolveColor(inputs.theme, "--background") + const pieRows: ReadonlyArray = rows.map((row) => ({ + ...row, + color: resolveColor(inputs.theme, colorTokens.get(row.name) ?? OTHER_COLOR, SLICE_FALLBACK), + })) + + if (rows.length === 0 || total <= 0) { + return h.div( + [ + h.Class(cn("relative grid h-full w-full place-items-center", inputs.className)), + h.OnMount(MeasureChartContainer()), + ], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ) + } + + const showLegend = inputs.legend !== "hidden" + const tableLegendW = clamp( + Math.round(model.containerWidth * TABLE_LEGEND_RATIO), + TABLE_LEGEND_MIN_W, + TABLE_LEGEND_MAX_W, + ) + const tableLegend = + showLegend && inputs.legend === "right" && model.containerWidth - tableLegendW >= TABLE_MIN_PIE_W + const chipLegend = showLegend && !tableLegend + + const series: ReadonlyArray = pieRows.map((row) => ({ + key: row.name, + label: row.collapsedCount ? `${row.name} +${row.collapsedCount}` : row.name, + color: row.color, + // Figures only in the side table: the bottom chips are a colour key. + value: tableLegend ? fmtValue(row.value, unit) : undefined, + secondary: tableLegend ? (total > 0 ? formatPercent(row.value / total) : EMPTY_VALUE) : undefined, + })) + const legendState = { + series, + highlighted: model.highlightedKey, + active: model.activeKey, + label: LEGEND_LABEL, + } + + const statsTable = h.div( + [h.Style({ width: `${tableLegendW}px`, "max-width": "100%" })], + Option.toArray(statsLegend(h, legendState)), + ) + const legend = tableLegend + ? statsTable + : chipLegend + ? legendRow(h, legendState, CHIP_STRIP_CLASS) + : undefined + + return h.div( + [ + h.Class(cn("flex h-full w-full flex-col select-none", inputs.className)), + h.OnMount(MeasureChartContainer()), + ], + [ + plotFrame(h, { + chartId: inputs.chartId, + className: "min-h-12 min-w-0 flex-1", + ariaLabel: LEGEND_LABEL, + spec: ChartSpec.Pie({ + rows: pieRows, + activeName: Option.getOrNull(model.activeKey), + highlighted: Option.getOrNull(model.highlightedKey), + background, + donut: inputs.donut === true, + innerRadius: inputs.innerRadius ?? null, + showLabels: inputs.showLabels ?? false, + showPercent: inputs.showPercent ?? true, + unit: unit ?? null, + showTooltip: inputs.tooltip !== "hidden", + }), + legend, + legendPlacement: tableLegend ? "right" : "bottom", + ...(inputs.donut === true + ? { reportPlot: true, overlay: donutCenterTotal(h, model, total, unit, inputs.innerRadius) } + : undefined), + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => tooltipBody(h, tooltip, unit, portal)), + }), + ], + ) +}) diff --git a/apps/web-foldkit/src/charts/planetscale-metric-series.ts b/apps/web-foldkit/src/charts/planetscale-metric-series.ts new file mode 100644 index 0000000000..0f0ccbaef9 --- /dev/null +++ b/apps/web-foldkit/src/charts/planetscale-metric-series.ts @@ -0,0 +1,26 @@ +import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import type { ChartSpec } from "./chart-spec" + +// The labels and value formats of `PlanetScaleChart`, shared by its definition and its card. + +export type PlanetScaleMetric = Extract["metric"] + +export const METRIC_LABELS = { + connectionsAvg: "Active connections", + cpuMaxPercent: "CPU utilization (max)", + memMaxPercent: "Memory utilization (max)", + storageUsedPercent: "Storage used (max)", + replicaLagMaxSeconds: "Replica lag (max)", +} as const satisfies Record + +const formatLag = (seconds: number) => + seconds >= 1 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds * 1000)}ms` +const formatStoragePercent = (percent: number) => + `${percent >= 99.5 || percent < 10 ? percent.toFixed(1) : percent.toFixed(0)}%` + +export const formatMetricValue = (value: number, metric: PlanetScaleMetric): string => { + if (metric === "cpuMaxPercent" || metric === "memMaxPercent") return formatPercent(value / 100) + if (metric === "storageUsedPercent") return formatStoragePercent(value) + if (metric === "replicaLagMaxSeconds") return formatLag(value) + return formatNumber(value) +} diff --git a/apps/web-foldkit/src/charts/plot-box-chart.ts b/apps/web-foldkit/src/charts/plot-box-chart.ts new file mode 100644 index 0000000000..6fa340435e --- /dev/null +++ b/apps/web-foldkit/src/charts/plot-box-chart.ts @@ -0,0 +1,36 @@ +import { Option } from "effect" +import { Submodel } from "foldkit" +import { bucketTooltipBody } from "./bucket-tooltip" +import type { Message, Model } from "./chart" +import type { ChartSpec } from "./chart-spec" +import { plotFrame } from "./plot-frame" + +// `CursorPlot` with a `height`: the plot frame in a fixed-height box, the bucket tooltip as its body. +// Embed with `h.submodel`; the card around it is the page's. + +export interface PlotBoxChartInputs { + readonly chartId: string + readonly spec: ChartSpec + readonly ariaLabel: string + readonly height: number +} + +export const view = Submodel.defineView((model, inputs, h) => + h.div( + [h.Class("w-full"), h.Style({ height: `${inputs.height}px` })], + [ + plotFrame(h, { + chartId: inputs.chartId, + spec: inputs.spec, + ariaLabel: inputs.ariaLabel, + className: "h-full w-full", + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + tooltip._tag === "Bucket" + ? Option.some(bucketTooltipBody(h, tooltip, portal)) + : Option.none(), + ), + }), + ], + ), +) diff --git a/apps/web-foldkit/src/charts/plot-frame.ts b/apps/web-foldkit/src/charts/plot-frame.ts new file mode 100644 index 0000000000..369a11f8d7 --- /dev/null +++ b/apps/web-foldkit/src/charts/plot-frame.ts @@ -0,0 +1,113 @@ +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Message } from "./chart" +import { CHART_LABEL_ATTRIBUTE, CHART_SPEC_ATTRIBUTE, type ChartSpec, encodeChartSpec } from "./chart-spec" +import { type ChartRenderer, MountChart, PLOT_BOX_ATTRIBUTE, PortalTooltipBody } from "./chart-mounts" +// The tooltip shell theming, imported where legacy imports it: every chart goes through the frame. +import "@maple/ui/components/plot/plot-tooltip.css" + +// Port of `PlotFrame` (packages/ui/src/components/plot/plot-frame.tsx). The markup and class +// composition are the React component's; the chart itself is mounted by `MountChart`. + +export interface PlotFrameInputs { + readonly chartId: string + readonly spec: ChartSpec + readonly ariaLabel: string + readonly renderer?: ChartRenderer + readonly className?: string | undefined + readonly legend?: Html | undefined + readonly legendPlacement?: "bottom" | "right" + readonly footer?: Html + readonly overlay?: Html + /** The chart reports its plot geometry (`Message.RenderedPlot`) so the page can draw `overlay`. */ + readonly reportPlot?: boolean + /** The `/lab/charts` prototypes' host (`MountChart host`). */ + readonly host?: "lab" + /** + * `renderTooltipBody(...)` for the current tooltip datum, `None` while the tooltip is hidden. The + * body's root carries `portal`, the Mount that moves it into the library's tooltip shell. + */ + readonly tooltipBody: (portal: Attribute) => Option.Option +} + +/** Renders the tooltip body out of layout; its portal Mount moves it into the tooltip shell. */ +const tooltipHolder = (h: HtmlBuilder, inputs: PlotFrameInputs): Html => + h.div( + [h.Hidden(true), h.DataAttribute("chart-tooltip-holder", "")], + Option.toArray(inputs.tooltipBody(h.OnMount(PortalTooltipBody()))), + ) + +// NOTE: the plot box and the side-legend row are separate view functions so their identities differ; +// as inline same-tag divs they would patch into each other when the legend moves beside the plot. +const plotBox = ( + h: HtmlBuilder, + inputs: PlotFrameInputs, + renderer: ChartRenderer, + sideLegend: boolean, +): Html => + h.div( + [ + h.Class(cn("relative min-h-0 flex-1", sideLegend && "min-w-0")), + h.Attribute(PLOT_BOX_ATTRIBUTE, ""), + h.Attribute(CHART_SPEC_ATTRIBUTE, encodeChartSpec(inputs.spec)), + h.Attribute(CHART_LABEL_ATTRIBUTE, inputs.ariaLabel), + h.OnMount( + MountChart({ + chartId: inputs.chartId, + ariaLabel: inputs.ariaLabel, + renderer, + reportPlot: inputs.reportPlot ?? false, + ...(inputs.host === undefined ? undefined : { host: inputs.host }), + }), + ), + ], + [ + h.div( + [h.Class("ts-chart-host"), h.Style({ position: "relative", width: "100%" })], + [h.div([h.Class("ts-chart-surface"), h.Style({ width: "100%", height: "100%" })])], + ), + h.div([ + h.DataAttribute("chart-plot", ""), + h.AriaHidden(true), + h.Class("pointer-events-none absolute top-0 left-0"), + ]), + ...(inputs.overlay === undefined + ? [] + : [ + h.div( + [ + h.DataAttribute("chart-overlay", ""), + h.Class("pointer-events-none absolute inset-0"), + ], + [inputs.overlay], + ), + ]), + tooltipHolder(h, inputs), + ], + ) + +const sideLegendRow = (h: HtmlBuilder, plot: Html, legend: Html): Html => + h.div( + [h.DataAttribute("chart-legend-row", ""), h.Class("flex min-h-0 flex-1 flex-row gap-3")], + [plot, h.div([h.Class("max-w-[45%] shrink-0 overflow-auto")], [legend])], + ) + +export const plotFrame = (h: HtmlBuilder, inputs: PlotFrameInputs): Html => { + const renderer = inputs.renderer ?? "canvas" + const legendPlacement = inputs.legendPlacement ?? "bottom" + const sideLegend = legendPlacement === "right" && inputs.legend !== undefined + const hostClassName = cn("flex flex-col select-none", inputs.className) + const plot = plotBox(h, inputs, renderer, sideLegend) + + return h.div( + [h.DataAttribute("chart-host", renderer), h.Class(hostClassName)], + [ + sideLegend && inputs.legend !== undefined ? sideLegendRow(h, plot, inputs.legend) : plot, + ...(inputs.legend !== undefined && !sideLegend + ? [h.div([h.Class("max-h-[45%] shrink-0 overflow-auto")], [inputs.legend])] + : []), + ...(inputs.footer === undefined ? [] : [h.div([h.Class("shrink-0")], [inputs.footer])]), + ], + ) +} diff --git a/apps/web-foldkit/src/charts/plot-legend.ts b/apps/web-foldkit/src/charts/plot-legend.ts new file mode 100644 index 0000000000..1aeab10962 --- /dev/null +++ b/apps/web-foldkit/src/charts/plot-legend.ts @@ -0,0 +1,102 @@ +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message } from "./chart" + +// Port of `PlotLegend` and `PlotStatsLegend` (packages/ui/src/components/plot/plot-legend.tsx). +// Hover and click are Chart Messages; `active` and `highlighted` come from the chart Model. + +export interface PlotLegendSeries { + readonly key: string + readonly label: string + readonly color: string + readonly dashed?: boolean + readonly value?: string | undefined + readonly secondary?: string | undefined +} + +export interface PlotLegendState { + readonly series: ReadonlyArray + readonly highlighted: Option.Option + readonly active: Option.Option + readonly label: string +} + +const swatch = (h: HtmlBuilder, color: string, dashed: boolean | undefined): Html => + h.span([ + h.Class(cn("size-2 shrink-0 rounded-xs", dashed && "border border-dashed")), + h.Style(dashed ? { "border-color": color } : { "background-color": color }), + ]) + +const legendValue = (h: HtmlBuilder, value: string, secondary: string | undefined): Html => + h.span( + [h.Class("ml-auto flex shrink-0 items-baseline gap-2 font-mono tabular-nums")], + [ + h.span([h.Class("text-foreground")], [value]), + ...(secondary === undefined + ? [] + : [h.span([h.Class("w-11 text-right text-muted-foreground")], [secondary])]), + ], + ) + +const isKey = (maybeKey: Option.Option, key: string): boolean => + Option.isSome(maybeKey) && maybeKey.value === key + +/** `PlotLegendItem` without a hide toggle (no chart in the gallery passes `onToggle`). */ +const legendItem = (h: HtmlBuilder, state: PlotLegendState, entry: PlotLegendSeries): Html => { + const isHighlighted = isKey(state.highlighted, entry.key) + const hovering = Option.isSome(state.active) + const isActive = isKey(state.active, entry.key) + const isMuted = hovering ? !isActive : Option.isSome(state.highlighted) && !isHighlighted + return h.keyed("button")( + entry.key, + [ + h.Type("button"), + h.AriaPressed(String(isHighlighted)), + h.OnClick(Message.ClickedLegendItem({ key: entry.key })), + h.OnMouseEnter(Message.EnteredLegendItem({ key: entry.key })), + h.OnFocus(Message.EnteredLegendItem({ key: entry.key })), + h.Class( + cn( + "flex items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/50", + isMuted && "opacity-40", + (isActive || (!hovering && isHighlighted)) && "bg-muted/50", + ), + ), + ], + [ + swatch(h, entry.color, entry.dashed), + h.span([h.Class("truncate")], [entry.label]), + ...(entry.value === undefined ? [] : [legendValue(h, entry.value, entry.secondary)]), + ], + ) +} + +const items = (h: HtmlBuilder, state: PlotLegendState): ReadonlyArray => + state.series.map((entry) => legendItem(h, state, entry)) + +/** `PlotLegend.Row` + `Items`. */ +export const legendRow = (h: HtmlBuilder, state: PlotLegendState, className?: string): Html => + h.div( + [ + h.AriaLabel(state.label), + h.OnPointerLeave(() => Option.some(Message.LeftLegend())), + h.Class(cn("flex flex-wrap gap-x-3 gap-y-0.5 pt-2 text-xs select-none", className)), + ], + items(h, state), + ) + +/** `PlotLegend.Column` + `Items`. */ +export const legendColumn = (h: HtmlBuilder, state: PlotLegendState, className?: string): Html => + h.div( + [ + h.AriaLabel(state.label), + h.OnPointerLeave(() => Option.some(Message.LeftLegend())), + h.Class(cn("flex flex-col gap-0.5 pl-3 text-xs select-none", className)), + ], + items(h, state), + ) + +/** `PlotStatsLegend`: the column with value and share figures, or nothing for no series. */ +export const statsLegend = (h: HtmlBuilder, state: PlotLegendState): Option.Option => + state.series.length === 0 ? Option.none() : Option.some(legendColumn(h, state, "pl-0")) diff --git a/apps/web-foldkit/src/charts/sequential-colors.ts b/apps/web-foldkit/src/charts/sequential-colors.ts new file mode 100644 index 0000000000..3d7e6b7d10 --- /dev/null +++ b/apps/web-foldkit/src/charts/sequential-colors.ts @@ -0,0 +1,105 @@ +import { resolveSequentialDomain, type SequentialScaleType } from "./sequential-domain" + +// `createSequentialColorScale` (packages/ui/src/components/plot/color-scale.ts) without d3: the +// view needs the ramp's colours for legends, and d3-scale stays behind the chart Mount (S2). +// The OKLCH parsing and mixing are copied verbatim; d3's clamped sequential map is restated. + +interface Oklch { + readonly l: number + readonly c: number + readonly h: number +} + +const OKLCH_RE = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([-\d.]+)/i + +const parsePercentOrUnit = (raw: string, full: number): number => + raw.endsWith("%") ? (Number.parseFloat(raw) / 100) * full : Number.parseFloat(raw) + +const srgbToLinear = (channel: number): number => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + +const parseHexToOklch = (color: string): Oklch | null => { + if (!color.startsWith("#")) return null + const body = color.slice(1) + const expanded = + body.length === 3 ? `${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}` : body.slice(0, 6) + if (expanded.length !== 6) return null + const int = Number.parseInt(expanded, 16) + if (!Number.isFinite(int)) return null + const r = srgbToLinear(((int >> 16) & 0xff) / 255) + const g = srgbToLinear(((int >> 8) & 0xff) / 255) + const b = srgbToLinear((int & 0xff) / 255) + const lms0 = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const lms1 = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const lms2 = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const l = 0.2104542553 * lms0 + 0.793617785 * lms1 - 0.0040720468 * lms2 + const a = 1.9779984951 * lms0 - 2.428592205 * lms1 + 0.4505937099 * lms2 + const bb = 0.0259040371 * lms0 + 0.7827717662 * lms1 - 0.808675766 * lms2 + return { l, c: Math.hypot(a, bb), h: ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360 } +} + +const parseOklch = (color: string): Oklch | null => { + const oklch = OKLCH_RE.exec(color.trim()) + if (oklch === null) return parseHexToOklch(color.trim()) + const l = parsePercentOrUnit(oklch[1] ?? "", 1) + const c = parsePercentOrUnit(oklch[2] ?? "", 0.4) + const h = Number.parseFloat(oklch[3] ?? "") + return Number.isFinite(l) && Number.isFinite(c) && Number.isFinite(h) ? { l, c, h } : null +} + +const formatOklch = ({ l, c, h }: Oklch): string => `oklch(${l.toFixed(4)} ${c.toFixed(4)} ${h.toFixed(2)})` + +const mixOklch = (low: Oklch, high: Oklch, t: number): Oklch => { + const raw = high.h - low.h + const delta = raw > 180 ? raw - 360 : raw < -180 ? raw + 360 : raw + return { + l: low.l + (high.l - low.l) * t, + c: low.c + (high.c - low.c) * t, + h: (low.h + delta * t + 360) % 360, + } +} + +const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0) + +const colorAt = (t: number, stops: ReadonlyArray, parsed: ReadonlyArray): string => { + const clamped = clamp01(t) + const segments = stops.length - 1 + if (segments <= 0) return stops[0] ?? "currentColor" + const scaled = clamped * segments + const lowIndex = Math.min(segments - 1, Math.floor(scaled)) + const highIndex = lowIndex + 1 + const local = scaled - lowIndex + if (local <= 0) return stops[lowIndex] ?? "currentColor" + if (local >= 1) return stops[highIndex] ?? "currentColor" + const low = parsed[lowIndex] + const high = parsed[highIndex] + if (low === null || low === undefined || high === null || high === undefined) + return (local < 0.5 ? stops[lowIndex] : stops[highIndex]) ?? "currentColor" + return formatOklch(mixOklch(low, high, local)) +} + +/** `scaleSequential(…).clamp(true)` / `scaleSequentialLog(…).clamp(true)` as a plain function. */ +export const sequentialColorScale = (options: { + readonly stops: ReadonlyArray + readonly domain: readonly [number, number] + readonly scaleType: SequentialScaleType +}): ((value: number) => string) => { + const parsed = options.stops.map(parseOklch) + const [lo, hi] = resolveSequentialDomain(options.domain, options.scaleType) + const transform = options.scaleType === "log" ? Math.log : (value: number) => value + const t0 = transform(lo) + const t1 = transform(hi) + const k = t0 === t1 ? 0 : 1 / (t1 - t0) + return (value) => { + const raw = k === 0 ? 0.5 : (transform(value) - t0) * k + return colorAt(Math.max(0, Math.min(1, raw)), options.stops, parsed) + } +} + +/** `muteColor` (color-scale.ts): a colour pushed `amount` of the way toward the background. */ +export const muteColor = (color: string, background: string, amount: number): string => { + const from = parseOklch(color) + const to = parseOklch(background) + if (!from || !to) return color + return formatOklch(mixOklch(from, to, Math.max(0, Math.min(1, amount)))) +} diff --git a/apps/web-foldkit/src/charts/sequential-domain.ts b/apps/web-foldkit/src/charts/sequential-domain.ts new file mode 100644 index 0000000000..f13171bb10 --- /dev/null +++ b/apps/web-foldkit/src/charts/sequential-domain.ts @@ -0,0 +1,24 @@ +// `resolveSequentialDomain` (packages/ui/src/components/plot/color-scale.ts), copied so the view can +// label a ramp without importing that module's d3-scale. + +export type SequentialScaleType = "linear" | "log" + +const LOG_FALLBACK_FLOOR = 1 +const LOG_FALLBACK_DECADES = 2 + +/** The `[lo, hi]` a scale of this type walks: a log domain never touches 0 nor inverts. */ +export const resolveSequentialDomain = ( + domain: readonly [number, number], + scaleType: SequentialScaleType, +): [number, number] => { + const [min, max] = domain + if (scaleType !== "log") return [min, max] + if (!(max > 0)) return [LOG_FALLBACK_FLOOR, LOG_FALLBACK_FLOOR * 10] + const floor = + min > 0 + ? Math.min(min, max) + : max > LOG_FALLBACK_FLOOR + ? LOG_FALLBACK_FLOOR + : max / 10 ** LOG_FALLBACK_DECADES + return [floor, max] +} diff --git a/apps/web-foldkit/src/charts/sparkline.ts b/apps/web-foldkit/src/charts/sparkline.ts new file mode 100644 index 0000000000..d66f9738f1 --- /dev/null +++ b/apps/web-foldkit/src/charts/sparkline.ts @@ -0,0 +1,43 @@ +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Message, Model } from "./chart" +import { ChartSpec } from "./chart-spec" +import { plotFrame } from "./plot-frame" + +// Port of `Sparkline` (packages/ui/src/components/ui/gradient-chart.tsx) over `PlotSparkline`: no +// axes, focus or tooltip, so the chart Model only satisfies the frame. Embed it with `h.submodel`. + +export interface SparklineInputs { + readonly chartId: string + readonly values: ReadonlyArray + /** Already resolved to a literal (canvas cannot read `var()`). */ + readonly color: string + readonly className?: string + /** Changes with every fetched result, so the chart redraws like `useMemo` on new data. */ + readonly revision: number + /** `PlotSparkline`'s `curve` and `fillOpacity` (the stat widget's monotone, `[0.35, 0]`). */ + readonly curve?: "linear" | "monotone" + readonly fillOpacity?: readonly [start: number, end: number] + /** `PlotSparkline` frames an empty series too (the gradient `Sparkline` draws nothing). */ + readonly isFramedWhenEmpty?: boolean +} + +export const view = Submodel.defineView((_model, inputs, h) => + inputs.values.length === 0 && inputs.isFramedWhenEmpty !== true + ? h.div([h.Class(inputs.className ?? "")], []) + : plotFrame(h, { + chartId: inputs.chartId, + className: inputs.className, + ariaLabel: "Trend", + spec: ChartSpec.Sparkline({ + values: inputs.values, + color: inputs.color, + gradientId: `spark-${inputs.chartId}`, + fillStart: inputs.fillOpacity?.[0] ?? 0.5, + fillEnd: inputs.fillOpacity?.[1] ?? 0.1, + revision: inputs.revision, + curve: inputs.curve ?? "linear", + }), + tooltipBody: () => Option.none(), + }), +) diff --git a/apps/web-foldkit/src/charts/stacked-breakdown-chart.ts b/apps/web-foldkit/src/charts/stacked-breakdown-chart.ts new file mode 100644 index 0000000000..e91dc52ee7 --- /dev/null +++ b/apps/web-foldkit/src/charts/stacked-breakdown-chart.ts @@ -0,0 +1,95 @@ +import { BREAKDOWN_OTHER_KEY, BREAKDOWN_OTHER_LABEL } from "@maple/web/components/infra/cloudflare/constants" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" +import type { ChartSpec } from "./chart-spec" +import { type LongRow, transformRows } from "./infra-metric-series" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// `StackedBreakdownChart`'s data side: the tail fold, the stack order and the palette, resolved for +// the canvas. The card and legend are the page's. + +export type StackedBreakdownSpec = Extract + +/** `OTHER_COLOR` (packages/ui/src/components/charts/_shared/bucket-series.ts). */ +const OTHER_COLOR = "var(--muted-foreground)" +/** Series ceiling for a dimension with no fixed vocabulary. */ +const MAX_SERIES = 6 + +/** The pooled-tail sentinel is a wire value, never a label. */ +export const breakdownSeriesLabel = (name: string) => + name === BREAKDOWN_OTHER_KEY ? BREAKDOWN_OTHER_LABEL : name + +/** `foldTail`: cap an open vocabulary at `MAX_SERIES`, pooling the rest into one `other` per bucket. */ +const foldTail = (rows: ReadonlyArray): ReadonlyArray => { + const totals = rows.reduce( + (acc, row) => acc.set(row.attributeValue, (acc.get(row.attributeValue) ?? 0) + row.value), + new Map(), + ) + if (totals.size <= MAX_SERIES + 1) return rows + const keep = new Set( + [...totals.entries()] + .toSorted((a, b) => b[1] - a[1]) + .slice(0, MAX_SERIES) + .map(([name]) => name), + ) + const pooled = rows + .filter((row) => !keep.has(row.attributeValue)) + .reduce( + (acc, row) => acc.set(row.bucket, (acc.get(row.bucket) ?? 0) + row.value), + new Map(), + ) + return [ + ...rows.filter((row) => keep.has(row.attributeValue)), + ...[...pooled].map(([bucket, value]) => ({ bucket, attributeValue: BREAKDOWN_OTHER_KEY, value })), + ] +} + +export type StackedBreakdownOptions = Readonly<{ + rows: ReadonlyArray + /** Fixed colours (CSS values) for a closed vocabulary. */ + colors: Readonly> + /** Fixed stack order; unlisted series append after, alphabetically. */ + order: ReadonlyArray + theme: ThemeColors + timeZone: string + gradientPrefix: string +}> + +export type StackedBreakdown = Readonly<{ + spec: StackedBreakdownSpec + /** Series in stack order with their unresolved colour, for the card's legend. */ + legend: ReadonlyArray> + isEmpty: boolean +}> + +export const stackedBreakdown = (options: StackedBreakdownOptions): StackedBreakdown => { + const bounded = options.order.length > 0 ? options.rows : foldTail(options.rows) + const transformed = transformRows(bounded) + const rank = new Map(options.order.map((name, index) => [name, index])) + const rankOf = (name: string) => + name === BREAKDOWN_OTHER_KEY ? Number.MAX_SAFE_INTEGER : (rank.get(name) ?? options.order.length) + const series = [...transformed.series].toSorted((a, b) => rankOf(a) - rankOf(b) || a.localeCompare(b)) + const identityColors = resolveSeriesColors( + series.filter((name) => options.colors[name] === undefined && name !== BREAKDOWN_OTHER_KEY), + ) + const paletteOf = (name: string) => + options.colors[name] ?? + (name === BREAKDOWN_OTHER_KEY ? OTHER_COLOR : (identityColors.get(name) ?? OTHER_COLOR)) + const legend = series.map((name) => ({ name, label: breakdownSeriesLabel(name), color: paletteOf(name) })) + return { + legend, + isEmpty: transformed.data.length === 0, + spec: { + _tag: "StackedBreakdown", + rows: bounded, + series, + labels: Object.fromEntries(legend.map(({ name, label }) => [name, label])), + colors: Object.fromEntries([ + ...legend.map(({ name, color }) => [name, resolveColor(options.theme, color)] as const), + ["border", resolveColor(options.theme, "--border")], + ["background", resolveColor(options.theme, "--background")], + ]), + timeZone: options.timeZone, + gradientPrefix: options.gradientPrefix, + }, + } +} diff --git a/apps/web-foldkit/src/charts/theme-colors.ts b/apps/web-foldkit/src/charts/theme-colors.ts new file mode 100644 index 0000000000..a8c21fca7d --- /dev/null +++ b/apps/web-foldkit/src/charts/theme-colors.ts @@ -0,0 +1,74 @@ +import { tokenFallback } from "@maple/ui/lib/colors" +import { Effect, Queue, Schema, Stream } from "effect" +import { Mount } from "foldkit" +import { defineMessageUnion } from "foldkit/message" + +/** + * Design tokens resolved to literal colours, keyed by custom property name (`--chart-1`). + * Canvas cannot read `var()`, so charts take literals; reading them is DOM work, done by a Mount. + */ +export const ThemeColors = Schema.Record(Schema.String, Schema.String) +export type ThemeColors = typeof ThemeColors.Type + +export const ThemeMessage = defineMessageUnion({ + ResolvedThemeColors: { colors: ThemeColors }, +}) +export type ThemeMessage = typeof ThemeMessage.Type + +/** The custom property a token names, or `undefined` for a literal colour (`resolvePlotColor`'s unwrap). */ +export const tokenProperty = (token: string): string | undefined => { + const inner = token.startsWith("var(") && token.endsWith(")") ? token.slice(4, -1).trim() : token + const name = inner.includes(",") ? token : inner + return name.startsWith("--") ? name : undefined +} + +/** `resolvePlotColor` (packages/ui/src/components/plot/theme.ts) against a resolved snapshot. */ +export const resolveColor = ( + colors: ThemeColors, + token: string, + fallback: string = tokenFallback(token), +): string => { + const property = tokenProperty(token) + if (property === undefined) return token + const value = colors[property] + return value === undefined || value === "" ? fallback : value +} + +const readTokens = (element: Element, tokens: ReadonlyArray): ThemeColors => { + const style = getComputedStyle(element) + return Object.fromEntries( + tokens.flatMap((token) => { + const property = tokenProperty(token) + return property === undefined ? [] : [[property, style.getPropertyValue(property).trim()]] + }), + ) +} + +/** + * Reads `tokens` off the element (custom properties inherit from ``) now and again on every + * light/dark flip of the `` class, like `usePlotColors` re-resolving on `useTheme`. + */ +export const ResolveThemeColors = Mount.defineStream("ResolveThemeColors", { + args: { tokens: Schema.Array(Schema.String) }, + messages: [ThemeMessage.ResolvedThemeColors], + execute: ({ element, tokens }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const emit = () => + Queue.offerUnsafe( + queue, + ThemeMessage.ResolvedThemeColors({ colors: readTokens(element, tokens) }), + ) + emit() + const observer = new MutationObserver(emit) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + return observer + }), + (observer) => Effect.sync(() => observer.disconnect()), + ).pipe(Effect.flatMap(() => Effect.never)), + ), +}) diff --git a/apps/web-foldkit/src/charts/timeseries-legend.ts b/apps/web-foldkit/src/charts/timeseries-legend.ts new file mode 100644 index 0000000000..8fa6ae26e4 --- /dev/null +++ b/apps/web-foldkit/src/charts/timeseries-legend.ts @@ -0,0 +1,233 @@ +import { findFirstPartialIndex } from "@maple/ui/components/plot/partial-buckets" +import { + computeSeriesStats, + isAllZeroStats, + type SeriesStats, + type SeriesStatsMap, + sortZeroSeriesLast, +} from "@maple/ui/components/plot/series-stats" +import { EMPTY_VALUE, formatValueByUnit } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Message, type Model } from "./chart" +import { bucketSecondsOf, normaliseRows, scaleRates, type SeriesDefinition } from "./timeseries" + +// `TimeseriesLegend` (packages/ui/src/components/plot/timeseries.tsx): the compact legend or the +// Min/Max/Mean/Last table, below or beside the plot; shared by the line, area and bar charts. + +export interface LegendSeries { + readonly key: string + readonly label: string + readonly color: string +} + +type LegendLayout = Readonly<{ isRight: boolean; maxHeight: number | null }> + +/** `heightStyle(maxHeight)`: a side legend is capped at the container's height. */ +const maxHeightStyle = (h: HtmlBuilder, layout: LegendLayout) => + layout.maxHeight === null ? [] : [h.Style({ "max-height": `${layout.maxHeight}px` })] + +/** `QueryBuilderLegend variant="compact"`: a row per series, click to hide it. */ +const compactLegend = ( + h: HtmlBuilder, + series: ReadonlyArray, + hidden: ReadonlyArray, + layout: LegendLayout, +): Html => { + const keys = series.map((entry) => entry.key) + return h.div( + [ + ...maxHeightStyle(h, layout), + h.Class( + cn( + "h-full overflow-auto text-xs", + layout.isRight ? "flex flex-col gap-0.5 pl-3" : "flex flex-wrap gap-x-3 gap-y-0.5 pt-2", + ), + ), + ], + series.map((entry) => + h.button( + [ + h.Key(entry.key), + h.Type("button"), + h.Class( + cn( + "hover:bg-muted/50 flex min-w-0 max-w-full items-center gap-1.5 rounded px-1 py-0.5 select-none", + hidden.includes(entry.key) && "opacity-40", + ), + ), + h.Title(entry.label), + h.OnClick(Message.ToggledLegendSeries({ key: entry.key, keys })), + ], + [ + h.span([ + h.Class("size-2 shrink-0 rounded-xs"), + h.Style({ "background-color": entry.color }), + ]), + h.span([h.Class("truncate")], [entry.label]), + ], + ), + ), + ) +} + +const STAT_COLUMNS: ReadonlyArray<{ label: string; field: keyof SeriesStats; className?: string }> = [ + { label: "Min", field: "min", className: "hidden @lg:table-cell" }, + { label: "Max", field: "max", className: "hidden @lg:table-cell" }, + { label: "Mean", field: "mean" }, + { label: "Last", field: "last" }, +] + +/** `QueryBuilderLegend variant="stats"`: swatch, label and four figures per series. */ +const statsTable = ( + h: HtmlBuilder, + series: ReadonlyArray, + stats: SeriesStatsMap, + hidden: ReadonlyArray, + unit: string | undefined, + layout: LegendLayout, +): Html => { + const keys = series.map((entry) => entry.key) + return h.div( + [ + ...maxHeightStyle(h, layout), + h.Class(cn("@container h-full overflow-auto text-xs", layout.isRight ? "pl-3" : "pt-2")), + ], + [ + h.table( + [h.Class("w-full table-fixed border-collapse")], + [ + h.thead( + [], + [ + h.tr( + [h.Class("text-muted-foreground")], + [ + h.th([h.Class("py-0.5 pr-3 text-left font-normal")], ["Series"]), + ...STAT_COLUMNS.map((column) => + h.th( + [ + h.Class( + cn( + "w-20 px-2 text-right font-normal last:pr-0", + column.className, + ), + ), + ], + [column.label], + ), + ), + ], + ), + ], + ), + h.tbody( + [], + series.map((entry) => { + const entryStats = stats[entry.key] ?? null + const allZero = isAllZeroStats(entryStats) + return h.keyed("tr")( + entry.key, + [ + h.OnClick(Message.ToggledLegendSeries({ key: entry.key, keys })), + h.Class( + cn( + "hover:bg-muted/50 cursor-pointer select-none", + hidden.includes(entry.key) && "opacity-40", + ), + ), + ], + [ + h.td( + [h.Class("max-w-0 py-0.5 pr-3")], + [ + h.span( + [h.Class("flex min-w-0 items-center gap-1.5")], + [ + h.span([ + h.Class("size-2 shrink-0 rounded-xs"), + h.Style({ "background-color": entry.color }), + ]), + h.span( + [ + h.Class( + cn( + "truncate", + allZero && "text-muted-foreground", + ), + ), + h.Title(entry.label), + ], + [entry.label], + ), + ], + ), + ], + ), + ...STAT_COLUMNS.map((column) => + entryStats === null || allZero + ? h.td( + [ + h.Class( + cn( + "px-2 text-right font-mono tabular-nums text-muted-foreground/60 last:pr-0", + column.className, + ), + ), + ], + column.field === "last" + ? [entryStats === null ? EMPTY_VALUE : "0"] + : [], + ) + : h.td( + [ + h.Class( + cn( + "px-2 text-right font-mono tabular-nums last:pr-0", + column.className, + ), + ), + ], + [formatValueByUnit(entryStats[column.field], unit)], + ), + ), + ], + ) + }), + ), + ], + ), + ], + ) +} + + +export type TimeseriesLegendInputs = Readonly<{ + series: ReadonlyArray + data: ReadonlyArray>> + definitions: ReadonlyArray + unit: string | undefined + nowMs: number + legend: "hidden" | "visible" | "right" | undefined + seriesStats: boolean +}> + +/** The legend `PlotFrame` gets: nothing when hidden (the tile header hoists it instead). */ +export const timeseriesLegend = (h: HtmlBuilder, model: Model, inputs: TimeseriesLegendInputs): Html => { + const { series } = inputs + if ((inputs.legend !== "visible" && inputs.legend !== "right") || series.length === 0) return h.empty + // Stats over the closed buckets only, so all-zero series sink to the bottom (`TimeseriesLegend`). + const keys = series.map((entry) => entry.key) + const normalised = normaliseRows(inputs.data, inputs.definitions) + const rows = scaleRates(normalised, keys, inputs.unit, bucketSecondsOf(normalised)) + const first = findFirstPartialIndex(rows, { now: inputs.nowMs }) + const stats = computeSeriesStats(first <= 0 ? rows : rows.slice(0, first), keys) + const sorted = sortZeroSeriesLast(series, stats) + const layout: LegendLayout = { + isRight: inputs.legend === "right", + maxHeight: inputs.legend === "right" && model.containerHeight > 0 ? model.containerHeight : null, + } + return inputs.seriesStats + ? statsTable(h, sorted, stats, model.hiddenKeys, inputs.unit, layout) + : compactLegend(h, sorted, model.hiddenKeys, layout) +} diff --git a/apps/web-foldkit/src/charts/timeseries-rows.ts b/apps/web-foldkit/src/charts/timeseries-rows.ts new file mode 100644 index 0000000000..1ba2f2d0fc --- /dev/null +++ b/apps/web-foldkit/src/charts/timeseries-rows.ts @@ -0,0 +1,26 @@ +// The row type and helpers of packages/ui's plot/timeseries.tsx, chart-library-free so views can +// read them. The axis context and x axis are @maple/ui's framework-free `plot/timeseries-axis`. + +export type { TimeseriesAxisContext } from "@maple/ui/components/plot/timeseries-axis" + +/** One normalised row: the bucket, its parsed date, and every series value by key. */ +export interface TimeseriesRow extends Record { + bucket: string + date: Date + partial?: boolean +} + +export const asFiniteNumber = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +export const rowsDomainMs = ( + rows: ReadonlyArray<{ readonly date: Date }>, +): readonly [number, number] | undefined => { + const first = rows[0]?.date.getTime() + const last = rows[rows.length - 1]?.date.getTime() + if (first === undefined || last === undefined || !Number.isFinite(first) || !Number.isFinite(last)) + return undefined + return [Math.min(first, last), Math.max(first, last)] +} diff --git a/apps/web-foldkit/src/charts/timeseries.ts b/apps/web-foldkit/src/charts/timeseries.ts new file mode 100644 index 0000000000..51f662ee0b --- /dev/null +++ b/apps/web-foldkit/src/charts/timeseries.ts @@ -0,0 +1,176 @@ +import { formatValueByUnit, inferBucketSeconds, inferRangeMs, parseBucketMs } from "@maple/ui/lib/format" +import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" +import { DEFAULT_TOKEN_FALLBACK } from "@maple/ui/lib/colors" +import { hasOnlyIntegerValues } from "@maple/ui/components/charts/_shared/sparse-series" +import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" +import { + integerTickValues, + linearYDomain, + logYDomain, + logYScale, + NICE_TICK_COUNT, + niceLinearDomain, +} from "@maple/ui/components/plot/plot-scales" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { + asFiniteNumber, + rowsDomainMs, + type TimeseriesAxisContext, + type TimeseriesRow, +} from "./timeseries-rows" +import { resolveColor, type ThemeColors } from "./theme-colors" + +// The React-free half of `Timeseries` (packages/ui/src/components/plot/timeseries.tsx): row +// normalisation, series keys and colour tokens, and the shared x and y axes. Copied, as that +// module also holds the React model hook and frame. + +export const HARD_SERIES_LIMIT = 60 +export const SERIES_FALLBACK_COLOR = DEFAULT_TOKEN_FALLBACK + +export interface SeriesDefinition { + readonly rawKey: string + readonly chartKey: string +} + +/** `normaliseTimeseriesRows`'s series discovery: first-seen order across all rows, `s1..sN` keys. */ +export const seriesDefinitionsOf = (data: ReadonlyArray>>) => { + const rawKeys = [ + ...new Set( + data.flatMap((row) => Object.keys(row).filter((key) => key !== "bucket" && key !== "partial")), + ), + ] + return { + definitions: rawKeys + .slice(0, HARD_SERIES_LIMIT) + .map((rawKey, index): SeriesDefinition => ({ rawKey, chartKey: `s${index + 1}` })), + totalSeriesCount: rawKeys.length, + } +} + +/** The rows of `normaliseTimeseriesRows`, values keyed by chart key; unparseable buckets dropped. */ +export const normaliseRows = ( + data: ReadonlyArray>>, + definitions: ReadonlyArray, +): Array => + data.flatMap((row): Array => { + const bucket = typeof row.bucket === "string" ? row.bucket : "" + const epochMs = parseBucketMs(bucket) + if (epochMs == null) return [] + const next: TimeseriesRow = { bucket, date: new Date(epochMs) } + if (row.partial === true) next.partial = true + definitions.forEach((definition) => { + next[definition.chartKey] = asFiniteNumber(row[definition.rawKey]) + }) + return [next] + }) + +/** `scaleTimeseriesRates`: rates are stored per bucket and displayed per second. */ +export const scaleRates = ( + rows: Array, + keys: ReadonlyArray, + unit: string | undefined, + bucketSeconds: number | undefined, +): Array => { + if (unit !== "requests_per_sec" || !bucketSeconds) return rows + return rows.map((row) => { + const next: TimeseriesRow = { bucket: row.bucket, date: row.date } + if (row.partial) next.partial = true + keys.forEach((key) => { + next[key] = asFiniteNumber(row[key]) / bucketSeconds + }) + return next + }) +} + +/** `resolveSeriesColors` by chart key: tokens (`var(--chart-1)`) or literals. */ +export const seriesColorTokens = ( + definitions: ReadonlyArray, +): ReadonlyMap => { + const byRawKey = resolveSeriesColors(definitions.map((definition) => definition.rawKey)) + return new Map( + definitions.map((definition) => [ + definition.chartKey, + byRawKey.get(definition.rawKey) ?? SERIES_FALLBACK_COLOR, + ]), + ) +} + +/** What a hoisting `TimeseriesLegend` hands the tile header: every series, colour resolved. */ +export const hoistedSeries = ( + data: ReadonlyArray>>, + theme: ThemeColors, +): ReadonlyArray<{ readonly key: string; readonly label: string; readonly color: string }> => { + const { definitions } = seriesDefinitionsOf(data) + const tokens = seriesColorTokens(definitions) + return definitions.map((definition) => ({ + key: definition.chartKey, + label: definition.rawKey, + color: resolveColor( + theme, + tokens.get(definition.chartKey) ?? SERIES_FALLBACK_COLOR, + SERIES_FALLBACK_COLOR, + ), + })) +} + +export const axisContextOf = ( + rows: ReadonlyArray, + timeZone: string | undefined, +): TimeseriesAxisContext => ({ + rangeMs: inferRangeMs(rows), + bucketSeconds: inferBucketSeconds(rows), + domainMs: rowsDomainMs(rows), + timeZone, +}) + +export const bucketSecondsOf = (rows: ReadonlyArray) => inferBucketSeconds(rows) + +export interface YAxisOptions { + readonly rows: ReadonlyArray> + readonly visibleKeys: ReadonlyArray + readonly unit?: string | undefined + readonly stacked?: boolean + readonly softMin?: number + readonly softMax?: number + /** Tick labels; defaults to `formatValueByUnit(value, unit)`. */ + readonly format?: (value: number) => string + readonly logScale?: boolean + readonly fitYAxisToData?: boolean + readonly thresholds?: ReadonlyArray<{ readonly value: number }> +} + +/** `timeseriesYAxis`: the shared y axis, domain included (log axes pin their own floor at 1). */ +export const timeseriesYAxis = (options: YAxisOptions) => { + const logScale = options.logScale === true + const dataDomain = linearYDomain({ + rows: options.rows, + keys: options.visibleKeys, + stacked: options.stacked, + softMin: options.softMin, + softMax: options.softMax, + fitYAxisToData: options.fitYAxisToData === true && options.softMin == null && !logScale, + thresholds: options.thresholds === undefined ? undefined : [...options.thresholds], + }) + const domain: [number, number] = logScale ? logYDomain(dataDomain[1]) : niceLinearDomain(dataDomain) + const integerOnly = + hasOnlyIntegerValues(options.rows, options.visibleKeys) && + options.unit == null && + options.format == null + return { + domain, + y: { + grid: DASHED_Y_GRID, + scale: logScale ? logYScale(dataDomain[1]) : scaleLinear().domain(domain), + nice: logScale ? false : NICE_TICK_COUNT, + axis: { + line: false, + ticks: { + size: 0, + padding: 6, + values: integerOnly && !logScale ? integerTickValues(domain) : undefined, + format: options.format ?? ((value: number) => formatValueByUnit(value, options.unit)), + }, + }, + }, + } +} diff --git a/apps/web-foldkit/src/command.ts b/apps/web-foldkit/src/command.ts new file mode 100644 index 0000000000..214af085b3 --- /dev/null +++ b/apps/web-foldkit/src/command.ts @@ -0,0 +1,191 @@ +import { DashboardWidgetSchema } from "@maple/domain/http" +import { mutateDashboard } from "./page/dashboards/kit/store" +import { Clock, Duration, Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { load, pushUrl, replaceUrl } from "foldkit/navigation" +import { Message } from "./message" +import { PRELOAD_HOLD_MS } from "./page/preload" +import { clearSessionToken, MapleAuthApi, readSessionToken, saveSessionToken } from "./platform/auth-api" +import { saveSelectedPlanKnown } from "./page/onboarding/plan-gate" +import { loadClerk, signOutOfClerk } from "./platform/clerk" +import { BillingCustomerCache } from "./data/billing-customer" +import { IntegrationStatuses } from "./data/integration-statuses" +import { ReadCache } from "./data/read-cache" +import { OrgCollections } from "./data/collections/org-collections" +import { readSessionTimeRange, writeSessionTimeRange } from "./platform/session-time-range" +import { SessionTimeRange } from "./route/session-time-range" +import { identifySession } from "./data/analytics" +import { SessionIdentity } from "./platform/session-identity" + +/** How long a signed-out Clerk answer may still turn into a session on a hard refresh (main.tsx). */ +const AUTH_SETTLE_TIMEOUT = Duration.seconds(2) + +/** `retryOrgCollections`: a fresh collection set; every live query moves onto it. */ +export const ReconnectOrgCollections = Command.define("ReconnectOrgCollections", { + messages: [Message.CompletedReconnectOrgCollections], + execute: Effect.gen(function* () { + yield* (yield* OrgCollections).recreate + return Message.CompletedReconnectOrgCollections() + }), +}) + +/** A dashboard toast's Undo: the removed widgets back on the board, wherever the user is now. */ +export const RestoreDashboardWidgets = Command.define("RestoreDashboardWidgets", { + args: { orgId: Schema.String, dashboardId: Schema.String, widgets: Schema.Array(DashboardWidgetSchema) }, + messages: [Message.CompletedRestoreDashboardWidgets], + execute: ({ orgId, dashboardId, widgets }) => + mutateDashboard(orgId, dashboardId, (document, nowIso) => { + const missing = widgets.filter((widget) => !document.widgets.some((existing) => existing.id === widget.id)) + return missing.length === 0 ? document : { ...document, widgets: [...document.widgets, ...missing], updatedAt: nowIso } + }).pipe(Effect.ignore, Effect.as(Message.CompletedRestoreDashboardWidgets())), +}) + +export const NavigateInternal = Command.define("NavigateInternal", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigateInternal], + execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigateInternal())), +}) + +export const ReplaceUrl = Command.define("ReplaceUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedReplaceUrl], + execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedReplaceUrl())), +}) + +export const LoadExternal = Command.define("LoadExternal", { + args: { href: Schema.String }, + messages: [Message.CompletedLoadExternal], + execute: ({ href }) => load(href).pipe(Effect.as(Message.CompletedLoadExternal())), +}) + +/** `resolveSelfHostedRouterAuth`: a rejected token is cleared, an unreachable API keeps it. */ +export const CheckSession = Command.define("CheckSession", { + messages: [Message.SucceededCheckSession, Message.FailedCheckSession], + execute: Effect.gen(function* () { + const maybeToken = yield* readSessionToken + if (Option.isNone(maybeToken)) { + return Message.FailedCheckSession({ failure: "NoToken" }) + } + const auth = yield* MapleAuthApi + const tenant = yield* auth.session(maybeToken.value) + return Message.SucceededCheckSession({ orgId: tenant.orgId }) + }).pipe( + Effect.catchTag("@maple/web-foldkit/SessionCheckError", (error) => + error.reason === "Rejected" + ? Effect.as(clearSessionToken, Message.FailedCheckSession({ failure: "Rejected" })) + : Effect.succeed(Message.FailedCheckSession({ failure: "Unreachable" })), + ), + ), +}) + +export const SaveSessionToken = Command.define("SaveSessionToken", { + args: { token: Schema.String }, + messages: [Message.CompletedSaveSessionToken], + execute: ({ token }) => saveSessionToken(token).pipe(Effect.as(Message.CompletedSaveSessionToken())), +}) + +export const LoadClerk = Command.define("LoadClerk", { + messages: [Message.CompletedLoadClerk, Message.FailedLoadClerk], + execute: loadClerk.pipe( + Effect.map((snapshot) => Message.CompletedLoadClerk({ snapshot })), + Effect.catchTag("@maple/web-foldkit/ClerkLoadError", (error) => + Effect.succeed(Message.FailedLoadClerk({ message: error.message })), + ), + ), +}) + +export const WaitForAuthSettle = Command.define("WaitForAuthSettle", { + messages: [Message.CompletedWaitForAuthSettle], + execute: Effect.sleep(AUTH_SETTLE_TIMEOUT).pipe(Effect.as(Message.CompletedWaitForAuthSettle())), +}) + +/** The Clerk user menu's Log out: `signOut()`, after which Clerk emits the signed-out session. */ +export const SignOutOfClerk = Command.define("SignOutOfClerk", { + messages: [Message.CompletedSignOutOfClerk], + execute: signOutOfClerk.pipe(Effect.as(Message.CompletedSignOutOfClerk())), +}) + +/** `ClerkAuthBridge`'s `identify(...)`: the session's rows and product events name the user and org. */ +export const IdentifySession = Command.define("IdentifySession", { + args: { identity: Schema.NullOr(SessionIdentity) }, + messages: [Message.CompletedIdentifySession], + execute: ({ identity }) => identifySession(identity).pipe(Effect.as(Message.CompletedIdentifySession())), +}) + +/** `billingCustomerAtom`: `GET /internal/billing/customer`, for the org it was asked for. */ +export const LoadCustomer = Command.define("LoadCustomer", { + args: { orgId: Schema.String, refresh: Schema.Boolean }, + messages: [Message.SucceededLoadCustomer, Message.FailedLoadCustomer], + execute: ({ orgId, refresh }) => + Effect.gen(function* () { + const customer = yield* (yield* BillingCustomerCache).get({ refresh }) + return Message.SucceededLoadCustomer({ orgId, customer }) + }).pipe(Effect.orElseSucceed(() => Message.FailedLoadCustomer({ orgId }))), +}) + +/** The plan settled: remember whether this org may render the app (`selectedPlanKnownAtomFor`). */ +export const SaveSelectedPlanKnown = Command.define("SaveSelectedPlanKnown", { + args: { orgId: Schema.String, mayRenderApp: Schema.Boolean }, + messages: [Message.CompletedSaveSelectedPlanKnown], + execute: ({ orgId, mayRenderApp }) => + saveSelectedPlanKnown(orgId, mayRenderApp).pipe(Effect.as(Message.CompletedSaveSelectedPlanKnown())), +}) + +/** TanStack's intent timer: the link stayed hovered or focused for `delayMs`. */ +export const WaitPreloadDelay = Command.define("WaitPreloadDelay", { + args: { href: Schema.String, version: Schema.Number, delayMs: Schema.Number }, + messages: [Message.ElapsedPreloadDelay], + execute: ({ href, version, delayMs }) => + Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.map((nowMs) => Message.ElapsedPreloadDelay({ href, version, nowMs })), + ), +}) + +/** `warmAtoms`' hold: unused preloads are released after it. */ +export const HoldPreloads = Command.define("HoldPreloads", { + args: { keys: Schema.Array(Schema.String) }, + messages: [Message.ExpiredPreloads], + execute: ({ keys }) => + Effect.sleep(Duration.millis(PRELOAD_HOLD_MS)).pipe(Effect.as(Message.ExpiredPreloads({ keys }))), +}) + +/** + * A holder let go of these reads (a page left, a preload's hold ran out): their idle TTL starts. A + * new page instance also names its org, which drops another org's reads. + */ +export const ReleaseReads = Command.define("ReleaseReads", { + args: { orgId: Schema.NullOr(Schema.String), keys: Schema.Array(Schema.String) }, + messages: [Message.CompletedReleaseReads], + execute: ({ orgId, keys }) => + Effect.gen(function* () { + const cache = yield* ReadCache + if (orgId !== null) { + yield* cache.scopeTo(orgId) + // Pages read the integration statuses without naming their org. + yield* (yield* IntegrationStatuses).scopeTo(orgId) + } + yield* cache.release(keys) + return Message.CompletedReleaseReads() + }), +}) + +/** The tab's remembered window for the org, with the clock its presets resolve against. */ +export const ReadSessionTimeRange = Command.define("ReadSessionTimeRange", { + args: { orgId: Schema.String }, + messages: [Message.LoadedSessionTimeRange], + execute: ({ orgId }) => + Effect.map(Effect.all([readSessionTimeRange(orgId), Clock.currentTimeMillis]), ([value, nowMs]) => + Message.LoadedSessionTimeRange({ orgId, value, nowMs }), + ), +}) + +/** `persistSessionTimeRange`: the window a settled navigation carried. */ +export const WriteSessionTimeRange = Command.define("WriteSessionTimeRange", { + args: { orgId: Schema.String, value: SessionTimeRange }, + messages: [Message.CompletedWriteSessionTimeRange], + execute: ({ orgId, value }) => + Effect.andThen(writeSessionTimeRange(orgId, value), Clock.currentTimeMillis).pipe( + Effect.map((nowMs) => Message.CompletedWriteSessionTimeRange({ nowMs })), + ), +}) diff --git a/apps/web-foldkit/src/data/analytics-events.test.ts b/apps/web-foldkit/src/data/analytics-events.test.ts new file mode 100644 index 0000000000..16c0d9d091 --- /dev/null +++ b/apps/web-foldkit/src/data/analytics-events.test.ts @@ -0,0 +1,105 @@ +import { OrgId, UserId } from "@maple/domain" +import { V2ApiKey, V2ApiKeyWithSecret } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../page/contract" +import { Message as IntegrationsMessage } from "../page/integrations/message" +import { init as initIntegrations, update as updateIntegrations } from "../page/integrations/update" +import * as PricingCards from "../page/onboarding/pricing-cards" +import * as QuickStart from "../page/onboarding/quick-start" +import { DEFAULT_QUICK_START_STATE } from "../page/onboarding/quick-start-state" +import * as CreateDialog from "../page/settings/keys/create-dialog" +import type { ClerkSnapshot } from "../platform/clerk" +import { identityOf, isSameIdentity } from "../platform/session-identity" + +// Each product event legacy's `trackProduct` fires is a Command at the same moment here. + +type Commands = { readonly commands?: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> } +const tracked = (result: Commands, name: string) => result.commands?.find((command) => command.name === name)?.args + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } + +describe("product events", () => { + test("a successful integration callback is integration_connected with its provider", () => { + const model = initIntegrations({ search: { integration: "github" } }, context).model + const connected = updateIntegrations( + model, + IntegrationsMessage.ReceivedConnectMessage({ provider: "github", status: "success", message: null }), + context, + ) + expect(tracked(connected, "TrackIntegrationConnected")).toEqual({ provider: "github" }) + const failed = updateIntegrations( + model, + IntegrationsMessage.ReceivedConnectMessage({ provider: "github", status: "error", message: "denied" }), + context, + ) + expect(tracked(failed, "TrackIntegrationConnected")).toBeUndefined() + }) + + test("a created API key is api_key_created with its kind and access", () => { + const key = Schema.decodeUnknownSync(V2ApiKey)({ + id: "key_aXwpxqBkqtYwtBtmsGbR41", + object: "api_key", + name: "CI deploy", + description: null, + key_prefix: "maple_ak_0003", + kind: "standard", + scopes: [], + revoked: false, + revoked_at: null, + last_used_at: null, + expires_at: null, + created_at: "2026-10-01T12:00:00.000Z", + created_by: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", + created_by_email: "ada@acme.test", + }) + const secret = Schema.decodeUnknownSync(V2ApiKeyWithSecret)({ ...Schema.encodeSync(V2ApiKey)(key), secret: "s" }) + const done = (kind: "standard" | "mcp") => + CreateDialog.update( + { ...CreateDialog.init("keys"), accessMode: "restricted" }, + CreateDialog.Message.CompletedCreate({ kind, result: Result.succeed(secret) }), + ) + expect(tracked(done("standard"), "TrackApiKeyCreated")).toEqual({ kind: "standard", access: "restricted" }) + expect(tracked(done("mcp"), "TrackApiKeyCreated")).toEqual({ kind: "mcp", access: "full" }) + }) + + test("a completed quick-start step is onboarding_step_completed", () => { + const loaded = { ...QuickStart.init("maple-onboarding-v6-org_1").model, state: DEFAULT_QUICK_START_STATE } + const picked = QuickStart.update(loaded, QuickStart.Message.PickedRole({ role: "backend" })).model + const next = QuickStart.update(picked, QuickStart.Message.ClickedContinue({ step: "role" })) + expect(tracked(next, "TrackOnboardingStepCompleted")).toEqual({ step: "role" }) + expect(tracked(QuickStart.update(loaded, QuickStart.Message.ClickedContinue({ step: "role" })), "TrackOnboardingStepCompleted")).toBeUndefined() + }) + + test("a checkout return that times out is plan_checkout_returned, unconfirmed", () => { + const waiting = PricingCards.initReturning(true).model + const timedOut = PricingCards.update(waiting, PricingCards.Message.ElapsedCheckoutTimeout()) + expect(tracked(timedOut, "TrackCheckoutReturned")).toEqual({ confirmed: false }) + }) +}) + +const snapshot = (fullName: string | null): ClerkSnapshot => ({ + maybeUserId: Option.some(UserId.make("user_ada")), + maybeOrgId: Option.some(orgId), + maybeUser: Option.some({ fullName, email: "ada@acme.test", imageUrl: "" }), + maybeOrganization: Option.none(), + memberships: [], +}) + +describe("session identity", () => { + test("the signed-in user and org name the session; signed out is nobody", () => { + expect(identityOf(snapshot("Ada Lovelace"))).toEqual({ + id: "user_ada", + username: "Ada Lovelace", + email: "ada@acme.test", + groupId: "org_acme", + }) + expect(identityOf({ ...snapshot(null), maybeUserId: Option.none() })).toBeNull() + }) + + test("identify runs again only when a primitive changed", () => { + expect(isSameIdentity(identityOf(snapshot("Ada")), identityOf(snapshot("Ada")))).toBe(true) + expect(isSameIdentity(identityOf(snapshot("Ada")), identityOf(snapshot("Ada L.")))).toBe(false) + }) +}) diff --git a/apps/web-foldkit/src/data/analytics.ts b/apps/web-foldkit/src/data/analytics.ts new file mode 100644 index 0000000000..1e5929f34f --- /dev/null +++ b/apps/web-foldkit/src/data/analytics.ts @@ -0,0 +1,23 @@ +import type { ProductEvent } from "@maple/web/lib/analytics" +import { Effect } from "effect" +import type { SessionIdentity } from "../platform/session-identity" + +export type { ProductEvent } + + +// The SDK's client entry, `import()`ed on first use so it stays out of startup. It shares the +// capture engine (on `globalThis`) with the session `platform/telemetry.ts` starts. +const sdk = Effect.tryPromise(() => import("@maple-dev/effect-sdk/client")) + +/** + * `trackProduct` (apps/web/src/lib/analytics.ts): a `custom` session event on the session the + * Foldkit telemetry started. Never fails: the engine queues events until that session's sink runs. + */ +export const trackProduct = (event: ProductEvent, props?: Readonly>) => + Effect.flatMap(sdk, ({ track }) => Effect.sync(() => track(event, props === undefined ? undefined : { ...props }))).pipe( + Effect.ignore, + ) + +/** `identify()` from `ClerkAuthBridge`: later session rows and events carry the user and org. */ +export const identifySession = (identity: SessionIdentity | null) => + Effect.flatMap(sdk, ({ identify }) => Effect.sync(() => identify(identity ?? undefined))).pipe(Effect.ignore) diff --git a/apps/web-foldkit/src/data/anomaly-service-counts.ts b/apps/web-foldkit/src/data/anomaly-service-counts.ts new file mode 100644 index 0000000000..cfcd85ea8a --- /dev/null +++ b/apps/web-foldkit/src/data/anomaly-service-counts.ts @@ -0,0 +1,28 @@ +import { V2AnomalyServiceCount } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import { MapleApiV2Client } from "../platform/api-clients" +import { IdleTtl } from "./idle-ttl" +import { ReadCache } from "./read-cache" +import type { RequestKey } from "./request-key" + +/** + * `openAnomalyServiceCountsAtom`, which the Overview and the services list both read: through the + * read cache, so the second page is answered without a request; a Reload reads again. + */ +export const loadAnomalyServiceCounts = (key: RequestKey, options?: { readonly refresh?: boolean }) => + Effect.gen(function* () { + const cache = yield* ReadCache + const client = yield* MapleApiV2Client + return yield* cache.read( + { + key, + idleTtlMs: IdleTtl.retainedQuery, + schema: Schema.Array(V2AnomalyServiceCount), + refresh: options?.refresh === true, + }, + Effect.map( + client.anomalies.serviceCounts({ query: { status: "open" } }), + (response) => response.data, + ), + ) + }) diff --git a/apps/web-foldkit/src/data/billing-customer.ts b/apps/web-foldkit/src/data/billing-customer.ts new file mode 100644 index 0000000000..276d23c2ad --- /dev/null +++ b/apps/web-foldkit/src/data/billing-customer.ts @@ -0,0 +1,51 @@ +import type { BillingCustomer } from "@maple/domain/http" +import { Context, Deferred, Effect, Layer, Option, SynchronizedRef } from "effect" +import { MapleInternalApiClient } from "../platform/api-clients" +import { SessionBearer } from "../platform/session-bearer" + +type Customer = BillingCustomer +type Entry = Readonly<{ token: Option.Option; result: Deferred.Deferred }> + +const sameToken = (a: Option.Option, b: Option.Option): boolean => + Option.getOrNull(a) === Option.getOrNull(b) + +/** + * `billingCustomerAtom`: one `GET /internal/billing/customer` per session token (so per org), shared + * by the root's plan gate, `/settings` and `/integrations`, as one atom is in apps/web. `refresh` is + * the atom's reactivity key: a plan change, a cap save or Retry reads it again. + */ +export class BillingCustomerCache extends Context.Service< + BillingCustomerCache, + { readonly get: (options?: Readonly<{ refresh?: boolean }>) => Effect.Effect } +>()("@maple/web-foldkit/BillingCustomerCache") { + static readonly layer = Layer.effect( + this, + Effect.gen(function* () { + const client = yield* MapleInternalApiClient + const bearer = yield* SessionBearer + const scope = yield* Effect.scope + const current = yield* SynchronizedRef.make(Option.none()) + + const entryFor = (token: Option.Option, refresh: boolean) => + SynchronizedRef.modifyEffect(current, (existing) => { + if (!refresh && Option.isSome(existing) && sameToken(existing.value.token, token)) + return Effect.succeed([existing.value, existing] as const) + return Effect.gen(function* () { + const result = yield* Deferred.make() + yield* Effect.forkIn(Deferred.complete(result, client.billing.getCustomer()), scope) + const entry: Entry = { token, result } + return [entry, Option.some(entry)] as const + }) + }) + + return { + get: (options) => + Effect.flatMap(bearer.token, (token) => + Effect.flatMap(entryFor(token, options?.refresh === true), (entry) => + Deferred.await(entry.result), + ), + ), + } + }), + ).pipe(Layer.provide([MapleInternalApiClient.layer, SessionBearer.layer])) +} diff --git a/apps/web-foldkit/src/data/collections/dashboards.ts b/apps/web-foldkit/src/data/collections/dashboards.ts new file mode 100644 index 0000000000..22fc12ed8c --- /dev/null +++ b/apps/web-foldkit/src/data/collections/dashboards.ts @@ -0,0 +1,102 @@ +import { createEffectCollection } from "@maple/effect-db/electric" +import { DashboardDocument, DashboardId, migrateToLatest } from "@maple/domain/http" +import type { V2DashboardUpdateParams } from "@maple/domain/http/v2" +import { DashboardRowSchema, type DashboardRow } from "@maple/web-data/collections/dashboards" +import { Effect, type ManagedRuntime, Option, Schema } from "effect" +import type { MapleApiV2Client } from "../../platform/api-clients" + +// apps/web/src/lib/collections/dashboards.ts: the per-org `dashboards` collection, whose writes +// go through the v2 API and return the Postgres txid TanStack DB awaits on the shape stream. + +const decodeDocument = Schema.decodeUnknownOption(DashboardDocument) +const decodeDashboardId = Schema.decodeUnknownEffect(DashboardId) + +/** `rowToDashboard`: migrate the stored payload, then decode; an undecodable row is `None`. */ +export const rowToDocument = (row: DashboardRow): Option.Option => + Option.flatMap( + Option.liftThrowable((payload: unknown) => + migrateToLatest(typeof payload === "string" ? JSON.parse(payload) : payload), + )(row.payload_json), + decodeDocument, + ) + +/** `rowToUpdateRequest`: the full PATCH body from the row's optimistic payload. */ +const toUpdateRequest = (document: DashboardDocument): V2DashboardUpdateParams => ({ + name: document.name, + description: document.description ?? null, + tags: document.tags ?? [], + timeRange: document.timeRange, + widgets: document.widgets, + sections: document.sections ?? [], + variables: document.variables ?? [], + refreshIntervalSeconds: document.refreshIntervalSeconds ?? null, +}) + +/** The write landed but carried no txid to confirm it on the shape stream (`requireTxid`). */ +export class MissingWriteTxidError extends Schema.TaggedError()( + "@maple/web-foldkit/collections/MissingWriteTxidError", + { message: Schema.String }, +) {} + +/** A stored payload that no longer decodes cannot be written back. */ +export class UndecodableDashboardError extends Schema.TaggedError()( + "@maple/web-foldkit/collections/UndecodableDashboardError", + { message: Schema.String, rawId: Schema.String }, +) {} + +const requireTxid = (txid: string | undefined) => + txid === undefined + ? Effect.fail( + new MissingWriteTxidError({ + message: "Dashboard write succeeded but the server returned no txid", + }), + ) + : Effect.succeed(Number(txid)) + +type V2Client = MapleApiV2Client["Service"] + +export const makeDashboardsCollection = (config: { + readonly orgId: string + readonly url: string + readonly runtime: ManagedRuntime.ManagedRuntime + readonly fetchClient: typeof globalThis.fetch + readonly client: V2Client +}) => + createEffectCollection({ + id: `dashboards:${config.orgId}`, + runtime: config.runtime, + schema: DashboardRowSchema, + getKey: (row) => row.id, + shapeOptions: { + url: config.url, + params: { shape: "dashboards", org: config.orgId }, + fetchClient: config.fetchClient, + }, + onUpdate: ({ transaction }) => + Effect.gen(function* () { + const { modified } = transaction.mutations[0] + const document = yield* Option.match(rowToDocument(modified), { + onNone: () => + Effect.fail( + new UndecodableDashboardError({ + message: "This dashboard's saved layout couldn't be read", + rawId: modified.id, + }), + ), + onSome: Effect.succeed, + }) + const id = yield* decodeDashboardId(modified.id) + const result = yield* config.client.dashboards.update({ + params: { id }, + payload: toUpdateRequest(document), + }) + return { txid: yield* requireTxid(result.txid) } + }), + onDelete: ({ transaction }) => + Effect.gen(function* () { + const { original } = transaction.mutations[0] + const id = yield* decodeDashboardId(original.id) + const result = yield* config.client.dashboards.delete({ params: { id } }) + return { txid: yield* requireTxid(result.txid) } + }), + }) diff --git a/apps/web-foldkit/src/data/collections/org-collections.ts b/apps/web-foldkit/src/data/collections/org-collections.ts new file mode 100644 index 0000000000..97fe537aed --- /dev/null +++ b/apps/web-foldkit/src/data/collections/org-collections.ts @@ -0,0 +1,279 @@ +import { createEffectCollection } from "@maple/effect-db/electric" +import { electricSyncBaseUrl } from "@maple/web/lib/services/common/electric-sync-url" +import { + Context, + Duration, + Effect, + Layer, + ManagedRuntime, + Option, + type Scope, + Stream, + SubscriptionRef, + SynchronizedRef, +} from "effect" +import { MapleApiV2Client } from "../../platform/api-clients" +import { SessionBearer } from "../../platform/session-bearer" +import { makeDashboardsCollection } from "./dashboards" +import { + AlertDestinationRowSchema, + AlertIncidentRowSchema, + AlertRuleRowSchema, + AlertRuleStateRowSchema, +} from "@maple/web-data/collections/alerts" +import { InvestigationRowSchema } from "@maple/web-data/collections/investigations" +import { ApiKeyRowSchema } from "@maple/web-data/collections/api-keys" +import { timestamptzParser } from "@maple/web-data/collections/shape-parsers" + +/** `getOrgCollections`'s 30 s fallback: a superseded set is cleaned up even if a reader lingers. */ +const CLEANUP_FALLBACK = Duration.seconds(30) + +/** `syncProxyUrl` (`shape-fetch.ts`): the authenticating shape proxy, never Electric itself. */ +const syncProxyUrl = `${electricSyncBaseUrl}/api/sync/shape` + +/** + * Electric's `fetchClient` contract is a `fetch`, so this is one; it adds the session bearer like + * `mapleSyncFetch` and leaves `init.signal` alone (live requests long-poll and abort themselves). + */ +const makeSyncFetch = + (context: Context.Context): typeof globalThis.fetch => + (input, init) => + Effect.runPromiseWith(context)( + Effect.gen(function* () { + const bearer = yield* SessionBearer + const token = yield* bearer.token + const headers = new Headers(init?.headers) + if (Option.isSome(token) && !headers.has("authorization")) + headers.set("authorization", `Bearer ${token.value}`) + return { ...init, headers } + }), + ).then((authorized) => globalThis.fetch(input, authorized)) + +const makeSet = ( + orgId: string, + runtime: ManagedRuntime.ManagedRuntime, + fetchClient: typeof globalThis.fetch, + client: MapleApiV2Client["Service"], +) => ({ + orgId, + // `createDashboardsCollection`: writes PATCH/DELETE through the v2 API. + dashboards: makeDashboardsCollection({ orgId, url: syncProxyUrl, runtime, fetchClient, client }), + apiKeys: createEffectCollection({ + id: `api_keys:${orgId}`, + runtime, + schema: ApiKeyRowSchema, + getKey: (row) => row.id, + shapeOptions: { + url: syncProxyUrl, + params: { shape: "api_keys", org: orgId }, + fetchClient, + parser: timestamptzParser, + }, + }), + // `createSyncedCollection`'s shape: id `:`, `org=` param for URL cache keying only. + alertRules: createEffectCollection({ + id: `alert_rules:${orgId}`, + runtime, + schema: AlertRuleRowSchema, + getKey: (row) => row.id, + shapeOptions: { + url: syncProxyUrl, + params: { shape: "alert_rules", org: orgId }, + fetchClient, + parser: timestamptzParser, + }, + }), + alertRuleStates: createEffectCollection({ + id: `alert_rule_states:${orgId}`, + runtime, + schema: AlertRuleStateRowSchema, + // Composite key (org_id, rule_id, group_key), as `createAlertRuleStatesCollection`. + getKey: (row) => `${row.rule_id}:${row.group_key}`, + shapeOptions: { + url: syncProxyUrl, + params: { shape: "alert_rule_states", org: orgId }, + fetchClient, + parser: timestamptzParser, + }, + }), + alertIncidents: createEffectCollection({ + id: `alert_incidents:${orgId}`, + runtime, + schema: AlertIncidentRowSchema, + getKey: (row) => row.id, + shapeOptions: { + url: syncProxyUrl, + params: { shape: "alert_incidents", org: orgId }, + fetchClient, + parser: timestamptzParser, + }, + }), + alertDestinations: createEffectCollection({ + id: `alert_destinations_v2:${orgId}`, + runtime, + schema: AlertDestinationRowSchema, + getKey: (row) => row.id, + shapeOptions: { + url: syncProxyUrl, + params: { shape: "alert_destinations_v2", org: orgId }, + fetchClient, + parser: timestamptzParser, + }, + }), +}) +export type OrgCollectionSet = ReturnType + +/** + * `createInvestigationCollection` (lib/collections/investigation-collections.ts): the + * `investigation_v2` shape scoped to one investigation, read-only. Not part of the org set: its + * lifecycle is the page's, so the caller releases it when its live query lets go. + */ +const makeInvestigationCollection = ( + orgId: string, + investigationId: string, + runtime: ManagedRuntime.ManagedRuntime, + fetchClient: typeof globalThis.fetch, +) => + createEffectCollection({ + id: `investigation_v2:${orgId}:${investigationId}`, + runtime, + schema: InvestigationRowSchema, + getKey: (row) => row.id, + shapeOptions: { + url: syncProxyUrl, + params: { shape: "investigation_v2", org: orgId, scope: investigationId }, + fetchClient, + parser: timestamptzParser, + }, + }) +export type InvestigationCollection = ReturnType +type SyncedCollection = + | OrgCollectionSet["dashboards"] + | OrgCollectionSet["apiKeys"] + | OrgCollectionSet["alertRules"] + | OrgCollectionSet["alertRuleStates"] + | OrgCollectionSet["alertIncidents"] + | OrgCollectionSet["alertDestinations"] + | InvestigationCollection + +/** `cleanupCollectionWhenIdle`: wait for the last live query to let go, then clean up. */ +const cleanupWhenIdle = (collection: SyncedCollection): Effect.Effect => + Effect.callback((resume) => { + if (collection.subscriberCount === 0) return resume(Effect.void) + const off = collection.on("subscribers:change", (event) => { + if (event.subscriberCount === 0) resume(Effect.void) + }) + return Effect.sync(off) + }).pipe( + Effect.timeoutOption(CLEANUP_FALLBACK), + Effect.andThen(Effect.tryPromise(() => collection.cleanup())), + Effect.ignore, + ) + +/** + * The Electric collections of the signed-in org (`org-collections.ts`): one live set, minted per + * org, the superseded set cleaned up once drained. A resource because every liveQuery + * Subscription must read the same collection instances. + */ +export class OrgCollections extends Context.Service< + OrgCollections, + { + readonly forOrg: (orgId: string) => Effect.Effect + /** A fresh scoped collection for one investigation; the caller releases it. */ + readonly forInvestigation: (orgId: string, investigationId: string) => Effect.Effect + /** Cleans the collection up once its last live query has let go (in the background). */ + readonly releaseInvestigation: (collection: InvestigationCollection) => Effect.Effect + /** `retryOrgCollections`: a fresh set for the open org; live streams move onto it. */ + readonly recreate: Effect.Effect + /** + * `useOrgCollections`: `build` over the org's set, rebuilt on each `recreate` (the old + * stream is torn down first, so its live queries let the superseded set drain). + */ + readonly liveFor: ( + orgId: string, + build: (set: OrgCollectionSet) => Stream.Stream, + ) => Stream.Stream + } +>()("@maple/web-foldkit/OrgCollections") { + static readonly layer = Layer.effect(this, makeOrgCollections()).pipe( + Layer.provide([SessionBearer.layer, MapleApiV2Client.layer]), + ) +} + +/** A live stream over the org's collections that follows every `recreate` (Reconnect). */ +export const orgLive = ( + orgId: string, + build: (set: OrgCollectionSet) => Stream.Stream, +): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + return (yield* OrgCollections).liveFor(orgId, build) + }), + ) + +function makeOrgCollections(): Effect.Effect< + OrgCollections["Service"], + never, + SessionBearer | MapleApiV2Client | Scope.Scope +> { + return Effect.gen(function* () { + const scope = yield* Effect.scope + const fetchClient = makeSyncFetch(yield* Effect.context()) + const client = yield* MapleApiV2Client + // Collection handlers and backoff logs run here; none of Maple's collections need services. + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(Layer.empty)), + (managed) => managed.disposeEffect, + ) + const current = yield* SynchronizedRef.make(Option.none()) + const generation = yield* SubscriptionRef.make(0) + const retire = (set: OrgCollectionSet) => + Effect.asVoid( + Effect.forkIn( + Effect.all([ + cleanupWhenIdle(set.dashboards), + cleanupWhenIdle(set.apiKeys), + cleanupWhenIdle(set.alertRules), + cleanupWhenIdle(set.alertRuleStates), + cleanupWhenIdle(set.alertIncidents), + cleanupWhenIdle(set.alertDestinations), + ]), + scope, + ), + ) + yield* Effect.addFinalizer(() => + Effect.flatMap( + SynchronizedRef.get(current), + Option.match({ onNone: () => Effect.void, onSome: retire }), + ), + ) + + const forOrg = (orgId: string) => + SynchronizedRef.modifyEffect(current, (existing) => { + if (Option.isSome(existing) && existing.value.orgId === orgId) { + return Effect.succeed([existing.value, existing] as const) + } + const next = makeSet(orgId, runtime, fetchClient, client) + return Effect.as(Option.match(existing, { onNone: () => Effect.void, onSome: retire }), [ + next, + Option.some(next), + ] as const) + }) + const forInvestigation = (orgId: string, investigationId: string) => + Effect.sync(() => makeInvestigationCollection(orgId, investigationId, runtime, fetchClient)) + const releaseInvestigation = (collection: InvestigationCollection) => + Effect.asVoid(Effect.forkIn(cleanupWhenIdle(collection), scope)) + const recreate = SynchronizedRef.updateEffect(current, (existing) => + Option.match(existing, { + onNone: () => Effect.succeed(existing), + onSome: (set) => + Effect.as(retire(set), Option.some(makeSet(set.orgId, runtime, fetchClient, client))), + }), + ).pipe(Effect.andThen(SubscriptionRef.update(generation, (value) => value + 1))) + const liveFor = (orgId: string, build: (set: OrgCollectionSet) => Stream.Stream) => + SubscriptionRef.changes(generation).pipe( + Stream.switchMap(() => Stream.unwrap(Effect.map(forOrg(orgId), build))), + ) + return { forOrg, forInvestigation, releaseInvestigation, recreate, liveFor } + }) +} diff --git a/apps/web-foldkit/src/data/execute-batcher.test.ts b/apps/web-foldkit/src/data/execute-batcher.test.ts new file mode 100644 index 0000000000..16c0f7e5e2 --- /dev/null +++ b/apps/web-foldkit/src/data/execute-batcher.test.ts @@ -0,0 +1,157 @@ +import { + QUERY_ENGINE_BATCH_MAX, + type QueryEngineBatchOutcome, + QueryEngineExecuteRequest, +} from "@maple/query-engine" +import { Effect, Exit, Fiber, Ref, type Scope } from "effect" +import { TestClock } from "effect/testing" +import { describe, expect, test } from "vitest" +import { MissingBatchResultError, makeExecuteBatcher, type SendExecuteBatch } from "./execute-batcher" + +const WINDOW_MS = 15 + +const timeout = { + _tag: "@maple/http/errors/QueryEngineTimeoutError", + type: "api_error", + code: "query_engine_timeout", + title: "Query timed out", + message: "too slow", + retryable: true, + recovery: "retry", +} as const + +// The batcher never inspects the payload; the positional outcome tells the callers apart. +const request = (_index: number) => + new QueryEngineExecuteRequest({ + startTime: "2026-08-01 00:00:00", + endTime: "2026-08-01 01:00:00", + query: { kind: "count", source: "logs" }, + }) + +const countOf = (outcome: { readonly result: { readonly kind: string; readonly data?: unknown } }) => + outcome.result.kind === "count" ? outcome.result.data : undefined + +const echo = (requests: ReadonlyArray): ReadonlyArray => + requests.map((_, index) => ({ + outcome: "success", + result: { kind: "count", source: "logs", data: { total: index } }, + })) + +/** A send that records each batch's size and answers with `reply`. */ +const recording = ( + reply: ( + requests: ReadonlyArray, + ) => ReadonlyArray = echo, +) => + Effect.map(Ref.make>([]), (batches) => { + const send: SendExecuteBatch = (requests) => + Effect.as( + Ref.update(batches, (sizes) => [...sizes, requests.length]), + reply(requests), + ) + return { batches, send } + }) + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(TestClock.layer()))) + +describe("makeExecuteBatcher", () => { + test("queries from separate fibers inside one window go out as one request, when the window closes", () => + run( + Effect.gen(function* () { + const { batches, send } = yield* recording() + const batcher = yield* makeExecuteBatcher(send, WINDOW_MS) + const first = yield* Effect.forkChild(batcher.enqueue(request(0))) + yield* TestClock.adjust("10 millis") + const second = yield* Effect.forkChild(batcher.enqueue(request(1))) + yield* TestClock.adjust("4 millis") + expect(yield* Ref.get(batches)).toEqual([]) + yield* TestClock.adjust("1 millis") + const responses = yield* Fiber.joinAll([first, second]) + expect(yield* Ref.get(batches)).toEqual([2]) + expect(responses.map(countOf)).toEqual([{ total: 0 }, { total: 1 }]) + }), + )) + + test("a full batch goes out at once and the overflow waits for a fresh window", () => + run( + Effect.gen(function* () { + const { batches, send } = yield* recording() + const batcher = yield* makeExecuteBatcher(send, WINDOW_MS) + const fibers = yield* Effect.forEach( + Array.from({ length: QUERY_ENGINE_BATCH_MAX + 1 }, (_, index) => index), + (index) => Effect.forkChild(batcher.enqueue(request(index))), + ) + yield* TestClock.adjust("1 millis") + expect(yield* Ref.get(batches)).toEqual([QUERY_ENGINE_BATCH_MAX]) + yield* TestClock.adjust(`${WINDOW_MS} millis`) + yield* Fiber.joinAll(fibers) + expect(yield* Ref.get(batches)).toEqual([QUERY_ENGINE_BATCH_MAX, 1]) + }), + )) + + test("a query after a full flush gets its own window, not the flushed batch's timer", () => + run( + Effect.gen(function* () { + const { batches, send } = yield* recording() + const batcher = yield* makeExecuteBatcher(send, WINDOW_MS) + const early = yield* Effect.forkChild(batcher.enqueue(request(0))) + yield* TestClock.adjust("10 millis") + const filling = yield* Effect.forEach([1, 2, 3], (index) => + Effect.forkChild(batcher.enqueue(request(index))), + ) + yield* TestClock.adjust("1 millis") + const late = yield* Effect.forkChild(batcher.enqueue(request(4))) + // The first window's timer would fire here; the late query must keep waiting. + yield* TestClock.adjust("5 millis") + expect(yield* Ref.get(batches)).toEqual([QUERY_ENGINE_BATCH_MAX]) + yield* TestClock.adjust("10 millis") + yield* Fiber.joinAll([early, ...filling, late]) + expect(yield* Ref.get(batches)).toEqual([QUERY_ENGINE_BATCH_MAX, 1]) + }), + )) + + test("each caller settles from its own outcome: one failing query does not fail its siblings", () => + run( + Effect.gen(function* () { + const { send } = yield* recording((requests) => + requests.flatMap((_, index): ReadonlyArray => + index === 1 + ? [{ outcome: "failure", error: timeout }] + : index === 2 + ? [] + : echo(requests).slice(index, index + 1), + ), + ) + const batcher = yield* makeExecuteBatcher(send, WINDOW_MS) + const fibers = yield* Effect.forEach([0, 1, 2], (index) => + Effect.forkChild(Effect.exit(batcher.enqueue(request(index)))), + ) + yield* TestClock.adjust(`${WINDOW_MS} millis`) + const [ok, timedOut, missing] = yield* Fiber.joinAll(fibers) + expect(Exit.isSuccess(ok)).toBe(true) + // Rejected with its own public body, as the promise batcher rejects it. + expect(timedOut).toEqual(Exit.fail(timeout)) + expect(missing).toEqual( + Exit.fail( + new MissingBatchResultError({ + message: "Batch response was missing a result for this query.", + }), + ), + ) + }), + )) + + test("a failed request fails every query in that batch with the same error", () => + run( + Effect.gen(function* () { + const error = { _tag: "TransportError", message: "Failed to fetch" } + const batcher = yield* makeExecuteBatcher(() => Effect.fail(error), WINDOW_MS) + const fibers = yield* Effect.forEach([0, 1], (index) => + Effect.forkChild(Effect.exit(batcher.enqueue(request(index)))), + ) + yield* TestClock.adjust(`${WINDOW_MS} millis`) + expect(yield* Fiber.joinAll(fibers)).toEqual([Exit.fail(error), Exit.fail(error)]) + }), + )) +}) diff --git a/apps/web-foldkit/src/data/execute-batcher.ts b/apps/web-foldkit/src/data/execute-batcher.ts new file mode 100644 index 0000000000..1bd4d2afee --- /dev/null +++ b/apps/web-foldkit/src/data/execute-batcher.ts @@ -0,0 +1,144 @@ +import { + QUERY_ENGINE_BATCH_MAX, + type QueryEngineBatchFailure, + type QueryEngineBatchOutcome, + type QueryEngineExecuteRequest, + QueryEngineExecuteResponse, +} from "@maple/query-engine" +import { EXECUTE_BATCH_WINDOW_MS } from "@maple/web-data/warehouse/execute-batcher" +import { Deferred, Duration, Effect, Ref, Schema, type Scope } from "effect" + +/** + * `@maple/web-data`'s promise `makeExecuteBatcher` as an Effect: queries enqueued from separate + * Command fibers within one timer window go out as one `/execute-batch` request, at most + * `QUERY_ENGINE_BATCH_MAX` per request, and each caller is settled from its own positional + * outcome. It fails with what the promise batcher rejects with (the per-item public body, or the + * whole request's error), so `executeQueryEngine` normalizes both apps' failures alike. + */ + +/** A batch response with fewer outcomes than queries. */ +export class MissingBatchResultError extends Schema.TaggedError()( + "@maple/web-foldkit/MissingBatchResultError", + { message: Schema.String }, +) {} + +/** Sends one batch; fails only when the whole request fails. */ +export type SendExecuteBatch = ( + requests: ReadonlyArray, +) => Effect.Effect, E> + +export type ExecuteFailure = E | QueryEngineBatchFailure | MissingBatchResultError + +export interface ExecuteBatcher { + readonly enqueue: ( + payload: QueryEngineExecuteRequest, + ) => Effect.Effect> +} + +interface Pending { + readonly payload: QueryEngineExecuteRequest + readonly settled: Deferred.Deferred> +} + +interface State { + readonly pending: ReadonlyArray> + /** The armed window timer; a timer whose token is no longer current flushes nothing. */ + readonly timer: number | null + readonly nextTimer: number +} + +const settle = (entry: Pending, outcome: QueryEngineBatchOutcome | undefined) => { + if (outcome === undefined) { + return Deferred.fail( + entry.settled, + new MissingBatchResultError({ message: "Batch response was missing a result for this query." }), + ) + } + // Per-item failures already use the complete public error body. + return outcome.outcome === "failure" + ? Deferred.fail(entry.settled, outcome.error) + : Deferred.succeed(entry.settled, new QueryEngineExecuteResponse({ result: outcome.result })) +} + +export const makeExecuteBatcher = ( + send: SendExecuteBatch, + windowMs: number = EXECUTE_BATCH_WINDOW_MS, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const scope = yield* Effect.scope + const state = yield* Ref.make>({ pending: [], timer: null, nextTimer: 0 }) + + const dispatch = (batch: ReadonlyArray>) => + send(batch.map((entry) => entry.payload)).pipe( + Effect.matchEffect({ + // Whole-request failure: every query in this batch failed for the same reason. + onFailure: (error) => + Effect.forEach(batch, (entry) => Deferred.fail(entry.settled, error), { + discard: true, + }), + onSuccess: (outcomes) => + Effect.forEach(batch, (entry, index) => settle(entry, outcomes[index]), { + discard: true, + }), + }), + Effect.forkIn(scope), + ) + + const schedule: Effect.Effect = Effect.flatMap( + Ref.modify(state, (current): [number | null, State] => + current.timer !== null + ? [null, current] + : [ + current.nextTimer, + { ...current, timer: current.nextTimer, nextTimer: current.nextTimer + 1 }, + ], + ), + (token) => + token === null + ? Effect.void + : Effect.asVoid( + Effect.forkIn( + Effect.andThen(Effect.sleep(Duration.millis(windowMs)), flush(token)), + scope, + ), + ), + ) + + /** `token` null: flush now (a full batch); otherwise only if that timer is still the armed one. */ + function flush(token: number | null): Effect.Effect { + return Effect.flatMap( + Ref.modify(state, (current): [ReadonlyArray>, State] => { + if (token !== null && current.timer !== token) return [[], current] + const batch = current.pending.slice(0, QUERY_ENGINE_BATCH_MAX) + return [ + batch, + { ...current, pending: current.pending.slice(QUERY_ENGINE_BATCH_MAX), timer: null }, + ] + }), + (batch) => + batch.length === 0 + ? Effect.void + : Effect.andThen( + dispatch(batch), + // Anything over the cap goes out as the next batch rather than being dropped. + Effect.flatMap(Ref.get(state), (current) => + current.pending.length > 0 ? schedule : Effect.void, + ), + ), + ) + } + + const enqueue = (payload: QueryEngineExecuteRequest) => + Effect.gen(function* () { + const settled = yield* Deferred.make>() + const size = yield* Ref.modify(state, (current): [number, State] => { + const pending = [...current.pending, { payload, settled }] + return [pending.length, { ...current, pending }] + }) + // A full batch has nothing to gain from waiting out the window. + yield* size >= QUERY_ENGINE_BATCH_MAX ? flush(null) : schedule + return yield* Deferred.await(settled) + }) + + return { enqueue } satisfies ExecuteBatcher + }) diff --git a/apps/web-foldkit/src/data/idle-ttl.ts b/apps/web-foldkit/src/data/idle-ttl.ts new file mode 100644 index 0000000000..1ab2605712 --- /dev/null +++ b/apps/web-foldkit/src/data/idle-ttl.ts @@ -0,0 +1,44 @@ +/** + * apps/web's idle TTLs for the reads that go through `ReadCache`: the family's `staleTime` + * (`lib/services/atoms/warehouse-query-atoms.ts`) or the retained query's `timeToLive`. + */ +export const IdleTtl = { + /** `getSpanHierarchyResultAtom`. */ + spanHierarchy: 60_000, + /** `getTraceSpanEventsResultAtom`. */ + traceSpanEvents: 60_000, + /** `getLogsCountResultAtom`. */ + logsCount: 60_000, + /** `productEventsForTraceResultAtom`. */ + productEventsForTrace: 60_000, + /** `getReplaysForTraceResultAtom`. */ + replaysForTrace: 60_000, + /** `getTracesFacetsResultAtom`. */ + tracesFacets: 30_000, + /** `getServiceOverviewResultAtom`. */ + serviceOverview: 30_000, + /** `getCustomChartServiceSparklinesResultAtom`. */ + serviceSparklines: 30_000, + /** `listReplaysResultAtom`. */ + listReplays: 30_000, + /** `replaysFacetsResultAtom`. */ + replaysFacets: 30_000, + /** `getReplayResultAtom`. */ + replay: 60_000, + /** `getReplayManifestResultAtom`. */ + replayManifest: 240_000, + /** `getSessionTranscriptResultAtom`. */ + sessionTranscript: 60_000, + /** The `*InfraTimeseriesResultAtom` families (host, pod, node, container). */ + infraTimeseries: 30_000, + /** `getServicesFacetsResultAtom`. */ + servicesFacets: 300_000, + /** `getReleasesResultAtom`. */ + releases: 30_000, + /** `getReleaseDetailResultAtom`. */ + releaseDetail: 30_000, + /** `COMMIT_DETAIL_TTL_MS` (`commitQueryAtom`, `commitsQueryAtom`). */ + commitDetail: 300_000, + /** `DEFAULT_QUERY_TTL` (`retainedQueryV2`): `openAnomalyServiceCountsAtom`. */ + retainedQuery: 60_000, +} as const diff --git a/apps/web-foldkit/src/data/infra-surfaces.ts b/apps/web-foldkit/src/data/infra-surfaces.ts new file mode 100644 index 0000000000..10affaf5a8 --- /dev/null +++ b/apps/web-foldkit/src/data/infra-surfaces.ts @@ -0,0 +1,62 @@ +import { InfraPresenceRequest, InfraPresenceResponse, type InfraSurfaceLiteral } from "@maple/domain/http" +import { formatWarehouseDateTime } from "@maple/query-engine" +import { Effect } from "effect" +import { MapleInternalApiClient } from "../platform/api-clients" +import type { NavSurface } from "../shell/nav" +import { readThrough } from "./read-cache" +import { requestKey } from "./request-key" +import type { TimeWindow } from "./time-range" + +// `useInfraSurfaces` (apps/web/src/hooks/use-infra-surfaces.ts): the presence probe plus the four +// integration statuses. The shell and the pages read the probe through one cache entry per window. + +/** The last hour, snapped to five minutes so the key holds still. */ +const PRESENCE_WINDOW_MS = 60 * 60 * 1000 +const PRESENCE_BUCKET_MS = 5 * 60 * 1000 + +/** `infraPresenceResultAtom`'s `staleTime`. */ +const PRESENCE_IDLE_TTL_MS = 300_000 + +/** The window `useInfraSurfaces()` probes when it is given none. */ +export const lastHourWindow = (nowMs: number): TimeWindow => { + const end = Math.floor(nowMs / PRESENCE_BUCKET_MS) * PRESENCE_BUCKET_MS + return { startTime: formatWarehouseDateTime(end - PRESENCE_WINDOW_MS), endTime: formatWarehouseDateTime(end) } +} + +export const presenceKey = (orgId: string, window: TimeWindow) => + requestKey(orgId, "infraPresence", { data: { startTime: window.startTime, endTime: window.endTime } }) + +/** `infraPresenceResultAtom({ data: window })`: one request per org and window, shared by every reader. */ +export const loadInfraPresence = (orgId: string, window: TimeWindow, options?: { readonly refresh?: boolean }) => + readThrough( + { + key: presenceKey(orgId, window), + idleTtlMs: PRESENCE_IDLE_TTL_MS, + schema: InfraPresenceResponse, + refresh: options?.refresh === true, + }, + Effect.gen(function* () { + const client = yield* MapleInternalApiClient + return yield* client.queryEngine.infraPresence({ + payload: new InfraPresenceRequest({ startTime: window.startTime, endTime: window.endTime }), + }) + }), + ).pipe(Effect.map((response) => response.surfaces)) + +/** Connected integrations; one that has not answered (or failed) counts as not connected. */ +export interface IntegrationFlags { + readonly cloudflare: boolean + readonly railway: boolean + readonly planetscale: boolean + readonly gcp: boolean +} + +/** The probe's surfaces plus the connected integrations; `null` while the probe is unknown. */ +export const infraSurfacesOf = ( + presence: ReadonlyArray | null, + integrations: IntegrationFlags, +): ReadonlySet | null => { + if (presence === null) return null + const connected = (["cloudflare", "planetscale", "railway", "gcp"] as const).filter((id) => integrations[id]) + return new Set([...presence, ...connected]) +} diff --git a/apps/web-foldkit/src/data/integration-statuses.test.ts b/apps/web-foldkit/src/data/integration-statuses.test.ts new file mode 100644 index 0000000000..5a70b5022c --- /dev/null +++ b/apps/web-foldkit/src/data/integration-statuses.test.ts @@ -0,0 +1,50 @@ +import { Effect, Ref } from "effect" +import { describe, expect, test } from "vitest" +import { sharedLoader } from "./integration-statuses" + +/** A load that fails on the runs listed in `failing` and answers with the run number otherwise. */ +const counting = (failing: ReadonlyArray) => + Effect.map(Ref.make(0), (runs) => ({ + runs, + load: Effect.flatMap(Ref.updateAndGet(runs, (n) => n + 1), (run) => + failing.includes(run) ? Effect.fail(`run ${run} failed`) : Effect.succeed(run), + ), + })) + +describe("IntegrationStatuses loader", () => { + test("a settled status is shared until a reader asks for a refresh", () => + Effect.runPromise( + Effect.gen(function* () { + const { runs, load } = yield* counting([]) + const status = yield* sharedLoader(load) + expect(yield* status()).toBe(1) + expect(yield* status()).toBe(1) + expect(yield* status({ refresh: true })).toBe(2) + expect(yield* Ref.get(runs)).toBe(2) + }), + )) + + test("an answer is kept per org: another org asks again", () => + Effect.runPromise( + Effect.gen(function* () { + const { runs, load } = yield* counting([]) + const status = yield* sharedLoader(load) + expect(yield* status({ orgId: "org_a" })).toBe(1) + expect(yield* status({ orgId: "org_b" })).toBe(2) + expect(yield* status({ orgId: "org_b" })).toBe(2) + expect(yield* Ref.get(runs)).toBe(2) + }), + )) + + test("a failed load is not kept: the next reader asks again", () => + Effect.runPromise( + Effect.gen(function* () { + const { runs, load } = yield* counting([1]) + const status = yield* sharedLoader(load) + expect(yield* Effect.flip(status())).toBe("run 1 failed") + expect(yield* status()).toBe(2) + expect(yield* status()).toBe(2) + expect(yield* Ref.get(runs)).toBe(2) + }), + )) +}) diff --git a/apps/web-foldkit/src/data/integration-statuses.ts b/apps/web-foldkit/src/data/integration-statuses.ts new file mode 100644 index 0000000000..a4208e1349 --- /dev/null +++ b/apps/web-foldkit/src/data/integration-statuses.ts @@ -0,0 +1,83 @@ +import type { CloudflareIntegrationStatus, RailwayIntegrationStatus } from "@maple/domain/http" +import type { V2GcpIntegration, V2PlanetScaleIntegration } from "@maple/domain/http/v2" +import { Context, Effect, Layer, Option, Ref, SynchronizedRef } from "effect" +import { MapleApiV2Client, MapleInternalApiClient } from "../platform/api-clients" + +/** + * The four integration statuses the sidebar mounts and the infra integration pages read. apps/web + * shares one atom per status between them; this does the same: one request, shared while in flight + * and kept after, until a reader asks for a refresh (a write's `reactivityKeys`, an empty-page poll). + */ + +export type PlanetscaleStatus = V2PlanetScaleIntegration +export type GcpStatus = V2GcpIntegration + +export interface LoadOptions { + readonly refresh?: boolean + /** The org the answer is for; without it, the org the root opened last (`scopeTo`). */ + readonly orgId?: string +} + +type Loader = (options?: LoadOptions) => Effect.Effect +type Held = Readonly<{ orgId: string | null; shared: Effect.Effect }> + +/** + * One shared load per status and org (legacy keys the atoms by the active org): another org's + * answer is never served. A failure is not kept: the next reader asks again, like the atom. + */ +export const sharedLoader = (load: Effect.Effect): Effect.Effect> => + Effect.map(SynchronizedRef.make(Option.none>()), (ref) => { + const forget = (failed: Effect.Effect) => + SynchronizedRef.update(ref, (held) => + Option.isSome(held) && held.value.shared === failed ? Option.none() : held, + ) + const start = (orgId: string | null) => + Effect.map(Effect.cached(load), (memo): Held => { + const shared: Effect.Effect = Effect.tapError(memo, () => forget(shared)) + return { orgId, shared } + }) + return (options) => { + const orgId = options?.orgId ?? null + return Effect.flatten( + SynchronizedRef.modifyEffect(ref, (current) => + Option.isSome(current) && current.value.orgId === orgId && options?.refresh !== true + ? Effect.succeed([current.value.shared, current] as const) + : Effect.map(start(orgId), (next) => [next.shared, Option.some(next)] as const), + ), + ) + } + }) + +export class IntegrationStatuses extends Context.Service< + IntegrationStatuses, + { + readonly cloudflare: Loader + readonly railway: Loader + readonly planetscale: Loader + readonly gcp: Loader + /** The open org: readers that do not name one read under it. */ + readonly scopeTo: (orgId: string) => Effect.Effect + } +>()("@maple/web-foldkit/IntegrationStatuses") { + static readonly layer = Layer.effect( + this, + Effect.gen(function* () { + const internal = yield* MapleInternalApiClient + const v2 = yield* MapleApiV2Client + const openOrg = yield* Ref.make(Option.none()) + const inOrg = + (loader: Loader): Loader => + (options) => + Effect.flatMap(Ref.get(openOrg), (org) => + loader({ ...options, orgId: options?.orgId ?? Option.getOrUndefined(org) }), + ) + return { + cloudflare: inOrg(yield* sharedLoader(internal.integrations.cloudflareStatus())), + railway: inOrg(yield* sharedLoader(internal.integrations.railwayStatus())), + planetscale: inOrg(yield* sharedLoader(v2.planetscaleIntegration.status())), + gcp: inOrg(yield* sharedLoader(v2.gcpIntegration.status())), + scopeTo: (orgId: string) => Ref.set(openOrg, Option.some(orgId)), + } + }), + ) +} diff --git a/apps/web-foldkit/src/data/keyed.test.ts b/apps/web-foldkit/src/data/keyed.test.ts new file mode 100644 index 0000000000..4ec24a52bc --- /dev/null +++ b/apps/web-foldkit/src/data/keyed.test.ts @@ -0,0 +1,81 @@ +import { Option, Result } from "effect" +import { AsyncData } from "foldkit" +import { describe, expect, test } from "vitest" +import * as Keyed from "./keyed" +import { requestKey } from "./request-key" + +const morning = requestKey("org_a", "getServicesFacets", { data: { startTime: "06:00", endTime: "18:00" } }) +const evening = requestKey("org_a", "getServicesFacets", { data: { startTime: "12:00", endTime: "24:00" } }) +const otherOrg = requestKey("org_b", "getServicesFacets", { data: { startTime: "12:00", endTime: "24:00" } }) + +const requested = (slot: Keyed.Keyed, key: string, mode: Keyed.RequestMode = "Load") => + Option.getOrThrow(Keyed.request(slot, key, mode)) + +const loaded = (key: string, data: number) => + Keyed.accept(requested(Keyed.idle(), key), key, Result.succeed(data)) + +describe("request keys", () => { + test("the same input in any key order, with or without undefined fields, is the same request", () => { + expect(requestKey("org_a", "q", { b: 1, a: { d: undefined, c: [2] } })).toBe( + requestKey("org_a", "q", { a: { c: [2] }, b: 1 }), + ) + expect(requestKey("org_a", "q", { a: 1 })).not.toBe(requestKey("org_b", "q", { a: 1 })) + }) +}) + +describe("Keyed slots", () => { + test("a superseded answer is dropped, the current one lands", () => { + const first = requested(Keyed.idle(), morning) + const second = requested(first, evening) + const afterLate = Keyed.accept(second, morning, Result.succeed(1)) + expect(afterLate).toEqual(second) + expect(Keyed.accept(afterLate, evening, Result.succeed(2)).data).toEqual( + AsyncData.Success({ data: 2 }), + ) + }) + + test("asking again for a pending or loaded key starts nothing; a refresh revalidates", () => { + const pending = requested(Keyed.idle(), morning) + expect(Keyed.request(pending, morning, "Load")).toEqual(Option.none()) + const done = Keyed.accept(pending, morning, Result.succeed(1)) + expect(Keyed.request(done, morning, "Load")).toEqual(Option.none()) + expect(requested(done, morning, "Refresh").data).toEqual(AsyncData.Refreshing({ data: 1 })) + }) + + test("a new window keeps the previous data on screen; a new org does not", () => { + const done = loaded(morning, 1) + expect(requested(done, evening).data).toEqual(AsyncData.Refreshing({ data: 1 })) + expect(requested(done, otherOrg).data).toEqual(AsyncData.Loading()) + }) + + test("a failed refresh keeps the last data beside the error", () => { + const refreshing = requested(loaded(morning, 1), morning, "Refresh") + expect(Keyed.accept(refreshing, morning, Result.fail("timeout")).data).toEqual( + AsyncData.Stale({ data: 1, error: "timeout" }), + ) + }) + + test("a new key's failure shows the error, not the previous key's rows", () => { + const switched = requested(loaded(morning, 1), evening) + expect(Keyed.accept(switched, evening, Result.fail("timeout")).data).toEqual( + AsyncData.Failure({ error: "timeout" }), + ) + const settled = Keyed.accept(switched, evening, Result.succeed(2)) + const refreshed = requested(settled, evening, "Refresh") + expect(Keyed.accept(refreshed, evening, Result.fail("timeout")).data).toEqual( + AsyncData.Stale({ data: 2, error: "timeout" }), + ) + }) + + test("a post-write re-read supersedes the read in flight: the older answer is dropped", () => { + const reloading = requested(loaded(morning, 1), morning, "Refresh") + const superseded = Keyed.supersede(reloading, morning) + expect(superseded.key).not.toBe(morning) + const afterOld = Keyed.accept(superseded, morning, Result.succeed(1)) + expect(afterOld).toEqual(superseded) + const key = superseded.key ?? "" + expect(Keyed.accept(afterOld, key, Result.succeed(2)).data).toEqual(AsyncData.Success({ data: 2 })) + expect(Keyed.request(Keyed.accept(afterOld, key, Result.succeed(2)), morning, "Load")).toEqual(Option.none()) + expect(Keyed.supersede(superseded, morning).key).not.toBe(key) + }) +}) diff --git a/apps/web-foldkit/src/data/keyed.ts b/apps/web-foldkit/src/data/keyed.ts new file mode 100644 index 0000000000..e0d2ba0b4f --- /dev/null +++ b/apps/web-foldkit/src/data/keyed.ts @@ -0,0 +1,90 @@ +import { Option, Result, Schema } from "effect" +import { AsyncData } from "foldkit" +import { orgOfKey, type RequestKey } from "./request-key" + +/** + * One request slot in a page Model: the `AsyncData` plus the key it was last requested under. + * This is what one `useAtomValue(queryAtom(input))` call site becomes. `retained`: the data on + * screen answers an earlier key (`useRetainedResult`), so a failure of the new key shows as one. + */ +export type Keyed = Readonly<{ key: RequestKey | null; data: AsyncData.AsyncData; retained?: boolean }> + +export const KeyedSchema = ( + dataSchema: Schema.Codec, + errorSchema: Schema.Codec, +) => + Schema.Struct({ + key: Schema.NullOr(Schema.String), + data: AsyncData.Schema(dataSchema, errorSchema).schema, + retained: Schema.optionalKey(Schema.Boolean), + }) + +export const idle = (): Keyed => ({ key: null, data: AsyncData.Idle() }) + +/** A superseding re-read's key: the request key plus a generation, so an older answer is dropped. */ +const GENERATION_SEPARATOR = "\u0001" + +/** The request key a slot key was built from (a superseding re-read adds a generation). */ +export const baseKey = (key: RequestKey): RequestKey => key.split(GENERATION_SEPARATOR)[0] ?? key + +const generationOf = (key: RequestKey | null): number => + key === null ? 0 : Number(key.split(GENERATION_SEPARATOR)[1] ?? "0") + +const withRetained = (key: RequestKey, data: AsyncData.AsyncData, retained: boolean): Keyed => + retained ? { key, data, retained } : { key, data } + +/** + * `Load` is route entry or an input change; `Refresh` is the explicit refresh (`useAtomRefresh`). + * Same key: `Load` keeps cached data and never restarts an in-flight request, `Refresh` + * revalidates. New key: always requests, keeping the previous data on screen (`Refreshing`) unless + * the org changed, which is apps/web's `useRetainedResult` (`lib/effect-atom.ts`). + */ +export type RequestMode = "Load" | "Refresh" + +/** `Some(next)`: put `next` in the Model and return the fetch Command. `None`: nothing to do. */ +export const request = ( + slot: Keyed, + key: RequestKey, + mode: RequestMode, +): Option.Option> => { + if (slot.key !== null && baseKey(slot.key) === baseKey(key)) { + const transition = + mode === "Load" ? AsyncData.loadIfMissing(slot.data) : AsyncData.revalidateOrLoad(slot.data) + return Option.map(transition, (data) => withRetained(key, data, slot.retained === true)) + } + const sameOrg = slot.key !== null && orgOfKey(slot.key) === orgOfKey(key) + return Option.some( + Option.match(sameOrg ? AsyncData.getData(slot.data) : Option.none(), { + onNone: (): Keyed => ({ key, data: AsyncData.Loading() }), + onSome: (previous) => withRetained(key, AsyncData.Refreshing({ data: previous }), true), + }), + ) +} + +/** + * A re-read after a write (reactivity invalidation): always requests, even with a read in flight, + * under a new generation of `key`, so the older answer is dropped. Fetch with the returned `key`. + */ +export const supersede = (slot: Keyed, key: RequestKey): Keyed => { + const base = baseKey(key) + const isSame = slot.key !== null && baseKey(slot.key) === base + const generation = isSame ? generationOf(slot.key) + 1 : 1 + // A read in flight keeps its state; the new generation's answer is the one that lands. + const requested = Option.getOrElse(request(slot, base, "Refresh"), () => slot) + return { ...requested, key: `${base}${GENERATION_SEPARATOR}${generation}` } +} + +/** + * Folds a result in only if it answers the slot's current key; a superseded answer is dropped. + * A failure over retained data from an earlier key is a plain `Failure`, not stale data. + */ +export const accept = (slot: Keyed, key: RequestKey, result: Result.Result): Keyed => + slot.key !== key + ? slot + : { + key, + data: + slot.retained === true && Result.isFailure(result) + ? AsyncData.Failure({ error: result.failure }) + : AsyncData.settle(slot.data, result), + } diff --git a/apps/web-foldkit/src/data/live-query.test.ts b/apps/web-foldkit/src/data/live-query.test.ts new file mode 100644 index 0000000000..b8e65554b6 --- /dev/null +++ b/apps/web-foldkit/src/data/live-query.test.ts @@ -0,0 +1,25 @@ +import { Effect } from "effect" +import { describe, expect, test } from "vitest" +import { releaseLiveQuery } from "./live-query" + +describe("releaseLiveQuery", () => { + test("cleans the collection up even when unsubscribing throws", async () => { + const cleanups: Array = [] + await Effect.runPromise( + releaseLiveQuery({ + subscription: { + unsubscribe: () => { + throw new globalThis.Error("already unsubscribed") + }, + }, + collection: { + cleanup: () => { + cleanups.push("collection") + return Promise.resolve() + }, + }, + }), + ) + expect(cleanups).toEqual(["collection"]) + }) +}) diff --git a/apps/web-foldkit/src/data/live-query.ts b/apps/web-foldkit/src/data/live-query.ts new file mode 100644 index 0000000000..b55d576984 --- /dev/null +++ b/apps/web-foldkit/src/data/live-query.ts @@ -0,0 +1,46 @@ +import { + type Context, + createLiveQueryCollection, + type GetResult, + type InitialQueryBuilder, + type QueryBuilder, +} from "@tanstack/db" +import { Effect, Queue, Stream } from "effect" + +interface LiveQuery { + readonly subscription: { readonly unsubscribe: () => void } + readonly collection: { readonly cleanup: () => Promise } +} + +/** + * A failed cleanup must not take the app down; the live query is gone either way. Each step is + * ignored on its own, so a throwing `unsubscribe` still cleans the collection up. + */ +export const releaseLiveQuery = ({ collection, subscription }: LiveQuery): Effect.Effect => + Effect.andThen( + Effect.ignore(Effect.try(() => subscription.unsubscribe())), + Effect.ignore(Effect.tryPromise(() => collection.cleanup())), + ) + +/** + * `useLiveQuery` as a Subscription stream (Hazel `apps/web-foldkit/src/data/live-query.ts`): + * emits the full result once the live query is ready and again after every change, and cleans + * the live query up when the Subscription's dependencies change or it is torn down. The query + * builder is the same one apps/web passes to `useLiveQuery`, so filters and order match exactly. + */ +export const liveQueryStream = ( + query: (q: InitialQueryBuilder) => QueryBuilder, + toMessage: (rows: ReadonlyArray>) => Message, +): Stream.Stream => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const collection = createLiveQueryCollection({ query, startSync: true }) + const emit = () => Queue.offerUnsafe(queue, toMessage(collection.toArray)) + const subscription = collection.subscribeChanges(emit) + collection.onFirstReady(emit) + return { collection, subscription } + }), + releaseLiveQuery, + ).pipe(Effect.andThen(Effect.never)), + ) diff --git a/apps/web-foldkit/src/data/load-failure.ts b/apps/web-foldkit/src/data/load-failure.ts new file mode 100644 index 0000000000..bc58226ce9 --- /dev/null +++ b/apps/web-foldkit/src/data/load-failure.ts @@ -0,0 +1,12 @@ +import { PublicHttpErrorBodySchema } from "@maple/domain/http" +import { displayError } from "@maple/web-data/lib/error-messages" + +/** + * A failed load as the Model keeps it: the public error body `displayError` derives from the + * failure (title, message, recovery), which is everything `ErrorState` renders. Plain data, so it + * rides in a Message and survives DevTools replay. + */ +export const LoadFailure = PublicHttpErrorBodySchema +export type LoadFailure = typeof LoadFailure.Type + +export const toLoadFailure = (error: unknown): LoadFailure => displayError(error) diff --git a/apps/web-foldkit/src/data/read-cache.test.ts b/apps/web-foldkit/src/data/read-cache.test.ts new file mode 100644 index 0000000000..75619d6937 --- /dev/null +++ b/apps/web-foldkit/src/data/read-cache.test.ts @@ -0,0 +1,141 @@ +import { Deferred, Effect, Exit, Fiber, Ref, Schema, type Scope } from "effect" +import { TestClock } from "effect/testing" +import { describe, expect, test } from "vitest" +import { ReadCache } from "./read-cache" +import { requestKey } from "./request-key" + +const TTL_MS = 30_000 +const Count = Schema.Struct({ count: Schema.Number }) + +const keyOf = (org: string, sha = "a") => requestKey(org, "vcsCommitDetail", { sha }) + +/** A load that counts its runs and answers with the run number. */ +const counting = Effect.map(Ref.make(0), (runs) => ({ + runs, + load: Effect.map( + Ref.updateAndGet(runs, (n) => n + 1), + (count) => ({ count }), + ), +})) + +const run = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe(Effect.provide(ReadCache.layer), Effect.scoped, Effect.provide(TestClock.layer())), + ) + +const read = (key: string, load: Effect.Effect<{ count: number }, unknown>, refresh = false) => + Effect.flatMap(Effect.service(ReadCache), (cache) => + cache.read({ key, idleTtlMs: TTL_MS, schema: Count, refresh }, load), + ) + +describe("ReadCache", () => { + test("a second reader of a settled key is answered from the cache", () => + run( + Effect.gen(function* () { + const { runs, load } = yield* counting + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 1 }) + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 1 }) + expect(yield* Ref.get(runs)).toBe(1) + }), + )) + + test("readers of an in-flight key share its one request", () => + run( + Effect.gen(function* () { + const gate = yield* Deferred.make() + const { runs, load } = yield* counting + const held = Effect.andThen(Deferred.await(gate), load) + const first = yield* Effect.forkChild(read(keyOf("org_a"), held)) + const second = yield* Effect.forkChild(read(keyOf("org_a"), held)) + yield* Effect.yieldNow + yield* Deferred.succeed(gate, undefined) + expect(yield* Fiber.join(first)).toEqual({ count: 1 }) + expect(yield* Fiber.join(second)).toEqual({ count: 1 }) + expect(yield* Ref.get(runs)).toBe(1) + }), + )) + + test("an entry idle past its TTL is read again", () => + run( + Effect.gen(function* () { + const { runs, load } = yield* counting + yield* read(keyOf("org_a"), load) + yield* TestClock.adjust(TTL_MS + 1) + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 2 }) + expect(yield* Ref.get(runs)).toBe(2) + }), + )) + + test("a release restarts the idle TTL, as a page leaving unmounts its atoms", () => + run( + Effect.gen(function* () { + const { runs, load } = yield* counting + yield* read(keyOf("org_a"), load) + yield* TestClock.adjust(TTL_MS - 1) + yield* Effect.flatMap(Effect.service(ReadCache), (cache) => cache.release([keyOf("org_a")])) + yield* TestClock.adjust(TTL_MS - 1) + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 1 }) + expect(yield* Ref.get(runs)).toBe(1) + }), + )) + + test("an org switch drops the other org's entries", () => + run( + Effect.gen(function* () { + const { runs, load } = yield* counting + yield* read(keyOf("org_a"), load) + yield* read(keyOf("org_b"), load) + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 3 }) + expect(yield* Ref.get(runs)).toBe(3) + }), + )) + + test("a reader naming its org is not answered from the open org's entry", () => + run( + Effect.gen(function* () { + const { runs, load } = yield* counting + const cache = yield* Effect.service(ReadCache) + const spec = { operation: "getServicesFacets", input: {}, idleTtlMs: TTL_MS, schema: Count } + yield* cache.scopeTo("org_a") + yield* cache.readInOrg(spec, load) + // The org switched; the root has not scoped the cache to the new org yet. + expect(yield* cache.readInOrg({ ...spec, orgId: "org_b" }, load)).toEqual({ count: 2 }) + expect(yield* Ref.get(runs)).toBe(2) + }), + )) + + test("a refresh reads again and replaces the entry", () => + run( + Effect.gen(function* () { + const { load } = yield* counting + yield* read(keyOf("org_a"), load) + expect(yield* read(keyOf("org_a"), load, true)).toEqual({ count: 2 }) + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 2 }) + }), + )) + + test("a failure reaches its readers but is not kept", () => + run( + Effect.gen(function* () { + const { runs, load } = yield* counting + const failing = Effect.andThen(load, Effect.fail("down")) + expect(Exit.isFailure(yield* Effect.exit(read(keyOf("org_a"), failing)))).toBe(true) + expect(yield* read(keyOf("org_a"), load)).toEqual({ count: 2 }) + expect(yield* Ref.get(runs)).toBe(2) + }), + )) + + test("a cached value that fails the reader's schema is read again", () => + run( + Effect.gen(function* () { + const cache = yield* Effect.service(ReadCache) + const Named = Schema.Struct({ name: Schema.String }) + yield* read(keyOf("org_a"), Effect.succeed({ count: 7 })) + const reread = cache.read( + { key: keyOf("org_a"), idleTtlMs: TTL_MS, schema: Named }, + Effect.succeed({ name: "x" }), + ) + expect(yield* reread).toEqual({ name: "x" }) + }), + )) +}) diff --git a/apps/web-foldkit/src/data/read-cache.ts b/apps/web-foldkit/src/data/read-cache.ts new file mode 100644 index 0000000000..ddbdc76951 --- /dev/null +++ b/apps/web-foldkit/src/data/read-cache.ts @@ -0,0 +1,190 @@ +import { + Clock, + Context, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Option, + Ref, + Schema, + type Scope, + SynchronizedRef, +} from "effect" +import { orgOfKey, type RequestKey, requestKey } from "./request-key" + +/** + * The app's read cache (s6-data.md "Read cache"): what apps/web's query atoms share across pages. + * One entry per request key; in-flight reads are joined, a settled one is served until its idle TTL + * runs out, counted from its last read or its holder's release (a page leaving, a preload's hold). + */ + +/** `withRetention`'s LRU bound: the oldest entries go first past it. */ +export const READ_CACHE_MAX_ENTRIES = 400 + +/** One read: its request key, the legacy family's idle TTL, and the schema a cached value must pass. */ +export interface ReadSpec { + readonly key: RequestKey + readonly idleTtlMs: number + readonly schema: S + /** `useAtomRefresh`: read again even when a live entry exists. */ + readonly refresh?: boolean +} + +type Entry = Readonly<{ + result: Deferred.Deferred + idleTtlMs: number + touchedAtMs: number +}> + +type Entries = ReadonlyMap + +const capped = (entries: Map): Map => + entries.size <= READ_CACHE_MAX_ENTRIES + ? entries + : new Map([...entries].slice(entries.size - READ_CACHE_MAX_ENTRIES)) + +/** Another org's entries are dropped on the first read for a new org (an org switch). */ +const ofOrg = (entries: Entries, org: string): Map => + new Map([...entries].filter(([key]) => orgOfKey(key) === org)) + +const isLive = (entry: Entry, nowMs: number) => + Effect.map( + Deferred.isDone(entry.result), + (isDone) => !isDone || nowMs - entry.touchedAtMs <= entry.idleTtlMs, + ) + +type EntriesRef = SynchronizedRef.SynchronizedRef +type Answer = Deferred.Deferred + +/** A load finished: a success stays (its TTL counts from now), a failure is not kept. */ +const settle = (entries: EntriesRef, key: RequestKey, result: Answer, exit: Exit.Exit) => + Effect.flatMap(Clock.currentTimeMillis, (nowMs) => + SynchronizedRef.update(entries, (current) => { + const entry = current.get(key) + if (entry === undefined || entry.result !== result) return current + const next = new Map(current) + if (Exit.isSuccess(exit)) next.set(key, { ...entry, touchedAtMs: nowMs }) + else next.delete(key) + return next + }), + ).pipe(Effect.andThen(Deferred.done(result, exit))) + +type Outcome = + | { readonly _tag: "Owner"; readonly fiber: Fiber.Fiber } + | { readonly _tag: "Joiner"; readonly result: Answer } + +const step = (outcome: Outcome, next: Entries): readonly [Outcome, Entries] => [ + outcome, + next, +] + +const makeRead = + (entries: EntriesRef, scope: Scope.Scope) => + (spec: ReadSpec, load: Effect.Effect) => + Effect.flatMap(Clock.currentTimeMillis, (nowMs) => + SynchronizedRef.modifyEffect(entries, (current) => + Effect.gen(function* () { + const next = ofOrg(current, orgOfKey(spec.key)) + const existing = next.get(spec.key) + if (existing !== undefined && spec.refresh !== true && (yield* isLive(existing, nowMs))) { + next.set(spec.key, { ...existing, touchedAtMs: nowMs }) + return step({ _tag: "Joiner", result: existing.result }, next) + } + const result = yield* Deferred.make() + // Forked in the cache's scope, so the read outlives the reader that started it. + const fiber = yield* Effect.forkIn( + Effect.onExit(load, (exit) => settle(entries, spec.key, result, exit)), + scope, + ) + next.set(spec.key, { result, idleTtlMs: spec.idleTtlMs, touchedAtMs: nowMs }) + return step({ _tag: "Owner", fiber }, capped(next)) + }), + ), + ).pipe( + Effect.flatMap((outcome): Effect.Effect => + outcome._tag === "Owner" + ? Fiber.join(outcome.fiber) + : Effect.flatMap(Deferred.await(outcome.result), (value) => + Schema.is(spec.schema)(value) ? Effect.succeed(value) : load, + ), + ), + ) + +const makeRelease = (entries: EntriesRef) => (keys: ReadonlyArray) => + Effect.flatMap(Clock.currentTimeMillis, (nowMs) => + SynchronizedRef.update( + entries, + (current) => + new Map( + [...current].map(([key, entry]) => [ + key, + keys.includes(key) ? { ...entry, touchedAtMs: nowMs } : entry, + ]), + ), + ), + ) + +/** `ReadCache.read` from a Command: `load` runs only when no live entry answers `spec.key`. */ +export const readThrough = ( + spec: ReadSpec, + load: Effect.Effect, +) => Effect.flatMap(Effect.service(ReadCache), (cache) => cache.read(spec, load)) + +/** A read whose key is built under the open org (`scopeTo`), for readers that do not carry it. */ +export interface OrgReadSpec extends Omit, "key"> { + readonly operation: string + readonly input: unknown + /** The reader's own org, when it knows it: wins over the open org, which may not be set yet. */ + readonly orgId?: string +} + +export class ReadCache extends Context.Service< + ReadCache, + { + readonly read: ( + spec: ReadSpec, + load: Effect.Effect, + ) => Effect.Effect + /** `read` under the open org's `requestKey`; before any org is open it only loads. */ + readonly readInOrg: ( + spec: OrgReadSpec, + load: Effect.Effect, + ) => Effect.Effect + /** The holder of these keys let go of them: their idle TTL starts now. */ + readonly release: (keys: ReadonlyArray) => Effect.Effect + /** The open org: other orgs' entries are dropped (an org switch). */ + readonly scopeTo: (orgId: string) => Effect.Effect + } +>()("@maple/web-foldkit/ReadCache") { + static readonly layer = Layer.effect( + this, + Effect.gen(function* () { + const scope = yield* Effect.scope + const entries = yield* SynchronizedRef.make(new Map()) + const openOrg = yield* Ref.make(Option.none()) + const read = makeRead(entries, scope) + return { + read, + readInOrg: ( + spec: OrgReadSpec, + load: Effect.Effect, + ) => + Effect.flatMap(Ref.get(openOrg), (orgId) => + Option.match(spec.orgId === undefined ? orgId : Option.some(spec.orgId), { + onNone: () => load, + onSome: (org) => + read({ ...spec, key: requestKey(org, spec.operation, spec.input) }, load), + }), + ), + release: makeRelease(entries), + scopeTo: (orgId: string) => + Effect.andThen( + Ref.set(openOrg, Option.some(orgId)), + SynchronizedRef.update(entries, (current) => ofOrg(current, orgId)), + ), + } + }), + ) +} diff --git a/apps/web-foldkit/src/data/request-key.ts b/apps/web-foldkit/src/data/request-key.ts new file mode 100644 index 0000000000..95c71c4233 --- /dev/null +++ b/apps/web-foldkit/src/data/request-key.ts @@ -0,0 +1,15 @@ +import { encodeOrgScopedKey } from "@maple/web-data/lib/cache-key" + +/** + * The identity of one data request: org, operation and input, as apps/web's atom keys encode it + * (`encodeOrgScopedKey`: org, NUL, the sorted and 15 s-snapped input). A result Message carries the + * key it was requested under; `update` drops it unless it still matches the slot, so a slow + * answer for an old filter, window or org never lands. + */ +export type RequestKey = string + +export const requestKey = (orgId: string, operation: string, input: unknown): RequestKey => + encodeOrgScopedKey(orgId, { operation, input }) + +/** The org a key was built for, so an org switch can drop data instead of keeping it on screen. */ +export const orgOfKey = (key: RequestKey): string => key.slice(0, key.indexOf("\u0000")) diff --git a/apps/web-foldkit/src/data/services-facets-probe.ts b/apps/web-foldkit/src/data/services-facets-probe.ts new file mode 100644 index 0000000000..65abf15b62 --- /dev/null +++ b/apps/web-foldkit/src/data/services-facets-probe.ts @@ -0,0 +1,50 @@ +import { getServicesFacets } from "@maple/web-data/warehouse/services" +import { Effect, Schema } from "effect" +import { IdleTtl } from "./idle-ttl" +import { ReadCache } from "./read-cache" +import type { RequestKey } from "./request-key" +import type { TimeWindow } from "./time-range" + +/** The operation the pages key the facets slot under: `requestKey(org, SERVICES_FACETS, { data })`. */ +export const SERVICES_FACETS = "getServicesFacets" + +const FacetItem = Schema.Struct({ name: Schema.String, count: Schema.Number }) + +/** `ServicesFacetsResponse` (`@maple/web-data/warehouse/services`), as the cache checks it. */ +export const ServicesFacetsResponse = Schema.Struct({ + data: Schema.Struct({ + environments: Schema.Array(FacetItem), + namespaces: Schema.Array(FacetItem), + commitShas: Schema.Array(FacetItem), + services: Schema.Array(FacetItem), + }), +}) + +/** + * `getServicesFacetsResultAtom({ data: window })`, which the Overview, the services list, the shell's + * namespace list and the chat read: through the read cache, so one window is requested once. + */ +export const loadServicesFacets = ( + window: TimeWindow, + options?: { readonly refresh?: boolean; readonly key?: RequestKey; readonly orgId?: string }, +) => + Effect.flatMap(Effect.service(ReadCache), (cache) => { + const spec = { + idleTtlMs: IdleTtl.servicesFacets, + schema: ServicesFacetsResponse, + refresh: options?.refresh === true, + } + const load = getServicesFacets({ data: window }) + // A page passes its slot key (its org); the shell and the chat read under the open org. + return options?.key === undefined + ? cache.readInOrg( + { + ...spec, + operation: SERVICES_FACETS, + input: { data: window }, + orgId: options?.orgId, + }, + load, + ) + : cache.read({ ...spec, key: options.key }, load) + }) diff --git a/apps/web-foldkit/src/data/session-tenant.test.ts b/apps/web-foldkit/src/data/session-tenant.test.ts new file mode 100644 index 0000000000..591eaf8b9f --- /dev/null +++ b/apps/web-foldkit/src/data/session-tenant.test.ts @@ -0,0 +1,34 @@ +import { CurrentTenant } from "@maple/domain/http" +import { Effect, Option, Ref, Schema } from "effect" +import { describe, expect, test } from "vitest" +import { makeSessionTenantCache } from "./session-tenant" + +const tenant = Schema.decodeUnknownSync(CurrentTenant.TenantSchema)({ + orgId: "org_parity", + userId: "user_ada", + roles: ["root"], + authMode: "self_hosted", +}) + +describe("session tenant cache", () => { + test("a failed read is asked again by the next reader; a good one is shared", async () => { + const outcome = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const reads = yield* Ref.make(0) + const fetchTenant = Effect.flatMap(Ref.updateAndGet(reads, (count) => count + 1), (count) => + count === 1 ? Effect.fail("network blip") : Effect.succeed(tenant), + ) + const cache = yield* makeSessionTenantCache(fetchTenant, Effect.succeed(Option.some("token_1"))) + const first = yield* Effect.result(cache.get) + const second = yield* cache.get + yield* cache.get + return { isFirstFailure: first._tag === "Failure", second, reads: yield* Ref.get(reads) } + }), + ), + ) + expect(outcome.isFirstFailure).toBe(true) + expect(outcome.second.userId).toBe("user_ada") + expect(outcome.reads).toBe(2) + }) +}) diff --git a/apps/web-foldkit/src/data/session-tenant.ts b/apps/web-foldkit/src/data/session-tenant.ts new file mode 100644 index 0000000000..2fecb1b674 --- /dev/null +++ b/apps/web-foldkit/src/data/session-tenant.ts @@ -0,0 +1,68 @@ +import type { CurrentTenant } from "@maple/domain/http" +import { Context, Deferred, Effect, Layer, Option, type Scope, SynchronizedRef } from "effect" +import { MapleApiClient } from "../platform/api-clients" +import { SessionBearer } from "../platform/session-bearer" + +type Tenant = CurrentTenant.TenantSchema +type Entry = Readonly<{ token: Option.Option; result: Deferred.Deferred }> + +const sameToken = (a: Option.Option, b: Option.Option): boolean => + Option.getOrNull(a) === Option.getOrNull(b) + +/** + * One read per session token, shared by every reader. A failed read is dropped once it settles, + * so the next reader asks again rather than keeping the failure until the token renews. + */ +export const makeSessionTenantCache = ( + fetchTenant: Effect.Effect, + token: Effect.Effect>, +): Effect.Effect<{ readonly get: Effect.Effect }, never, Scope.Scope> => + Effect.gen(function* () { + const scope = yield* Effect.scope + const current = yield* SynchronizedRef.make(Option.none()) + + const dropIfCurrent = (result: Entry["result"]) => + SynchronizedRef.update(current, (existing) => + Option.isSome(existing) && existing.value.result === result ? Option.none() : existing, + ) + + const entryFor = (forToken: Option.Option) => + SynchronizedRef.modifyEffect(current, (existing) => { + if (Option.isSome(existing) && sameToken(existing.value.token, forToken)) + return Effect.succeed([existing.value, existing] as const) + return Effect.gen(function* () { + const result = yield* Deferred.make() + const read = fetchTenant.pipe(Effect.onError(() => dropIfCurrent(result))) + yield* Effect.forkIn(Deferred.complete(result, read), scope) + const entry: Entry = { token: forToken, result } + return [entry, Option.some(entry)] as const + }) + }) + + return { + get: Effect.flatMap(token, (forToken) => Effect.flatMap(entryFor(forToken), (entry) => Deferred.await(entry.result))), + } + }) + +/** + * `retainedQuery("auth", "session", {})`: one `GET /api/auth/session` per session token, shared by + * every reader (the shell's tenant lookup, the alerts pages' admin check), as one atom is in apps/web. + */ +export class SessionTenant extends Context.Service }>()( + "@maple/web-foldkit/SessionTenant", +) { + static readonly layer = Layer.effect( + this, + Effect.gen(function* () { + const client = yield* MapleApiClient + const bearer = yield* SessionBearer + return yield* makeSessionTenantCache(client.auth.session(), bearer.token) + }), + ).pipe(Layer.provide([MapleApiClient.layer, SessionBearer.layer])) +} + +/** `useIsOrgAdmin()`: the session's roles allow org administration; `false` when it fails. */ +export const readIsOrgAdmin = Effect.gen(function* () { + const session = yield* (yield* SessionTenant).get + return session.roles.some((role) => role === "root" || role === "org:admin") +}).pipe(Effect.orElseSucceed(() => false)) diff --git a/apps/web-foldkit/src/data/time-range.ts b/apps/web-foldkit/src/data/time-range.ts new file mode 100644 index 0000000000..51f2e0802d --- /dev/null +++ b/apps/web-foldkit/src/data/time-range.ts @@ -0,0 +1,31 @@ +import { resolveTimeRangeAt, snapTimestamp } from "@maple/web-data/lib/time-range" +import { Clock, Effect, Schema } from "effect" + +/** A resolved warehouse window, as every warehouse request carries it. */ +export const TimeWindow = Schema.Struct({ startTime: Schema.String, endTime: Schema.String }) +export type TimeWindow = typeof TimeWindow.Type + +export interface WindowRequest { + readonly startTime?: string | undefined + readonly endTime?: string | undefined + readonly preset: string + /** False after an explicit refresh: legacy re-resolves against the real clock then. */ + readonly snap: boolean + readonly timeZone?: string | undefined +} + +/** + * apps/web's `resolveEffectiveTimeRange` plus the 15 s key snap legacy applies when it decodes an + * atom key back into the request, so the window sent is byte-identical to its execute-batch payload. + */ +export const resolveWindow = (request: WindowRequest, nowMs: number): TimeWindow => { + const window = resolveTimeRangeAt(nowMs, request.startTime, request.endTime, request.preset, { + timeZone: request.timeZone, + snap: request.snap, + }) + return { startTime: snapTimestamp(window.startTime), endTime: snapTimestamp(window.endTime) } +} + +/** Reads the clock, so it runs in a Command, never in `update`. */ +export const currentWindow = (request: WindowRequest): Effect.Effect => + Effect.map(Clock.currentTimeMillis, (nowMs) => resolveWindow(request, nowMs)) diff --git a/apps/web-foldkit/src/data/warehouse-error.ts b/apps/web-foldkit/src/data/warehouse-error.ts new file mode 100644 index 0000000000..93ecff6502 --- /dev/null +++ b/apps/web-foldkit/src/data/warehouse-error.ts @@ -0,0 +1,39 @@ +import type { AnyPublicHttpErrorBody } from "@maple/domain/http" +import { + type BackendError, + isTransportFailure, + isWarehouseApiError, + type WarehouseApiError, +} from "@maple/web-data/warehouse/effect-utils" +import { Schema } from "effect" + +/** + * Why a warehouse request failed, as the Model keeps it: plain data, small enough to ride in a + * Message. `Rejected` carries the API's public error body code (per-item or whole-request). + */ +export const WarehouseFailure = Schema.Struct({ + reason: Schema.Literals(["Rejected", "Unreachable", "Failed"]), + message: Schema.String, + code: Schema.optionalKey(Schema.String), + retryable: Schema.Boolean, +}) +export type WarehouseFailure = typeof WarehouseFailure.Type + +const rejected = (body: AnyPublicHttpErrorBody): WarehouseFailure => ({ + reason: "Rejected", + message: body.message, + code: body.code, + retryable: body.retryable, +}) + +/** + * A `@maple/web-data` query failure as Message data: a request that never reached the API is + * `Unreachable`, an error the API answered carries its public body, anything local is `Failed`. + */ +export const toWarehouseFailure = (error: WarehouseApiError | BackendError): WarehouseFailure => { + if (!isWarehouseApiError(error)) return rejected("error" in error ? error.error : error) + if (error._tag === "@maple/web/errors/WarehouseUnreachableError" || isTransportFailure(error.cause)) { + return { reason: "Unreachable", message: "Cannot reach Maple API", retryable: true } + } + return { reason: "Failed", message: error.message, retryable: error.error.retryable } +} diff --git a/apps/web-foldkit/src/data/warehouse.ts b/apps/web-foldkit/src/data/warehouse.ts new file mode 100644 index 0000000000..dd5bc74d4a --- /dev/null +++ b/apps/web-foldkit/src/data/warehouse.ts @@ -0,0 +1,38 @@ +import { QueryEngineExecuteBatchRequest } from "@maple/query-engine" +import { + MapleApiV2Http, + MapleInternalHttp, + WarehouseExecutor, +} from "@maple/web-data/warehouse/warehouse-services" +import { Effect, Layer } from "effect" +import { MapleApiV2Client, MapleInternalApiClient } from "../platform/api-clients" +import { makeExecuteBatcher } from "./execute-batcher" + +/** + * `@maple/web-data`'s warehouse services over this app's clients. One instance per runtime, so + * Commands on separate fibers share one batch window, as every apps/web atom shares its module + * batcher. Query functions (`@maple/web-data/warehouse/*`) run as Commands against it. + */ +const WarehouseExecutorLive = Layer.effect( + WarehouseExecutor, + Effect.gen(function* () { + const client = yield* MapleInternalApiClient + const batcher = yield* makeExecuteBatcher((requests) => + client.queryEngine + .executeBatch({ payload: new QueryEngineExecuteBatchRequest({ requests }) }) + .pipe( + Effect.map((response) => response.results), + Effect.withSpan("QueryEngine.executeBatch", { + attributes: { "query.batch_size": requests.length }, + }), + ), + ) + return { enqueue: batcher.enqueue } + }), +) + +export const WarehouseLive = Layer.mergeAll( + WarehouseExecutorLive, + Layer.effect(MapleInternalHttp, Effect.service(MapleInternalApiClient)), + Layer.effect(MapleApiV2Http, Effect.service(MapleApiV2Client)), +).pipe(Layer.provide([MapleInternalApiClient.layer, MapleApiV2Client.layer])) diff --git a/apps/web-foldkit/src/entry.ts b/apps/web-foldkit/src/entry.ts new file mode 100644 index 0000000000..13003bb4bf --- /dev/null +++ b/apps/web-foldkit/src/entry.ts @@ -0,0 +1,39 @@ +import "./styles.css" + +import { Runtime } from "foldkit" +import type { UrlRequest } from "foldkit/navigation" +import type { Url } from "foldkit/url" +import { labEnabled, labPathOf, startLab } from "./lab/boot" +import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" +import { ResourcesLive } from "./platform/resources" +import { instrument } from "./platform/telemetry" + +const container = document.getElementById("app") ?? document.body + +const startApplication = () => + Runtime.run( + Runtime.makeApplication( + instrument({ + Model, + Flags, + init, + update, + view, + subscriptions, + resources: ResourcesLive, + container, + routing: { + // Typed: `instrument` passes the config through, so Foldkit cannot infer them. + onUrlRequest: (request: UrlRequest) => Message.ClickedLink({ request }), + onUrlChange: (url: Url) => Message.ChangedUrl({ url }), + }, + devTools: { Message }, + }), + ), + { flags }, + ) + +// `/lab/` boots that lab page's own program instead of the app (parity scenarios, benches). +const labPath = labEnabled ? labPathOf(location.pathname) : undefined +if (labPath === undefined) startApplication() +else void startLab(labPath, container) diff --git a/apps/web-foldkit/src/lab/agent-sessions-rows.ts b/apps/web-foldkit/src/lab/agent-sessions-rows.ts new file mode 100644 index 0000000000..cb8bc36c38 --- /dev/null +++ b/apps/web-foldkit/src/lab/agent-sessions-rows.ts @@ -0,0 +1,153 @@ +import type { SessionRow } from "../page/agent-sessions/list/model" + +// copied: the rows of apps/web `lab/agent-sessions-list-lab.tsx`, chosen for the shapes that break +// the list's columns (an unnamed session, an unidentified vendor, ms beside hours, no usage at all). + +const BASE: SessionRow = { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRPKH", + vendorId: "claude_agent_sdk", + vendorVersion: "", + traceCount: 3, + spanCount: 142, + errorSpanCount: 0, + toolErrorCount: 0, + turnErrorCount: 0, + serviceNames: ["maple-slack-agent"], + models: ["claude-sonnet-5"], + agentNames: ["slack-triage"], + firstAgentName: "slack-triage", + llmCalls: 24, + toolCalls: 61, + totalTokens: 1_284_000, + inputTokens: 184_000, + cacheReadTokens: 940_000, + cacheWriteTokens: 96_000, + outputTokens: 42_000, + reasoningTokens: 22_000, + cost: 1.83, + startTime: "", + endTime: "", + durationMs: 184_000, +} + +const NO_TOKENS = { + totalTokens: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cost: 0, +} + +const iso = (ms: number) => new Date(ms).toISOString().replace("T", " ").replace("Z", "") + +export const buildRows = (nowMs: number): ReadonlyArray => { + const at = (minutesAgo: number, durationMs: number) => { + const start = nowMs - minutesAgo * 60_000 + return { startTime: iso(start), endTime: iso(start + durationMs), durationMs } + } + return [ + { ...BASE, ...at(4, 184_000) }, + { + ...BASE, + ...at(9, 1_240), + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRQ44", + vendorId: "openai_agents_sdk", + agentNames: ["web-fetcher", "deep-research"], + firstAgentName: "deep-research", + serviceNames: ["research-worker", "api"], + models: ["openai/gpt-5.6", "openai/gpt-5.5"], + llmCalls: 3, + toolCalls: 1, + spanCount: 11, + traceCount: 1, + totalTokens: 41_200, + inputTokens: 28_000, + cacheReadTokens: 0, + cacheWriteTokens: 4_200, + outputTokens: 6_000, + reasoningTokens: 3_000, + cost: 0.09, + }, + { + ...BASE, + ...at(26, 3_920_000), + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRZZ9", + vendorId: "langchain", + agentNames: [], + firstAgentName: "", + serviceNames: ["ingest-classifier"], + models: ["bedrock/us.anthropic.claude-opus-5"], + llmCalls: 412, + toolCalls: 1_204, + spanCount: 4_318, + traceCount: 61, + errorSpanCount: 26, + toolErrorCount: 24, + turnErrorCount: 1, + totalTokens: 22_400_000, + inputTokens: 3_100_000, + cacheReadTokens: 17_800_000, + cacheWriteTokens: 810_000, + outputTokens: 480_000, + reasoningTokens: 210_000, + cost: 41.6, + }, + { + ...BASE, + ...NO_TOKENS, + ...at(51, 12_400), + sessionId: "trace:4bf92f3577b34da6a3ce929d0e0e4736", + vendorId: "unknown:my-inhouse-agent", + agentNames: ["nightly-reconcile"], + firstAgentName: "nightly-reconcile", + serviceNames: ["billing"], + models: ["gemini-3-pro"], + llmCalls: 7, + toolCalls: 0, + spanCount: 24, + traceCount: 1, + errorSpanCount: 12, + toolErrorCount: 0, + turnErrorCount: 12, + totalTokens: 812_000, + }, + { + ...BASE, + ...NO_TOKENS, + ...at(118, 640), + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRCD2", + vendorId: "vercel_ai_sdk", + agentNames: ["support-autoresponder-with-a-very-long-name"], + firstAgentName: "support-autoresponder-with-a-very-long-name", + serviceNames: ["support-web", "support-api", "kb-search"], + models: ["claude-haiku-4-5"], + llmCalls: 1, + toolCalls: 0, + spanCount: 4, + traceCount: 1, + }, + { + ...BASE, + ...at(240, 74_000), + sessionId: "wrun_01M0CSAEW96BH2W9185XZPREF3", + vendorId: "crewai", + agentNames: ["coder", "planner", "reviewer"], + firstAgentName: "planner", + serviceNames: ["crew-runner"], + models: ["claude-opus-5"], + llmCalls: 38, + toolCalls: 9, + spanCount: 210, + traceCount: 2, + totalTokens: 640_000, + inputTokens: 210_000, + cacheReadTokens: 300_000, + cacheWriteTokens: 40_000, + outputTokens: 60_000, + reasoningTokens: 30_000, + cost: 7.42, + }, + ] +} diff --git a/apps/web-foldkit/src/lab/agent-tools-detail-fixture.ts b/apps/web-foldkit/src/lab/agent-tools-detail-fixture.ts new file mode 100644 index 0000000000..b806b2aa88 --- /dev/null +++ b/apps/web-foldkit/src/lab/agent-tools-detail-fixture.ts @@ -0,0 +1,673 @@ +// copied: apps/web/src/lab/agent-tools-fixture.ts, the detail half (`SESSION_SEEDS`, the error +// groups, `buildToolErrorDetailFixture`, `buildToolDetailFixture`); apps/web's view types are dropped. + +import { + EMPTY_MEASURES, + type ToolErrorOccurrenceRow, + type ToolErrorRow, + type ToolErrorSessionRow, + type ToolSeriesPoint, + type ToolTotals, +} from "@maple/web-data/lib/tool-analytics" +import type { ErrorDetail } from "../page/agent-sessions/tool/error-modal-model" +import { + BUCKET_MS, + BUCKETS, + MS, + rollup, + type ToolAnalyticsSearch, + type ToolFixtureCell, +} from "./agent-tools-fixture" + +const SESSION_SEEDS = [ + { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRPKH", + agentName: "slack-triage", + model: "claude-sonnet-5", + serviceName: "maple-slack-agent", + tools: ["read_file", "bash", "grep"], + calls: 61, + errors: 0, + avgMs: 14, + maxMs: 340, + minutesAgo: 6, + }, + { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRQ44", + agentName: "deep-research", + model: "gemini-3-pro", + serviceName: "research-worker", + tools: ["web_fetch", "search_source_code"], + calls: 18, + errors: 4, + avgMs: 2_100, + maxMs: 31_000, + minutesAgo: 22, + }, + { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRZZ9", + // No agent name at all: the lane has to hold its width anyway. + agentName: "", + model: "claude-opus-5", + serviceName: "ci-runner", + tools: ["run_tests", "bash", "deploy_preview_environment_and_wait"], + calls: 204, + errors: 51, + avgMs: 48_000, + maxMs: 612_000, + minutesAgo: 58, + }, + { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRAB1", + agentName: "nightly-reconcile", + model: "openai/gpt-5.6", + serviceName: "api", + tools: ["list_dashboards", "bash"], + calls: 7, + errors: 0, + avgMs: 190, + maxMs: 420, + minutesAgo: 190, + }, + { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPRCD2", + agentName: "support-autoresponder-with-a-very-long-name", + model: "claude-sonnet-5", + serviceName: "maple-slack-agent", + tools: ["read_file"], + calls: 2, + errors: 0, + avgMs: 9, + maxMs: 11, + minutesAgo: 340, + }, + { + sessionId: "wrun_01M0CSAEW96BH2W9185XZPREF3", + agentName: "planner", + model: "claude-opus-5", + serviceName: "ci-runner", + tools: ["run_tests"], + calls: 41, + errors: 19, + avgMs: 62_000, + maxMs: 240_000, + minutesAgo: 700, + }, +] as const + +/* ------------------------------------------------------------------------------------------------- + * The tool detail page, and the error group modal it opens + * + * Written from real tool failures rather than invented ones, so the page is + * reviewed against the shapes it will actually meet: a schema decoder's message + * inside a `{"result": …}` envelope, a dominant group that is most of a tool's + * failures, groups that fold several array indices into one, a long tail of + * one-offs, a failure recorded before grouping existed, and a tool that + * reports nothing about why it failed. Counts are verbatim; which day they + * fell on is shaped to show one group that stopped and one that has not. + * -----------------------------------------------------------------------------------------------*/ + +const DAY = 86_400_000 + +interface ErrorGroupSpec { + /** The group's raw text, as the index keeps it. */ + readonly message: string + readonly errorType: string + readonly calls: number + readonly sessions: number + /** The raw texts the group folded, where there is more than one. */ + readonly variants?: ReadonlyArray<{ readonly message: string; readonly calls: number }> + /** Failed calls per day of the week, oldest first. */ + readonly perDay: ReadonlyArray + /** Calls of the tool since the group's latest failure. */ + readonly callsSince: number + /** What a failed call was made with. `''` where the span recorded none. */ + readonly arguments: (index: number) => string + /** Where the failures happen, as `[model, service, share]`. */ + readonly where?: ReadonlyArray +} + +/** A tool result's error envelope, as the maple vendor records one. */ +const envelope = (text: string) => JSON.stringify({ result: text }) + +const FINDINGS_CLAIM = + 'Confirmed: the incident\'s failure is a disk-capacity exhaustion — the embedded chDB store on the local filesystem hit its "no space left on device" ceiling, and at least one trace ingest (POST /v1/traces) was rejected with HTTP 500 as a result.' +const LOG_PATTERN = + 'chDB insert (traces): Code: 1001. DB::Exception: filesystem error: in create_directories: No space left on device ["/var/lib/maple/store/traces"]' + +const evidenceItem = (index: number, omit?: "traceIds" | "logPatterns") => ({ + ...(omit !== "logPatterns" && { logPatterns: [LOG_PATTERN] }), + note: `Single error occurrence at 01:5${index}, one ingest request rejected while the store was full.`, + ...(omit !== "traceIds" && { traceIds: [`4bf92f3577b34da6a3ce929d0e0e47${30 + index}`] }), +}) + +const candidate = (overrides: Record) => + JSON.stringify({ + claim: FINDINGS_CLAIM, + confidence: "high", + mechanism: "The store's volume filled; every insert after that failed until the volume was grown.", + evidence: [evidenceItem(0), evidenceItem(1)], + suggestedActions: ["Grow the store's volume", "Alert on free space below 10%"], + ...overrides, + }) + +const queryData = (overrides: Record) => + JSON.stringify({ + source: "traces", + kind: "breakdown", + start_time: "2026-09-04 00:00:00", + end_time: "2026-09-11 00:00:00", + ...overrides, + }) + +const INVESTIGATIONS: ReadonlyArray = [ + ["z-ai/glm-5.3-flash:nitro", "maple-investigations", 0.912], + ["z-ai/glm-5.3-flash:nitro", "maple-api", 0.067], + ["z-ai/glm-5.3-flash", "maple-investigations", 0.021], +] + +const missingKeyAt = (path: string) => envelope(`Invalid tool input: Missing key\n at ${path}`) +const expectedAt = (expected: string, path: string) => + envelope(`Invalid tool input: Expected ${expected}\n at ${path}`) + +const ERROR_GROUPS = new Map>([ + [ + "submit_candidate", + [ + { + message: expectedAt("array", '["evidence"]'), + errorType: "tool_error", + calls: 387, + sessions: 175, + perDay: [60, 110, 58, 159, 0, 0, 0, 0], + callsSince: 281, + // The model sent the array as a string holding its JSON. + arguments: (index) => + candidate({ evidence: JSON.stringify([evidenceItem(index), evidenceItem(index + 1)]) }), + where: INVESTIGATIONS, + }, + { + message: missingKeyAt('["claim"]'), + errorType: "tool_error", + calls: 116, + sessions: 63, + perDay: [40, 45, 30, 1, 0, 0, 0, 0], + callsSince: 290, + arguments: () => "{}", + where: INVESTIGATIONS, + }, + { + message: missingKeyAt('["evidence"][0]["logPatterns"]'), + errorType: "tool_error", + calls: 18, + sessions: 18, + variants: [ + { message: missingKeyAt('["evidence"][0]["logPatterns"]'), calls: 12 }, + { message: missingKeyAt('["evidence"][1]["logPatterns"]'), calls: 6 }, + ], + perDay: [5, 8, 5, 0, 0, 0, 0, 0], + callsSince: 402, + arguments: (index) => + candidate({ + evidence: + index % 3 === 2 + ? [evidenceItem(0), evidenceItem(1, "logPatterns")] + : [evidenceItem(0, "logPatterns"), evidenceItem(1)], + }), + }, + { + message: missingKeyAt('["evidence"][0]["traceIds"]'), + errorType: "tool_error", + calls: 9, + sessions: 9, + variants: [ + { message: missingKeyAt('["evidence"][0]["traceIds"]'), calls: 4 }, + { message: missingKeyAt('["evidence"][1]["traceIds"]'), calls: 3 }, + { message: missingKeyAt('["evidence"][2]["traceIds"]'), calls: 2 }, + ], + perDay: [3, 4, 1, 1, 0, 0, 0, 0], + callsSince: 284, + arguments: (index) => + candidate({ + evidence: [0, 1, 2].map((item) => + evidenceItem( + item, + item === variantIndex(index, [4, 3, 2]) ? "traceIds" : undefined, + ), + ), + }), + }, + { + message: missingKeyAt('["mechanism"]'), + errorType: "tool_error", + calls: 6, + sessions: 6, + perDay: [1, 2, 2, 1, 0, 0, 0, 0], + callsSince: 300, + arguments: () => + JSON.stringify({ + claim: FINDINGS_CLAIM, + confidence: "medium", + evidence: [evidenceItem(0)], + }), + }, + { + message: expectedAt("array", '["suggestedActions"]'), + errorType: "tool_error", + calls: 6, + sessions: 5, + perDay: [2, 1, 2, 1, 0, 0, 0, 0], + callsSince: 296, + arguments: () => candidate({ suggestedActions: JSON.stringify(["Grow the store's volume"]) }), + }, + { + message: expectedAt("object", '["evidence"][0]'), + errorType: "tool_error", + calls: 3, + sessions: 3, + perDay: [1, 0, 1, 1, 0, 0, 0, 0], + callsSince: 330, + // A string the schema cannot read as an object: no hint claims otherwise. + arguments: () => candidate({ evidence: ["disk full on the store volume"] }), + }, + ], + ], + [ + "query_data", + [ + { + message: + "Tool failed: `group_by=attribute` requires `attribute_key`. Use explore_attributes to discover available keys.", + errorType: "tool_error", + calls: 14, + sessions: 13, + perDay: [1, 2, 1, 3, 2, 1, 3, 1], + callsSince: 40, + arguments: () => queryData({ group_by: "attribute" }), + }, + { + message: + 'Tool failed: Invalid group_by "service.version" for source="traces" kind="breakdown". Valid group_by values: "service", "span_name", "status_code", "http_method", "attribute".', + errorType: "tool_error", + calls: 7, + sessions: 6, + perDay: [0, 2, 1, 2, 0, 1, 1, 0], + callsSince: 210, + arguments: () => queryData({ group_by: "service.version" }), + }, + { + message: + 'Tool failed: Invalid parameters: SchemaError(Missing key at ["source"]). Check the "query_data" tool schema for valid parameter names and types.', + errorType: "tool_error", + calls: 5, + sessions: 5, + perDay: [1, 1, 0, 2, 0, 1, 0, 0], + callsSince: 260, + arguments: () => JSON.stringify({ kind: "timeseries", group_by: "service" }), + }, + { + message: + 'Tool failed: Invalid parameters: SchemaError(Missing key at ["kind"]). Check the "query_data" tool schema for valid parameter names and types.', + errorType: "tool_error", + calls: 5, + sessions: 3, + perDay: [0, 0, 3, 0, 1, 0, 1, 0], + callsSince: 120, + arguments: () => JSON.stringify({ source: "logs", group_by: "service" }), + }, + { + message: + 'Tool failed: Invalid group_by "service.version" for source="traces" kind="timeseries". Valid group_by values: "service", "span_name", "none".', + errorType: "tool_error", + calls: 3, + sessions: 2, + perDay: [0, 1, 2, 0, 0, 0, 0, 0], + callsSince: 900, + arguments: () => queryData({ kind: "timeseries", group_by: "service.version" }), + }, + { + message: + "Tool failed: `source=metrics` requires `metric_name` and `metric_type`. Use list_metrics to discover available metrics.", + errorType: "tool_error", + calls: 3, + sessions: 1, + perDay: [0, 0, 0, 0, 0, 3, 0, 0], + callsSince: 510, + arguments: () => queryData({ source: "metrics" }), + }, + { + message: + 'Tool failed: Invalid group_by "commit_shas" for source="traces" kind="breakdown". Valid group_by values: "service", "span_name", "status_code", "http_method", "attribute".', + errorType: "tool_error", + calls: 3, + sessions: 3, + perDay: [1, 0, 1, 0, 0, 1, 0, 0], + callsSince: 520, + arguments: () => queryData({ group_by: "commit_shas" }), + }, + { + message: + "Tool failed: @maple/http/errors/QueryEngineValidationError: Timeseries query too expensive\nRequested 2154 points, maximum is 1500", + errorType: "tool_error", + calls: 2, + sessions: 2, + variants: [ + { + message: + "Tool failed: @maple/http/errors/QueryEngineValidationError: Timeseries query too expensive\nRequested 2154 points, maximum is 1500", + calls: 1, + }, + { + message: + "Tool failed: @maple/http/errors/QueryEngineValidationError: Timeseries query too expensive\nRequested 1790 points, maximum is 1500", + calls: 1, + }, + ], + perDay: [0, 0, 1, 0, 0, 0, 1, 0], + callsSince: 150, + arguments: () => queryData({ kind: "timeseries", bucket_seconds: 280 }), + }, + { + message: + 'Tool failed: Invalid parameters: SchemaError(`2026-09-04 00:00` is not a timestamp — expected `YYYY-MM-DD HH:mm:ss` (UTC) or an ISO-8601 timestamp at ["start_time"]). Check the "query_data" tool schema for valid parameter names and types.', + errorType: "tool_error", + calls: 1, + sessions: 1, + perDay: [0, 0, 1, 0, 0, 0, 0, 0], + callsSince: 1_200, + arguments: () => queryData({ start_time: "2026-09-04 00:00" }), + }, + { + message: + 'Tool failed: Invalid group_by "vcs.ref.head.revision" for source="traces" kind="breakdown". Valid group_by values: "service", "span_name", "status_code", "http_method", "attribute".', + errorType: "tool_error", + calls: 1, + sessions: 1, + perDay: [0, 0, 0, 0, 1, 0, 0, 0], + callsSince: 700, + arguments: () => queryData({ group_by: "vcs.ref.head.revision" }), + }, + ...[ + "Tool failed: Timeout exceeded: elapsed 15346.717367 ms, maximum: 15000 ms (query_id=01M1XS1YN5140J9SMWJTC5GGMJ)", + "Tool failed: Unknown metric `http.server.request.duration`. Use list_metrics to discover available metrics.", + 'Tool failed: Invalid filter "status_code=5xx". Filters compare one attribute to one value.', + "Tool failed: Requested 171.3 hours, maximum is 168 hours", + "Tool failed: `kind=breakdown` does not accept `bucket_seconds`.", + "Tool failed: Repository 'maple/maple-api' is not connected to this organization", + ].map((message, index): ErrorGroupSpec => ({ + message, + errorType: "tool_error", + calls: 1, + sessions: 1, + perDay: [0, 1, 2, 3, 4, 5, 6, 7].map((day) => (day === index + 1 ? 1 : 0)), + callsSince: 300 + index * 80, + arguments: () => queryData({}), + })), + // Recorded before failures kept their text: one group, named for what it is. + { + message: "", + errorType: "", + calls: 1, + sessions: 1, + perDay: [1, 0, 0, 0, 0, 0, 0, 0], + callsSince: 1_700, + arguments: () => "", + }, + ], + ], + [ + "sandbox_exec", + [ + { + // The framework reports a generic message and records neither the + // arguments nor the result: the cause is not in the telemetry. + message: "effect-agent.execute_tool: Tool execution reached a failed terminal state", + errorType: "ToolCallFailed", + calls: 4, + sessions: 3, + perDay: [0, 2, 0, 1, 0, 1, 0, 0], + callsSince: 0, + arguments: () => "", + where: [["", "maple-chat", 1]], + }, + ], + ], +]) + +/** The tools the lab's detail view can open, and the shape each one shows. */ +export const DETAIL_TOOLS = ["submit_candidate", "query_data", "sandbox_exec", "grep"] as const + +/** Which of a group's variants the `index`-th sample is, spread by their counts. */ +function variantIndex(index: number, counts: ReadonlyArray): number { + const total = counts.reduce((sum, count) => sum + count, 0) + let slot = index % total + for (const [position, count] of counts.entries()) { + if (slot < count) return position + slot -= count + } + return 0 +} + +/** A stable fake fingerprint per group — any decimal UInt64 will do. */ +const fingerprintOf = (message: string, index: number) => + message === "" + ? "0" + : String( + [...message].reduce( + (hash, char) => (hash * 31n + BigInt(char.charCodeAt(0))) % 18_446_744_073_709_551_557n, + BigInt(index + 7), + ), + ) + +const dayStart = (ms: number) => Math.floor(ms / DAY) * DAY + +/** The error groups of one tool, as the Errors read returns them. */ +export function buildToolErrorsFixture(tool: string, nowMs: number): ReadonlyArray { + const today = dayStart(nowMs) + return (ERROR_GROUPS.get(tool) ?? []).map((spec, index) => { + const days = spec.perDay.map((calls, day) => ({ + bucket: today - (spec.perDay.length - 1 - day) * DAY, + calls, + })) + const active = days.filter((day) => day.calls > 0) + const lastDay = active[active.length - 1]?.bucket ?? today + return { + fingerprint: fingerprintOf(spec.message, index), + errorType: spec.errorType, + message: spec.message, + calls: spec.calls, + sessions: spec.sessions, + variants: spec.variants?.length ?? 1, + firstSeen: (active[0]?.bucket ?? today) + 9 * 3_600_000 + index * 60_000, + // The newest day at 23:48, or the morning for a group still failing today. + lastSeen: Math.min( + lastDay + 23 * 3_600_000 + 48 * 60_000 - index * 67_000, + nowMs - 22 * 3_600_000 - index * 60_000, + ), + callsSince: spec.callsSince, + trend: days.filter((day) => day.calls > 0), + } + }) +} + +const SAMPLE_SESSION_IDS = [ + "7a3e91c4-5b02-4d8f-9c11-2f6b0e4a7d19", + "2f8b06d7-91ce-4a35-8b70-5d2c6e9f0a41", + "c41e5a90-0d7b-4f62-a3e8-91b7d24c6f05", + "e03b7d21-6a4f-4c9e-b812-7f05a3d9c2e6", + "9e2144b4-626f-4633-8ca5-ac2771f9d6e3", + "41c7d0a9-3be2-4f18-9d6a-5e08b7c21f94", +] + +/** One group's facts and a page of its samples, as the detail and samples reads + * return them. `pages` stands in for the reader's "Load 25 more" clicks. */ +export function buildToolErrorDetailFixture( + tool: string, + row: ToolErrorRow, + options: { readonly session?: string; readonly variant?: string; readonly pages: number }, +): { + readonly detail: ErrorDetail + readonly occurrences: ReadonlyArray + readonly hasMore: boolean +} { + const spec = (ERROR_GROUPS.get(tool) ?? []).find( + (candidate, index) => fingerprintOf(candidate.message, index) === row.fingerprint, + ) + if (spec === undefined) + return { detail: { sessions: [], variants: [], breakdown: [] }, occurrences: [], hasMore: false } + const variants = spec.variants ?? [{ message: spec.message, calls: spec.calls }] + const where = spec.where ?? [["z-ai/glm-5.3-flash:nitro", "maple-investigations", 1] as const] + const sessionIds = SAMPLE_SESSION_IDS.slice(0, Math.min(SAMPLE_SESSION_IDS.length, spec.sessions)) + + const sessions: ReadonlyArray = sessionIds.map((sessionId, index) => ({ + sessionId, + vendorId: "maple", + agentName: "investigation-lane", + service: where[index % where.length]![1], + hits: Math.max(1, Math.round(spec.calls / (spec.sessions + index))), + lastSeen: row.lastSeen - index * 47 * 60_000, + })) + + const all: ReadonlyArray = Array.from({ length: spec.calls }, (_, index) => { + const variant = + variants[ + variantIndex( + index, + variants.map((candidate) => candidate.calls), + ) + ]! + const [model, service] = where[index % where.length]! + const args = spec.arguments(index) + const result = spec.message.startsWith("{") ? variant.message : "" + return { + timestamp: row.lastSeen - index * 37 * 60_000, + traceId: `4bf92f3577b34da6a3ce929d${index.toString(16).padStart(8, "0")}`, + spanId: `a1b2c3d4${index.toString(16).padStart(8, "0")}`, + sessionId: sessionIds[index % sessionIds.length] ?? "trace:4bf92f3577b34da6a3ce929d0e0e4736", + vendorId: "maple", + agentName: "investigation-lane", + model, + service, + errorType: spec.errorType, + message: variant.message, + durationNs: (index % 3) * MS, + statusCode: result === "" ? "Error" : "Ok", + arguments: args, + argumentsBytes: new TextEncoder().encode(args).length, + result, + resultBytes: new TextEncoder().encode(result).length, + } + }) + const narrowed = all.filter( + (occurrence) => + (options.session === undefined || occurrence.sessionId === options.session) && + (options.variant === undefined || occurrence.message === options.variant), + ) + return { + detail: { + sessions, + variants: + spec.variants === undefined + ? [{ message: spec.message, calls: spec.calls, lastSeen: row.lastSeen }] + : variants.map((candidate, index) => ({ + ...candidate, + lastSeen: row.lastSeen - index * 3_600_000, + })), + breakdown: where.map(([model, service, share]) => ({ + model, + service, + calls: Math.max(1, Math.round(spec.calls * share)), + })), + }, + occurrences: narrowed.slice(0, 25 * options.pages), + hasMore: narrowed.length > 25 * options.pages, + } +} + +/** The sessions list the detail page shows, in the list read's own row shape — + * narrowed like the metrics: a seed survives only where the scoped cells hold + * its service (which carries the env), and under the selected model. The tools + * the lab borrows from production have no seeds of their own, so they take + * every seed that ran under their scope. */ +function detailSessions( + tool: string, + nowMs: number, + scoped: ReadonlyArray, + model: string | undefined, +) { + const seeded = SESSION_SEEDS.some((seed) => (seed.tools as ReadonlyArray).includes(tool)) + return SESSION_SEEDS.filter( + (seed) => + (!seeded || + ((seed.tools as ReadonlyArray).includes(tool) && + scoped.some((cell) => cell.service === seed.serviceName))) && + (model === undefined || seed.model === model), + ).map((seed, index) => { + const startedAt = nowMs - seed.minutesAgo * 60_000 + const durationMs = seed.maxMs * 4 + 12_000 + return { + sessionId: seed.sessionId, + vendorId: ["eve", "claude_agent_sdk", "vercel_ai_sdk", "langchain"][index % 4]!, + vendorVersion: ["v1.4.2", "v0.9.1", "v5.0.4", "v0.3.27"][index % 4]!, + traceCount: 1 + (index % 6), + spanCount: 22 + index * 97, + errorSpanCount: seed.errors, + toolErrorCount: seed.errors, + turnErrorCount: 0, + serviceNames: [seed.serviceName], + models: [seed.model], + agentNames: seed.agentName === "" ? [] : [seed.agentName], + firstAgentName: seed.agentName, + llmCalls: Math.round(seed.calls / 3), + toolCalls: seed.calls, + totalTokens: seed.calls * 900, + inputTokens: seed.calls * 600, + cacheReadTokens: seed.calls * 200, + cacheWriteTokens: 0, + outputTokens: seed.calls * 100, + reasoningTokens: 0, + cost: seed.calls * 0.004, + startTime: new Date(startedAt).toISOString().replace("T", " ").slice(0, 23), + endTime: new Date(startedAt + durationMs).toISOString().replace("T", " ").slice(0, 23), + durationMs, + } + }) +} + +/** `/agent-sessions/tools/$toolName` over the same week the overview draws. */ +export function buildToolDetailFixture( + tool: string, + search: ToolAnalyticsSearch, + nowMs: number, + cells: ReadonlyArray, +) { + const scoped = cells.filter( + (cell) => + cell.tool === tool && + (search.model === undefined || cell.model === search.model) && + (search.service === undefined || cell.service === search.service) && + (search.env === undefined || cell.env === search.env), + ) + const series: ToolSeriesPoint[] = [...rollup(scoped, (cell) => `${cell.bucket}`).entries()].map( + ([bucket, value]) => ({ bucket: Number(bucket), seriesKey: tool, ...value }), + ) + const totals: ToolTotals = rollup(scoped, () => "all").get("all") ?? EMPTY_MEASURES + const sessions = detailSessions(tool, nowMs, scoped, search.model) + + return { + series, + totals, + scopeCalls: cells.filter((cell) => cell.tool === tool).reduce((sum, cell) => sum + cell.calls, 0), + firstSeen: scoped.reduce((min, cell) => (min === 0 ? cell.bucket : Math.min(min, cell.bucket)), 0), + lastSeen: scoped.reduce((max, cell) => Math.max(max, cell.bucket), 0), + description: `Runs ${tool} in the agent's workspace and returns its output, truncated to the last 4,000 characters.`, + range: { startMs: nowMs - BUCKETS * BUCKET_MS, endMs: nowMs }, + errors: buildToolErrorsFixture(tool, nowMs), + errorsLoading: false, + errorsFailure: undefined, + sessions, + sessionsCapped: false, + sessionsLoading: false, + sessionsFailure: undefined, + } +} diff --git a/apps/web-foldkit/src/lab/agent-tools-detail.ts b/apps/web-foldkit/src/lab/agent-tools-detail.ts new file mode 100644 index 0000000000..4d7744d25a --- /dev/null +++ b/apps/web-foldkit/src/lab/agent-tools-detail.ts @@ -0,0 +1,196 @@ +import { OrgId } from "@maple/domain" +import { AiSessionListItem, AiToolErrorSampleCursor } from "@maple/domain/http" +import { formatWarehouseDateTime } from "@maple/query-engine" +import { Option, Result, Schema } from "effect" +import { Command } from "foldkit" +import { fromString } from "foldkit/url" + +import type { PageContext, PageOutMessage } from "../page/contract" +import * as ErrorModal from "../page/agent-sessions/tool/error-modal-model" +import { Message as ToolMessage } from "../page/agent-sessions/tool/message" +import type { Model as ToolModel } from "../page/agent-sessions/tool/model" +import { routeChanged, update as toolUpdate } from "../page/agent-sessions/tool/update" +import { Selection } from "../page/agent-sessions/tools-kit/reads" +import type { Resources } from "../platform/resources" +import { urlToAppRoute } from "../route" +import { BUCKET_MS, BUCKETS, buildToolCells, toolFixtureFacets } from "./agent-tools-fixture" +import { buildToolDetailFixture, buildToolErrorDetailFixture, buildToolErrorsFixture } from "./agent-tools-detail-fixture" + +// The tools lab's "detail" view: the tool detail page and its error modal over the fixture week, +// its reads answered in place and its URL the lab's state, as `agent-tools-lab.tsx` drives them. + +type PageCommand = Command.Command +export type DetailStep = Readonly<{ model: ToolModel; commands?: ReadonlyArray; outMessage?: PageOutMessage }> +export type Settled = Readonly<{ model: ToolModel; commands: ReadonlyArray }> + +export const contextOf = (timeZone: string): PageContext => ({ + orgId: Schema.decodeSync(OrgId)("org_lab"), + pinnedNamespace: null, + timeZone, +}) + +const SelectionArgs = Schema.Struct({ key: Schema.String, selection: Selection }) +const decodeSelection = Schema.decodeUnknownOption(SelectionArgs) +const decodeKey = Schema.decodeUnknownOption(Schema.Struct({ key: Schema.String })) +const SamplesArgs = Schema.Struct({ + key: Schema.String, + fingerprint: Schema.String, + session: Schema.UndefinedOr(Schema.String), + variant: Schema.UndefinedOr(Schema.String), + before: Schema.UndefinedOr(AiToolErrorSampleCursor), +}) +const decodeSamples = Schema.decodeUnknownOption(SamplesArgs) +const decodeSessions = Schema.decodeUnknownOption(Schema.Array(AiSessionListItem)) + +const SAMPLES_PAGE = 25 + +/** The fixture's reading of the page's search: the toolbar's filters as the lab's state carries them. */ +const fixtureOf = (model: ToolModel) => + buildToolDetailFixture( + model.toolName, + { service: model.search.service, env: model.search.env, model: model.search.model }, + model.nowMs, + buildToolCells(model.nowMs), + ) + +const errorRowOf = (model: ToolModel, fingerprint: string) => + buildToolErrorsFixture(model.toolName, model.nowMs).find((row) => row.fingerprint === fingerprint) + +const modalAnswer = (message: ErrorModal.Message) => ToolMessage.GotErrorModalMessage({ message }) + +/** One page of the open group's samples, after the cursor the page holds, 25 at a time. */ +const samplesPage = (model: ToolModel, args: typeof SamplesArgs.Type) => { + const row = errorRowOf(model, args.fingerprint) + if (row === undefined) return { occurrences: [], nextCursor: undefined } + const all = buildToolErrorDetailFixture(model.toolName, row, { + session: args.session, + variant: args.variant, + pages: Number.POSITIVE_INFINITY, + }).occurrences + const start = args.before === undefined ? 0 : all.findIndex((sample) => sample.spanId === args.before?.spanId) + 1 + const occurrences = all.slice(start, start + SAMPLES_PAGE) + const last = occurrences.at(-1) + return { + occurrences, + nextCursor: + last === undefined || start + SAMPLES_PAGE >= all.length + ? undefined + : { timestamp: formatWarehouseDateTime(last.timestamp), spanId: last.spanId }, + } +} + +/** The fixture's answer to one of the page's reads; `None` for a Command that is not a read. */ +const answer = (command: PageCommand, model: ToolModel): Option.Option => { + const data = () => fixtureOf(model) + switch (command.name) { + case "FetchAgentToolSeries": + return Option.map(decodeKey(command.args), ({ key }) => + ToolMessage.CompletedFetchSeries({ key, result: Result.succeed({ data: data().series, seriesKind: "none" }) }), + ) + case "FetchAgentToolTotals": + return Option.map(decodeSelection(command.args), ({ key }) => { + const fixture = data() + return ToolMessage.CompletedFetchTotals({ + key, + result: Result.succeed({ + current: fixture.totals, + previous: undefined, + allSessions: 0, + firstSeen: fixture.firstSeen, + lastSeen: fixture.lastSeen, + description: fixture.description, + }), + }) + }) + case "FetchAgentToolErrors": + return Option.map(decodeKey(command.args), ({ key }) => + ToolMessage.CompletedFetchErrors({ key, result: Result.succeed({ data: data().errors }) }), + ) + case "FetchAgentToolSessions": + return Option.map(decodeKey(command.args), ({ key }) => + ToolMessage.CompletedFetchSessions({ + key, + result: Result.succeed({ data: Option.getOrElse(decodeSessions(data().sessions), () => []) }), + }), + ) + case "FetchAgentToolFacets": + return Option.map(decodeKey(command.args), ({ key }) => + ToolMessage.CompletedFetchFacets({ + key, + result: Result.succeed({ vendors: [], agents: [], tools: [], ...toolFixtureFacets(buildToolCells(model.nowMs)) }), + }), + ) + default: + return answerModal(command, model) + } +} + +/** The modal's reads, lifted into the page's Commands under their own names. */ +const answerModal = (command: PageCommand, model: ToolModel): Option.Option => { + switch (command.name) { + case "FetchAgentToolErrorDetail": + return Option.flatMap( + Schema.decodeUnknownOption(Schema.Struct({ key: Schema.String, fingerprint: Schema.String }))(command.args), + ({ key, fingerprint }) => + Option.map(Option.fromUndefinedOr(errorRowOf(model, fingerprint)), (row) => + modalAnswer( + ErrorModal.Message.CompletedFetchDetail({ + key, + result: Result.succeed(buildToolErrorDetailFixture(model.toolName, row, { pages: 1 }).detail), + }), + ), + ), + ) + case "FetchAgentToolErrorSamples": + return Option.map(decodeSamples(command.args), (args) => + modalAnswer(ErrorModal.Message.CompletedFetchSamples({ key: args.key, result: Result.succeed(samplesPage(model, args)) })), + ) + default: + return Option.none() + } +} + +/** The fixture's own week: `range` is `now - 56 buckets .. now`, as the legacy lab draws it. */ +const fixtureWindow = (command: PageCommand): PageCommand => + command.name === "ResolveAgentToolWindow" + ? Command.mapMessage(command, (message) => + message._tag === "CompletedResolveWindow" + ? ToolMessage.CompletedResolveWindow({ + ...message, + window: { + startTime: formatWarehouseDateTime(message.nowMs - BUCKETS * BUCKET_MS), + endTime: formatWarehouseDateTime(message.nowMs), + }, + }) + : message, + ) + : command + +/** The page's navigations become the lab's state: the new search re-enters the page as the route would. */ +const followNavigation = (model: ToolModel, outMessage: PageOutMessage | undefined, timeZone: string) => { + if (outMessage?._tag !== "RequestedNavigation") return Option.none() + return Option.flatMap(Option.map(fromString(`http://lab${outMessage.url}`), urlToAppRoute), (route) => + route._tag === "AgentSessionTool" + ? Option.some(routeChanged(model, { toolName: route.toolName, search: route.search }, contextOf(timeZone))) + : Option.none(), + ) +} + +/** Runs a page step to rest: reads are answered in place, navigations re-enter, the rest is returned. */ +export const settle = (step: DetailStep, timeZone: string): Settled => { + const resolved = (step.commands ?? []).map((command) => [command, answer(command, step.model)] as const) + const rest = resolved.flatMap(([command, maybe]) => (Option.isNone(maybe) ? [fixtureWindow(command)] : [])) + const navigated = Option.match(followNavigation(step.model, step.outMessage, timeZone), { + onNone: (): Settled => ({ model: step.model, commands: [] }), + onSome: (next) => settle(next, timeZone), + }) + return resolved + .flatMap(([, maybe]) => Option.toArray(maybe)) + .reduce( + (result, message) => { + const next = settle(toolUpdate(result.model, message, contextOf(timeZone)), timeZone) + return { model: next.model, commands: [...result.commands, ...next.commands] } + }, + { model: navigated.model, commands: [...rest, ...navigated.commands] }, + ) +} diff --git a/apps/web-foldkit/src/lab/agent-tools-fixture.ts b/apps/web-foldkit/src/lab/agent-tools-fixture.ts new file mode 100644 index 0000000000..ba07d511ee --- /dev/null +++ b/apps/web-foldkit/src/lab/agent-tools-fixture.ts @@ -0,0 +1,402 @@ +// copied: apps/web/src/lab/agent-tools-fixture.ts, the overview half (`buildToolCells`, +// `buildToolAnalyticsFixture`, `toolFixtureFacets`); apps/web's view types are restated here. + +import type { AiToolsSeriesKind } from "@maple/domain/http" +import { + EMPTY_MEASURES, + type ToolBreakdownRow, + type ToolMeasures, + type ToolSeriesPoint, + type ToolTotals, +} from "@maple/web-data/lib/tool-analytics" + +/** The lab's stand-in for the URL: the toolbar's filters and the scope chips. */ +export type ToolAnalyticsSearch = Readonly<{ + q?: string + service?: string + env?: string + failing?: boolean + tool?: string + model?: string +}> + +/** `AgentToolsViewData`: what the page's reads return, derived from the cells. */ +export type AgentToolsViewData = Readonly<{ + series: ReadonlyArray + scopeSeries: ReadonlyArray + seriesKind: AiToolsSeriesKind + totals: ToolTotals + previousTotals: ToolTotals | undefined + allSessions: number + scopeCalls: number + tools: ReadonlyArray +}> + +export const HOUR = 3_600_000 +export const BUCKET_MS = 3 * HOUR +export const BUCKETS = 56 +/** Nanoseconds per millisecond — the fixture is written in ms, the view model is ns. */ +export const MS = 1_000_000 + +interface ToolProfile { + readonly name: string + readonly service: string + readonly env: string + /** Calls per bucket at the profile's baseline. */ + readonly rate: number + readonly errorRate: number + readonly p50Ms: number + /** Tail multiplier on p50 — 30 is the "usually instant, sometimes hangs" shape. */ + readonly tail: number + readonly models: ReadonlyArray +} + +const PROFILES: ReadonlyArray = [ + { + name: "read_file", + service: "maple-slack-agent", + env: "production", + rate: 42, + errorRate: 0.002, + p50Ms: 8, + tail: 4, + models: ["claude-sonnet-5", "claude-opus-5"], + }, + { + name: "bash", + service: "maple-slack-agent", + env: "production", + rate: 26, + errorRate: 0.06, + p50Ms: 220, + tail: 30, + models: ["claude-sonnet-5", "claude-opus-5", "openai/gpt-5.6"], + }, + { + name: "run_tests", + service: "ci-runner", + env: "production", + rate: 9, + errorRate: 0.21, + p50Ms: 41_000, + tail: 6, + models: ["claude-opus-5", "openai/gpt-5.6"], + }, + { + name: "search_source_code", + service: "research-worker", + env: "staging", + rate: 14, + errorRate: 0.01, + p50Ms: 380, + tail: 9, + models: ["claude-sonnet-5", "gemini-3-pro"], + }, + { + name: "deploy_preview_environment_and_wait", + service: "ci-runner", + env: "staging", + rate: 3, + errorRate: 0.44, + p50Ms: 96_000, + tail: 3, + models: ["claude-opus-5"], + }, + { + name: "web_fetch", + service: "research-worker", + env: "production", + rate: 6, + errorRate: 0.11, + p50Ms: 1_400, + tail: 12, + models: ["gemini-3-pro", "openai/gpt-5.6"], + }, + { + name: "grep", + service: "maple-slack-agent", + env: "production", + rate: 4, + errorRate: 0, + p50Ms: 30, + tail: 3, + models: ["claude-sonnet-5"], + }, + { + name: "list_dashboards", + service: "api", + env: "production", + rate: 2, + errorRate: 0, + p50Ms: 120, + tail: 2, + models: ["claude-sonnet-5"], + }, + // The three tools the detail view opens on, at the volumes their failures + // were recorded against — see the error groups further down. + { + name: "submit_candidate", + service: "maple-investigations", + env: "production", + rate: 18, + errorRate: 0.55, + p50Ms: 1, + tail: 3, + models: ["z-ai/glm-5.3-flash:nitro"], + }, + { + name: "query_data", + service: "maple-investigations", + env: "production", + rate: 32, + errorRate: 0.03, + p50Ms: 640, + tail: 8, + models: ["z-ai/glm-5.3-flash:nitro"], + }, + { + name: "sandbox_exec", + service: "maple-chat", + env: "production", + rate: 2, + errorRate: 0.1, + p50Ms: 8_400, + tail: 4, + models: ["claude-sonnet-5"], + }, +] + +/** Deterministic pseudo-noise: a fixture that changed on reload is useless for review. */ +function wobble(seed: number): number { + const x = Math.sin(seed * 12.9898) * 43758.5453 + return x - Math.floor(x) +} + +export interface ToolFixtureCell extends ToolMeasures { + readonly bucket: number + readonly tool: string + readonly model: string + readonly service: string + readonly env: string +} + +/** The week, as flat cells. One pass, so the whole lab shares one dataset. */ +export function buildToolCells(nowMs: number): ReadonlyArray { + const cells: ToolFixtureCell[] = [] + const start = nowMs - BUCKETS * BUCKET_MS + + PROFILES.forEach((profile, profileIndex) => { + for (let index = 0; index < BUCKETS; index++) { + const bucket = start + index * BUCKET_MS + // A diurnal swing plus per-bucket noise, so the lines have shape. + const hourOfDay = ((bucket / HOUR) % 24) / 24 + const diurnal = 0.55 + 0.45 * Math.sin(hourOfDay * Math.PI * 2 - 1.2) + const noise = 0.7 + wobble(profileIndex * 97 + index) * 0.6 + // `run_tests` regresses through the back half of the window. It is the + // one thing this page exists to make visible, so the fixture has one. + const regression = + profile.name === "run_tests" && index > BUCKETS * 0.6 ? 1 + (index - BUCKETS * 0.6) / 14 : 1 + + profile.models.forEach((model, modelIndex) => { + const share = 1 / profile.models.length + const calls = Math.max(0, Math.round(profile.rate * diurnal * noise * share)) + if (calls === 0) return + // One model is consistently the worse one — the comparison the Models + // panel exists for. + const modelPenalty = modelIndex === 0 ? 1 : 1.6 + const errors = Math.round(calls * profile.errorRate * modelPenalty * regression) + const p50 = + profile.p50Ms * MS * modelPenalty * regression * (0.85 + wobble(index + modelIndex) * 0.3) + cells.push({ + bucket, + tool: profile.name, + model, + service: profile.service, + env: profile.env, + calls, + // Roughly a session per handful of calls, floored at one. + sessions: Math.max(1, Math.round(calls / 6)), + errors: Math.min(calls, errors), + p50, + p90: p50 * (1 + (profile.tail - 1) * 0.35), + p95: p50 * profile.tail, + }) + }) + } + }) + + return cells +} +/** + * Roll cells up by a key, adding the counts and call-weighting the percentiles. + * A fixture's shortcut only: two p90s do not combine into the p90 of their + * union, which is why the real reads merge inside the query. + */ +export function rollup( + cells: ReadonlyArray, + keyOf: (cell: ToolFixtureCell) => string, +): Map { + const sums = new Map() + const weights = new Map() + + for (const cell of cells) { + const key = keyOf(cell) + const previous = sums.get(key) ?? EMPTY_MEASURES + const weight = weights.get(key) ?? { p50: 0, p90: 0, p95: 0 } + weights.set(key, { + p50: weight.p50 + cell.p50 * cell.calls, + p90: weight.p90 + cell.p90 * cell.calls, + p95: weight.p95 + cell.p95 * cell.calls, + }) + sums.set(key, { + calls: previous.calls + cell.calls, + sessions: previous.sessions + cell.sessions, + errors: previous.errors + cell.errors, + p50: 0, + p90: 0, + p95: 0, + }) + } + + const out = new Map() + for (const [key, value] of sums) { + const weight = weights.get(key)! + out.set(key, { + ...value, + p50: value.calls > 0 ? weight.p50 / value.calls : 0, + p90: value.calls > 0 ? weight.p90 / value.calls : 0, + p95: value.calls > 0 ? weight.p95 / value.calls : 0, + }) + } + return out +} + +/** + * The page's data under a scope, derived the way the four endpoints would derive + * it — including the asymmetry that matters: the Tools table ignores the tool + * selection and the Models panel ignores the model selection, so neither can + * strand the reader on a table holding only the row they already picked. + */ +export function buildToolAnalyticsFixture( + search: ToolAnalyticsSearch, + nowMs: number, + cells: ReadonlyArray, +): AgentToolsViewData { + const needle = (search.q ?? "").trim().toLowerCase() + const filtered = cells.filter((cell) => { + if (needle !== "" && !cell.tool.toLowerCase().includes(needle)) return false + if (search.service !== undefined && cell.service !== search.service) return false + if (search.env !== undefined && cell.env !== search.env) return false + if (search.failing === true && cell.errors === 0) return false + return true + }) + + const scoped = filtered.filter( + (cell) => + (search.tool === undefined || cell.tool === search.tool) && + (search.model === undefined || cell.model === search.model), + ) + + // The chart's split follows the selection, exactly as `aiToolsSeriesKind` + // derives it server-side: by tool until a tool is picked, by the models that + // tool ran under until a model is, then a single series still named by the + // tool. + const seriesKind: AiToolsSeriesKind = + search.tool !== undefined && search.model === undefined ? "model" : "tool" + const seriesKeyOf = (cell: ToolFixtureCell) => (seriesKind === "model" ? cell.model : cell.tool) + + // `\u0000` separates the rollup key's two halves: it is the one character a + // tool or model name cannot contain. + const series: ToolSeriesPoint[] = [ + ...rollup(scoped, (cell) => `${cell.bucket}\u0000${seriesKeyOf(cell)}`).entries(), + ].map(([key, value]) => { + const [bucket, seriesKey] = key.split("\u0000") + return { bucket: Number(bucket), seriesKey: seriesKey!, ...value } + }) + + // The chart's read: the same scope, one series (`split: "none"`). + const scopeSeries: ToolSeriesPoint[] = [...rollup(scoped, (cell) => `${cell.bucket}`).entries()].map( + ([bucket, value]) => ({ bucket: Number(bucket), seriesKey: "", ...value }), + ) + + const totals: ToolTotals = rollup(scoped, () => "all").get("all") ?? EMPTY_MEASURES + + // The comparison window. The fixture holds no earlier data, so it is modelled + // as "busier and slower than last week, but failing less" — enough to put + // every delta arrow and both colours on screen at once. + const previousTotals: ToolTotals = { + calls: Math.round(totals.calls * 0.88), + sessions: Math.round(totals.sessions * 0.93), + errors: Math.round(totals.errors * 1.15), + p50: totals.p50 * 0.96, + p90: totals.p90 * 0.74, + p95: totals.p95 * 0.7, + } + + const lastSeenBy = (keyOf: (cell: ToolFixtureCell) => string, pick: (a: number, b: number) => number) => { + const out = new Map() + for (const cell of filtered) { + const key = keyOf(cell) + const seen = out.get(key) + out.set(key, seen === undefined ? cell.bucket : pick(seen, cell.bucket)) + } + return out + } + + const breakdown = ( + rows: ReadonlyArray, + keyOf: (cell: ToolFixtureCell) => string, + ): ReadonlyArray => { + const lastSeen = lastSeenBy(keyOf, Math.max) + const firstSeen = lastSeenBy(keyOf, Math.min) + return [...rollup(rows, keyOf).entries()] + .map(([key, value]) => ({ + key, + ...value, + lastSeen: lastSeen.get(key) ?? nowMs, + firstSeen: firstSeen.get(key) ?? nowMs, + })) + .sort((a, b) => b.calls - a.calls) + } + + const tools = breakdown( + filtered.filter((cell) => search.model === undefined || cell.model === search.model), + (cell) => cell.tool, + ) + + return { + series, + seriesKind, + scopeSeries, + totals, + previousTotals, + // The denominator, derived as the route derives it: the model-scoped Tools + // breakdown, before the tool chip. + scopeCalls: tools.reduce((sum, row) => sum + row.calls, 0), + tools, + // The window's whole session population — the real one counts every agent + // session, including the ones that called no tool at all, so the fixture's + // is deliberately larger than any tool total. + allSessions: Math.round(cells.reduce((sum, cell) => sum + cell.sessions, 0) * 1.4), + } +} + +/** The service / model / env options the toolbar's selects offer, counted like + * the real facets. */ +export function toolFixtureFacets(cells: ReadonlyArray) { + const count = (keyOf: (cell: ToolFixtureCell) => string) => { + const out = new Map() + for (const cell of cells) { + const key = keyOf(cell) + out.set(key, (out.get(key) ?? 0) + cell.sessions) + } + return [...out.entries()] + .map(([name, value]) => ({ name, count: value })) + .sort((a, b) => b.count - a.count) + } + return { + services: count((cell) => cell.service), + models: count((cell) => cell.model), + environments: count((cell) => cell.env), + } +} diff --git a/apps/web-foldkit/src/lab/agent-transcript-fixture.ts b/apps/web-foldkit/src/lab/agent-transcript-fixture.ts new file mode 100644 index 0000000000..336f5fb646 --- /dev/null +++ b/apps/web-foldkit/src/lab/agent-transcript-fixture.ts @@ -0,0 +1,218 @@ +// copied: the fixture of apps/web `lab/bench/agent-transcript-bench.tsx` (`buildLargeSessionFixture`), +// a session big enough to hurt: every call carries its prompt and reply, every tool a large result. + +import type { AiSessionGenAiValues, AiSessionSpan } from "@maple/domain/http" +import { agentSpan, llmSpan, toolSpan, userMessages } from "@maple/agent-sessions/testing" + +const SECOND = 1000 +const TURN_GAP_MS = 90 * SECOND +const VENDOR_ID = "claude_agent_sdk" + +/** Deterministic, so two runs of the bench render the same bytes. */ +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const WORDS = + "retry backoff webhook idempotency charge invoice customer ledger dispatch queue partition replica latency p95 deploy rollback config jitter attempt handler schema migration index cursor".split( + " ", + ) + +function prose(random: () => number, words: number): string { + const out: string[] = [] + for (let i = 0; i < words; i++) { + const word = WORDS[Math.floor(random() * WORDS.length)]! + out.push(i > 0 && i % 17 === 0 ? `${word}.` : word) + } + return out.join(" ") +} + +const SYSTEM_INSTRUCTIONS = [ + "You are a careful coding agent working in a payments monorepo.", + "", + "## Rules", + "", + ...Array.from({ length: 160 }, (_, i) => `${i + 1}. Rule ${i + 1}: ${prose(mulberry32(i), 14)}`), +].join("\n") + +function markdownReply(random: () => number, paragraphs: number): string { + const blocks: string[] = [] + for (let p = 0; p < paragraphs; p++) { + blocks.push(prose(random, 40 + Math.floor(random() * 50))) + if (p % 3 === 1) { + blocks.push( + [ + "```ts", + ...Array.from( + { length: 12 }, + (_, i) => `const step${i} = retry(${i}, { jitter: ${random().toFixed(3)} })`, + ), + "```", + ].join("\n"), + ) + } + if (p % 4 === 2) { + blocks.push( + Array.from({ length: 5 }, (_, i) => `- **item ${i}** — ${prose(random, 9)}`).join("\n"), + ) + } + } + return blocks.join("\n\n") +} + +/** A warehouse-shaped result: rows of one query, sized by `rows`. */ +function jsonResult(random: () => number, rows: number): Record { + return { + query_id: `q_${Math.floor(random() * 1e9).toString(16)}`, + elapsed_ms: Math.round(random() * 4000), + rows: Array.from({ length: rows }, (_, i) => ({ + t: new Date(Date.UTC(2026, 7, 25, 13, 50) + i * 60_000).toISOString(), + service: `checkout-api-${i % 7}`, + p95_ms: Number((80 + random() * 900).toFixed(2)), + count: Math.floor(random() * 5000), + error_rate: Number((random() * 0.05).toFixed(4)), + region: random() > 0.5 ? "eu-central-1" : "us-east-1", + })), + } +} + +function textResult(random: () => number, lines: number): string { + return Array.from( + { length: lines }, + (_, i) => `${String(i + 1).padStart(4, " ")} ${prose(random, 10)}`, + ).join("\n") +} + +function usage(inputTokens: number, outputTokens: number): AiSessionGenAiValues { + return { + usageInputTokens: inputTokens, + usageOutputTokens: outputTokens, + usageCost: inputTokens * 0.000003 + outputTokens * 0.000015, + } +} + +/** + * `turns` turns, each: one agent span, three model calls (the last one the + * closing reply, the others each dispatching a tool), and two tool spans whose + * results run from ten kilobytes to a few hundred. + */ +export function buildLargeSessionFixture(turns: number, seed = 1): readonly AiSessionSpan[] { + const random = mulberry32(seed) + const spans: AiSessionSpan[] = [] + const tools = ["run_sql", "read_file", "search_traces", "grep_repo"] + + for (let turn = 0; turn < turns; turn++) { + const traceId = `trace-big-${turn}` + const agentId = `big-${turn}-agent` + const t = turn * TURN_GAP_MS + spans.push( + agentSpan({ + spanId: agentId, + traceId, + startMs: t, + durationMs: 60 * SECOND, + agentName: "coding-agent", + vendorId: VENDOR_ID, + }), + ) + const prompt = `${prose(random, 30 + Math.floor(random() * 600))}\n\n\`\`\`\n${textResult(random, 8)}\n\`\`\`` + for (let call = 0; call < 3; call++) { + const closing = call === 2 + const tool = closing ? undefined : tools[(turn + call) % tools.length]! + const callId = `big-${turn}-${call}` + spans.push( + llmSpan({ + spanId: `big-${turn}-l${call}`, + parentSpanId: agentId, + traceId, + startMs: t + call * 20 * SECOND, + durationMs: 4 * SECOND, + spanName: "chat claude-opus-5", + model: "claude-opus-5", + ttftSeconds: 0.6, + vendorId: VENDOR_ID, + genAi: { + ...usage(20_000 + turn * 3_000 + call * 4_000, 800 + call * 400), + systemInstructions: SYSTEM_INSTRUCTIONS, + inputMessages: call === 0 ? userMessages(prompt) : undefined, + outputMessages: [ + { + role: "assistant", + parts: [ + ...(call % 2 === 0 + ? [ + { + type: "reasoning", + content: prose(random, 200 + Math.floor(random() * 200)), + }, + ] + : []), + { + type: "text", + content: markdownReply( + random, + closing ? 6 + Math.floor(random() * 6) : 1, + ), + }, + ...(tool === undefined + ? [] + : [ + { + type: "tool_call", + id: callId, + name: tool, + arguments: { + sql: `SELECT * FROM traces WHERE turn = ${turn} AND call = ${call}`, + filters: Array.from({ length: 12 }, (_, i) => ({ + key: `attr.${i}`, + value: prose(random, 3), + })), + }, + }, + ]), + ], + }, + ], + responseFinishReasons: [closing ? "stop" : "tool_use"], + }, + }), + ) + if (tool !== undefined) { + const textual = (turn * 3 + call) % 6 === 0 + spans.push( + toolSpan({ + spanId: `big-${turn}-t${call}`, + parentSpanId: agentId, + traceId, + startMs: t + call * 20 * SECOND + 5 * SECOND, + durationMs: 2 * SECOND, + serviceName: "warehouse-mcp", + toolName: tool, + vendorId: VENDOR_ID, + genAi: { + toolCallId: callId, + toolCallArguments: { + sql: `SELECT * FROM traces WHERE turn = ${turn} AND call = ${call}`, + filters: Array.from({ length: 12 }, (_, i) => ({ + key: `attr.${i}`, + value: prose(random, 3), + })), + }, + toolCallResult: textual + ? textResult(random, 300 + Math.floor(random() * 300)) + : jsonResult(random, 80 + Math.floor(random() * 1_200)), + }, + }), + ) + } + } + } + return spans +} diff --git a/apps/web-foldkit/src/lab/alerts-lab/model.ts b/apps/web-foldkit/src/lab/alerts-lab/model.ts new file mode 100644 index 0000000000..aa53e23250 --- /dev/null +++ b/apps/web-foldkit/src/lab/alerts-lab/model.ts @@ -0,0 +1,36 @@ +import { Message as HubMessage, RuleBucket } from "../../page/alerts/hub/message" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Chart from "../../charts/chart" +import { ChartRuntime } from "../../charts/chart-runtime" +import { ThemeColors } from "../../charts/theme-colors" +import * as AlertsHub from "../../page/alerts/hub/model" +import * as Shell from "../../shell" + +// `/lab/alerts` (apps/web/src/lab/alerts-lab.tsx): the hub's tabs and the incident page over one +// fixture org (`buildAlertsLabFixture`), inside the real shell. + +export const Model = Schema.Struct({ + /** `useState(() => Date.now())`: the fixture is a function of the clock read at boot. */ + nowMs: Schema.Number, + timeZone: Schema.String, + /** The hub's Model, for the views that read its menus and the rules search. */ + hub: AlertsHub.Model, + /** The Rules section's status chip (`useState`). */ + status: Schema.NullOr(RuleBucket), + chart: Chart.Model, + chartRuntime: ChartRuntime, + themeColors: Schema.NullOr(ThemeColors), + shell: Shell.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage }, + GotChartMessage: { message: Chart.Message }, + ResolvedThemeColors: { colors: ThemeColors }, + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, + GotShellMessage: { message: Shell.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/alerts-lab/update.ts b/apps/web-foldkit/src/lab/alerts-lab/update.ts new file mode 100644 index 0000000000..3473d47f51 --- /dev/null +++ b/apps/web-foldkit/src/lab/alerts-lab/update.ts @@ -0,0 +1,91 @@ +import { OrgId } from "@maple/domain" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import type { Update } from "foldkit" +import * as Chart from "../../charts/chart" +import { LoadChartRuntime } from "../../charts/chart-runtime" +import * as AlertsHub from "../../page/alerts/hub/update" +import type { PageContext } from "../../page/contract" +import type { Resources } from "../../platform/resources" +import { foldLabShell, initLabShell, type LabShellFlags } from "../app-shell" +import { Message, type Model } from "./model" + +export type Return = Update.Return + +export const AlertsLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export type AlertsLabFlags = typeof AlertsLabFlags.Type + +/** The boot clock and the browser's zone (`Intl.DateTimeFormat().resolvedOptions()`). */ +export const alertsLabFlags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), +}) + +/** The hub views run against a hub Model; its org is the fixture's, its admin rights on. */ +const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab_alerts"), + pinnedNamespace: null, + timeZone: "UTC", +} + +export const init = (flags: AlertsLabFlags & LabShellFlags): Return => { + const shell = initLabShell(flags) + const hub = AlertsHub.init({ _tag: "Alerts", search: {} }, LAB_CONTEXT).model + return { + model: { + nowMs: flags.nowMs, + timeZone: flags.timeZone, + hub: { ...hub, isAdmin: true, nowMs: flags.nowMs }, + status: null, + chart: Chart.init(), + chartRuntime: { _tag: "Loading" }, + themeColors: null, + shell: shell.model, + }, + commands: [ + ...(shell.commands ?? []).map(Command.mapMessage((message) => Message.GotShellMessage({ message }))), + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], + } +} + +const foldShell = foldLabShell((message) => Message.GotShellMessage({ message })) + +/** The hub's local UI (menus, the rules search, broken logos); its writes stay no-ops, as legacy's. */ +const hubStep = (model: Model, message: Message & { _tag: "GotHubMessage" }): Return => { + const inner = message.message + if (inner._tag === "ClickedStatusFilter") + return { model: { ...model, status: model.status === inner.bucket ? null : inner.bucket } } + if (inner._tag === "ClickedClearRuleFilters") return { model: { ...model, status: null } } + const isLocal = + inner._tag === "ChangedRulesQuery" || + inner._tag === "ClickedShowAllRules" || + inner._tag === "FailedLoadProviderLogo" || + (inner._tag === "GotRowMenuMessage" && inner.message._tag !== "ClickedItem") + if (!isLocal) return { model } + const next = AlertsHub.update(model.hub, inner, LAB_CONTEXT) + return { + model: { ...model, hub: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => Message.GotHubMessage({ message: child })), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: (got) => hubStep(model, got), + GotChartMessage: ({ message: inner }) => { + const next = Chart.update(model.chart, inner) + return { + model: { ...model, chart: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => Message.GotChartMessage({ message: child })), + } + }, + ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), + FailedLoadChartRuntime: ({ reason }) => ({ model: { ...model, chartRuntime: { _tag: "Failed", reason } } }), + GotShellMessage: ({ message: inner }) => foldShell(model, inner), + }) diff --git a/apps/web-foldkit/src/lab/alerts-lab/view-page.ts b/apps/web-foldkit/src/lab/alerts-lab/view-page.ts new file mode 100644 index 0000000000..a1ad2db49c --- /dev/null +++ b/apps/web-foldkit/src/lab/alerts-lab/view-page.ts @@ -0,0 +1,112 @@ +import { buildAlertsLabFixture, type AlertsLabFixture } from "@maple/web/lab/alerts-fixture" +import { buildIncidentTimeline } from "@maple/web-data/alerts/incident-timeline" +import type { Html, HtmlBuilder } from "foldkit/html" +import { isChartRuntimeReady } from "../../charts/chart-runtime" +import { ResolveThemeColors } from "../../charts/theme-colors" +import { headerTitle, incidentBody } from "../../page/alerts/incident/view" +import { providerOf } from "../../page/alerts/shared/providers" +import { RULE_CHART_TOKENS } from "../../page/alerts/shared/rule-chart" +import { dashboardPage, pageHeader } from "../../page/kit/dashboard-page" +import { AppRoute, hrefOf } from "../../route" +import { linkButton } from "../../ui/button" +import { icon } from "../../ui/icon" +import { labShellView } from "../app-shell" +import { Message, type Model } from "./model" +import { FRAME_WIDTH, hubSections, section } from "./view" + +type H = HtmlBuilder + +/** `IncidentFrame`: `IncidentHeader` (a page header) over `IncidentBody`, from the fixture. */ +const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture): Html => { + const { incident, rule, ruleIncidents, states, preview, range, deliveries, investigation } = fixture.incident + const entries = buildIncidentTimeline({ + incident, + deliveries, + investigation, + renotifyIntervalMinutes: rule.renotifyIntervalMinutes, + destinationTypeLabel: (type) => providerOf(type).label, + }) + const isChartReady = model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) + return h.div( + [h.Class("overflow-hidden rounded-md border bg-background"), h.Style({ width: FRAME_WIDTH })], + [ + h.div( + [h.Class("border-b p-4")], + [ + pageHeader( + h, + headerTitle(h, incident, rule, model.nowMs, model.timeZone), + linkButton( + h, + { + variant: "outline", + size: "sm", + href: hrefOf(AppRoute.Investigation({ id: String(investigation.id), search: {} })), + }, + [icon(h, "ChatBubbleSparkleIcon"), "Open investigation"], + ), + ), + ], + ), + h.div( + [h.Class("p-6")], + [ + incidentBody(h, { + incident, + rule, + ruleIncidents, + states, + preview, + isPreviewLoading: !isChartReady, + previewError: null, + range, + now: model.nowMs, + timeZone: model.timeZone, + entries, + isEntriesLoading: false, + isEntriesFailed: false, + chartId: "alerts-lab-incident-chart", + chart: model.chart, + toChartMessage: (message) => Message.GotChartMessage({ message }), + colors: model.themeColors ?? {}, + }), + ], + ), + ], + ) +} + +const pageBody = (h: H, model: Model): Html => { + const fixture = buildAlertsLabFixture(model.nowMs) + return h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], + [ + dashboardPage(h, {}, [ + h.div( + [h.Class("space-y-12")], + [ + h.submodel({ + slotId: "alerts-lab-hub", + model: model.hub, + view: hubSections, + viewInputs: { fixture, status: model.status, timeZone: model.timeZone, nowMs: model.nowMs }, + toParentMessage: (message) => Message.GotHubMessage({ message }), + }), + section(h, "incident", "Incident: critical error rate on maple-chat", [incidentFrame(h, model, fixture)]), + ], + ), + ]), + ], + ) +} + +/** apps/web's `DashboardLayout` around the lab page: the real shell. */ +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + labShellView(h, model.shell, (message) => Message.GotShellMessage({ message }), { + currentPath: "/lab/alerts", + breadcrumbs: [{ label: "Lab" }, { label: "Alerts" }], + page: () => pageBody(h, model), + }), + ) diff --git a/apps/web-foldkit/src/lab/alerts-lab/view.ts b/apps/web-foldkit/src/lab/alerts-lab/view.ts new file mode 100644 index 0000000000..1328a2c75a --- /dev/null +++ b/apps/web-foldkit/src/lab/alerts-lab/view.ts @@ -0,0 +1,162 @@ +import type { AlertsLabFixture } from "@maple/web/lab/alerts-fixture" +import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" +import { cn } from "@maple/ui/lib/utils" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alertsEmptyState } from "../../page/alerts/hub/view-empty" +import { inboxTab } from "../../page/alerts/hub/view-inbox" +import { DESTINATION_ROW_GRID, destinationRow, HEALTH_WINDOW } from "../../page/alerts/hub/view-routing" +import { rulesTab } from "../../page/alerts/hub/view-rules" +import type { Message as HubMessage } from "../../page/alerts/hub/message" +import type { Model as HubModel } from "../../page/alerts/hub/model" +import { underlineTabClass, underlineTabStrip } from "../../page/kit/underline-tabs" +import { button } from "../../ui/button" +import { icon } from "../../ui/icon" +import type { Model } from "./model" + +type AlertsTab = "inbox" | "rules" | "routing" + +export const FRAME_WIDTH = "1200px" +const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ + { value: "inbox", label: "Inbox" }, + { value: "rules", label: "Rules" }, + { value: "routing", label: "Routing" }, +] + +/** `LabSection`. */ +export const section = (h: HtmlBuilder, id: string, title: string, children: ReadonlyArray): Html => + h.section( + [h.Id(id), h.Class("scroll-mt-6 space-y-3")], + [ + h.h2([h.Class("font-mono text-xs text-muted-foreground")], ["#", id, " ", h.span([h.Class("text-foreground")], [title])]), + ...children, + ], + ) + +/** `AlertsFrame`: the `/alerts` header and tab strip around one tab's body, at a desktop width. */ +const alertsFrame = (h: HtmlBuilder, tab: AlertsTab, data: AlertsOverviewReady, child: Html): Html => { + const counts = new Map([ + ["inbox", data.openIncidents.length], + ["rules", data.rules.length], + ["routing", 0], + ]) + return h.div( + [h.Class("overflow-hidden rounded-md border bg-background"), h.Style({ width: FRAME_WIDTH })], + [ + h.div( + [h.Class("space-y-3 border-b px-4 pt-4")], + [ + h.div( + [h.Class("flex items-center justify-between gap-4")], + [ + h.p([h.Class("text-sm font-medium")], ["Alerts"]), + button(h, { size: "sm" }, [icon(h, "PlusIcon"), tab === "routing" ? "Add destination" : "New rule"]), + ], + ), + underlineTabStrip( + h, + { label: "Alerts sections", bleed: false, divided: false }, + ALERTS_TABS.map((entry) => { + const count = counts.get(entry.value) ?? 0 + return h.span( + [h.Class(underlineTabClass(entry.value === tab))], + [ + entry.label, + ...(count > 0 + ? [ + h.span( + [ + h.Class( + cn( + "font-mono text-xs tabular-nums", + entry.value === "inbox" ? "text-severity-error" : "text-muted-foreground", + ), + ), + ], + [String(count)], + ), + ] + : []), + ], + ) + }), + ), + ], + ), + h.div([h.Class("p-6")], [child]), + ], + ) +} + +type HubInputs = Readonly<{ fixture: AlertsLabFixture; status: Model["status"]; timeZone: string; nowMs: number }> + +/** The sections the hub's views draw, run against the lab's hub Model. */ +export const hubSections = Submodel.defineView((hub, inputs, h) => { + const { fixture } = inputs + const routing = { destinations: fixture.destinations, timeZone: inputs.timeZone, nowMs: inputs.nowMs } + return h.div( + [h.Class("space-y-12")], + [ + section(h, "inbox", "Inbox: two firing, one held", [alertsFrame(h, "inbox", fixture.overview, inboxTab(h, fixture.overview))]), + section(h, "inbox-quiet", "Inbox: nothing firing, two blind spots", [ + alertsFrame(h, "inbox", fixture.quiet, inboxTab(h, fixture.quiet)), + ]), + section(h, "rules", "Rules", [ + alertsFrame( + h, + "rules", + fixture.overview, + rulesTab(h, hub, { + data: fixture.overview, + destinations: fixture.destinations, + status: inputs.status ?? undefined, + emptyState: alertsEmptyState(h, { + isAdmin: true, + hasDestinations: true, + serviceName: undefined, + onAddDestination: undefined, + }), + }), + ), + ]), + section(h, "routing", "Routing: destinations and delivery health", [ + alertsFrame( + h, + "routing", + fixture.overview, + h.div( + [h.Class("overflow-x-auto rounded-md border")], + [ + h.div( + [h.Class("min-w-[760px]")], + [ + h.div( + [h.Class(cn(DESTINATION_ROW_GRID, "border-b px-4 py-2 text-xs text-muted-foreground"))], + [ + h.span([], ["Destination"]), + h.span([], ["Type"]), + h.span([], ["Last ", String(HEALTH_WINDOW), " attempts"]), + h.span([h.Class("text-right")], ["Last delivered"]), + h.span([], []), + ], + ), + h.ul( + [h.Class("divide-y")], + fixture.destinations.map((destination) => + destinationRow(h, hub, routing, { + destination, + recent: fixture.deliveries + .filter((event) => event.destinationId === destination.id) + .slice(0, HEALTH_WINDOW), + }), + ), + ), + ], + ), + ], + ), + ), + ]), + ], + ) +}) diff --git a/apps/web-foldkit/src/lab/app-shell.ts b/apps/web-foldkit/src/lab/app-shell.ts new file mode 100644 index 0000000000..247a417644 --- /dev/null +++ b/apps/web-foldkit/src/lab/app-shell.ts @@ -0,0 +1,127 @@ +import { Effect, Option, Schema, Stream } from "effect" +import { Subscription, Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { readSessionToken } from "../platform/auth-api" +import type { Resources } from "../platform/resources" +import * as Shell from "../shell" + +// apps/web renders shell lab pages inside the real `DashboardLayout`: the same Shell Submodel the +// app root keeps, booted for the session's org, with the frame around the page and its overlays. + +export const LabShellFlags = Schema.Struct({ + shell: Schema.Struct({ + isMobile: Schema.Boolean, + theme: Shell.Theme, + pinnedNamespaces: Schema.Record(Schema.String, Schema.String), + dashboardFavorites: Schema.Array(Schema.String), + }), + maybeOrgId: Schema.Option(Schema.String), +}) +export type LabShellFlags = typeof LabShellFlags.Type + +const Claims = Schema.fromJsonString(Schema.Struct({ org_id: Schema.String })) +const decodeClaims = Schema.decodeUnknownOption(Claims) + +/** The org of the stored self-hosted token, read from its claims (legacy's session org). */ +const orgOfToken = (token: string): Effect.Effect> => + Effect.try(() => atob((token.split(".")[1] ?? "").replaceAll("-", "+").replaceAll("_", "/"))).pipe( + Effect.map((json) => Option.map(decodeClaims(json), (claims) => claims.org_id)), + Effect.orElseSucceed(() => Option.none()), + ) + +export const labShellFlags: Effect.Effect = Effect.all({ + shell: Shell.readShellFlags, + maybeOrgId: Effect.flatMap(readSessionToken, (maybeToken) => + Option.match(maybeToken, { onNone: () => Effect.succeed(Option.none()), onSome: orgOfToken }), + ), +}) + +/** The shell, booted for the token's org: the sidebar's requests go out with the page. */ +export const initLabShell = ( + flags: LabShellFlags, +): Update.ReturnWithOutMessage => { + const shell = Shell.init(flags.shell) + return Option.match(flags.maybeOrgId, { + onNone: () => ({ model: shell }), + onSome: (orgId) => Shell.enter(orgId)(shell), + }) +} + +/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ +export const foldLabShell = ( + toParentMessage: (message: Shell.Message) => Message, +) => + Update.foldChild({ + update: Shell.update, + read: (model: Model) => Option.some(model.shell), + write: (model: Model, shell: Shell.Model): Model => ({ ...model, shell }), + toParentMessage, + foldOutMessage: () => (model: Model) => ({ model }), + }) + +/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ +export const labShellView = ( + h: HtmlBuilder, + shell: Shell.Model, + toParentMessage: (message: Shell.Message) => Message, + frame: Readonly<{ + currentPath: string + breadcrumbs: ReadonlyArray + page: () => Html + }>, +): ReadonlyArray => [ + h.submodel({ + slotId: "shell-frame", + model: shell, + view: Shell.frameView, + toParentMessage, + viewInputs: { ...frame, topbarActions: () => null }, + }), + h.submodel({ + slotId: "shell-sheet", + model: shell, + view: Shell.sheetLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), + h.submodel({ + slotId: "shell-menu", + model: shell, + view: Shell.menuLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), +] + +/** The root's shell Subscriptions: `useIsMobile`, the sidebar shortcut and the Pinned group's shape. */ +export const labShellSubscriptions = ( + toParentMessage: (message: Shell.Message) => Message, +) => + Subscription.make()((entry) => ({ + shellViewport: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => Stream.map(Shell.viewportChanges, toParentMessage), + }, + ), + shellSidebarShortcut: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => Stream.map(Shell.sidebarShortcut, toParentMessage), + }, + ), + shellDashboards: entry( + { orgId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: (model) => ({ orgId: Option.getOrNull(model.shell.maybeOrgId) }), + dependenciesToStream: ({ orgId }) => + orgId === null + ? Stream.empty + : Shell.pinnedDashboardRows(orgId, (dashboards) => + toParentMessage(Shell.Message.SucceededLoadDashboards({ orgId, dashboards })), + ), + }, + ), + })) diff --git a/apps/web-foldkit/src/lab/bench/chart-bench.ts b/apps/web-foldkit/src/lab/bench/chart-bench.ts new file mode 100644 index 0000000000..08d945ee65 --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/chart-bench.ts @@ -0,0 +1,82 @@ +import { Clock, Effect, Schema } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { defineLabWithFlags, type LabEntry } from "../define" +import * as Chart from "../../charts/chart" +import { ChartRuntime, isChartRuntimeReady, LoadChartRuntime } from "../../charts/chart-runtime" +import { ThemeColors } from "../../charts/theme-colors" +import type { Resources } from "../../platform/resources" + +// The chart benches' shared state (apps/web `lab/bench/*-chart-bench.tsx`): a canvas chart per id, +// the chart runtime, the resolved theme, and one linked cursor across them. + +export const Model = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + charts: Schema.Record(Schema.String, Chart.Model), + chartRuntime: ChartRuntime, + themeColors: Schema.NullOr(ThemeColors), +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotChartMessage: { chartId: Schema.String, message: Chart.Message }, + ResolvedThemeColors: { colors: ThemeColors }, + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, + CompletedMountLinkedCursor: {}, +}) +export type Message = typeof Message.Type + +export const BenchFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export type BenchFlags = typeof BenchFlags.Type + +export const benchFlags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), +}) + +type Return = Update.Return + +export const init = (flags: BenchFlags): Return => ({ + model: { ...flags, charts: {}, chartRuntime: ChartRuntime.Loading(), themeColors: null }, + commands: [ + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], +}) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotChartMessage: ({ chartId, message: inner }) => { + const next = Chart.update(model.charts[chartId] ?? Chart.init(), inner) + return { + model: { ...model, charts: { ...model.charts, [chartId]: next.model } }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotChartMessage({ chartId, message: child }), + ), + } + }, + ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: ChartRuntime.Ready() } }), + FailedLoadChartRuntime: ({ reason }) => ({ + model: { ...model, chartRuntime: ChartRuntime.Failed({ reason }) }, + }), + CompletedMountLinkedCursor: () => ({ model }), + }) + +export const isReady = (model: Model): boolean => + model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) + +export const toChart = (chartId: string) => (message: Chart.Message) => + Message.GotChartMessage({ chartId, message }) + +/** A chart bench page: the shared state under its own view. */ +export const defineChartBench = ( + title: string, + view: (model: Model, h: HtmlBuilder) => Html, +): LabEntry => defineLabWithFlags(title, { Model, Flags: BenchFlags, flags: benchFlags, init, update, view }) diff --git a/apps/web-foldkit/src/lab/bench/dashboard-grid-harness.ts b/apps/web-foldkit/src/lab/bench/dashboard-grid-harness.ts new file mode 100644 index 0000000000..ec52bed325 --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/dashboard-grid-harness.ts @@ -0,0 +1,195 @@ +/** + * `window.__dashboardGridBench` from apps/web's `DashboardGridBench`, for + * `perf/dashboard-grid.perf.spec.ts`. React's Profiler has no Foldkit counterpart: `react.commits` + * counts DOM commits under the board (mutation batches), durations stay 0. A content render is one + * run of the memoised tile view, which marks `TILE_RENDER_MARK`; the board reports its layout writes. + */ +interface ReactRenderMetrics { + commits: number + totalActualDurationMs: number + actualDurationP95Ms: number + maxActualDurationMs: number +} + +interface InteractionMetrics { + frames: number + frameP95Ms: number + droppedFrames: number + longTasks: number + totalBlockingMs: number + react: ReactRenderMetrics +} + +interface MountMetrics { + mountMs: number + layoutShift: number + commits: number +} + +export interface DashboardGridBenchHarness { + ready: boolean + beginInteraction: () => void + endInteraction: () => Promise + measureMount: () => Promise + contentRenders: () => number + layoutCommits: () => number + lastCommitSize: () => number +} + +declare global { + interface Window { + __dashboardGridBench?: DashboardGridBenchHarness + } +} + +export const TILE_RENDER_MARK = "dashboard-grid-bench:tile-render" +/** The board's counters, written by the view so the harness reads them without the Model. */ +export const LAYOUT_COMMITS_ATTRIBUTE = "data-layout-commits" +export const LAST_COMMIT_SIZE_ATTRIBUTE = "data-last-commit-size" + +const FRAME_MS = 1000 / 60 + +const percentile = (values: ReadonlyArray, p: number): number => { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.round((p / 100) * (sorted.length - 1)))] ?? 0 +} + +const nextPaint = () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + +/** The Mount sits on the element the view writes the counters to. */ +const counter = (root: Element, attribute: string) => Number(root.getAttribute(attribute) ?? 0) + +/** + * Legacy moves the lifted tile and the resize ghost with direct style writes outside React + * (`use-grid-interaction.ts`); here they are patches. A batch of only those does not count. + */ +const isPointerFollow = (record: MutationRecord): boolean => + record.type === "attributes" && + record.attributeName === "style" && + record.target instanceof Element && + (record.target.hasAttribute("data-grid-item") || record.target.matches('[aria-hidden="true"].border-dashed')) + +/** Counts DOM commits (mutation batches) under `root` until the returned stop is called. */ +const watchCommits = (root: Element) => { + const commits = { count: 0 } + const observer = new MutationObserver((records) => { + if (!records.every(isPointerFollow)) commits.count++ + }) + observer.observe(root, { subtree: true, childList: true, attributes: true, characterData: true }) + return () => { + observer.disconnect() + return commits.count + } +} + +const reactMetrics = (commits: number): ReactRenderMetrics => ({ + commits, + totalActualDurationMs: 0, + actualDurationP95Ms: 0, + maxActualDurationMs: 0, +}) + +/** `measureMount`: a remount timed to the second frame after, its layout shift and commits. */ +const measureMount = (root: Element, requestRemount: () => void): Promise => { + const shift = { value: 0 } + const observer = new PerformanceObserver((list) => + list.getEntries().forEach((entry) => { + if ("value" in entry && typeof entry.value === "number") shift.value += entry.value + }), + ) + observer.observe({ type: "layout-shift", buffered: false }) + const stopCommits = watchCommits(root) + const startedAt = performance.now() + requestRemount() + return nextPaint().then(() => { + const mountMs = performance.now() - startedAt + return new Promise((resolve) => + setTimeout(() => { + observer.disconnect() + resolve({ mountMs, layoutShift: shift.value, commits: stopCommits() }) + }, 400), + ) + }) +} + +type Interaction = { frames: Array; longTasks: Array; stop: () => number } + +/** Frame and long-task sampling plus DOM commits from `beginInteraction` to `endInteraction`. */ +const startInteraction = (root: Element): Interaction => { + const frames: Array = [] + const longTasks: Array = [] + const sampling = { previous: performance.now(), handle: 0, running: true } + const observer = new PerformanceObserver((list) => longTasks.push(...list.getEntries())) + observer.observe({ entryTypes: ["longtask"] }) + const stopCommits = watchCommits(root) + const sample = (now: number) => { + frames.push(now - sampling.previous) + sampling.previous = now + if (sampling.running) sampling.handle = requestAnimationFrame(sample) + } + sampling.handle = requestAnimationFrame(sample) + return { + frames, + longTasks, + stop: () => { + sampling.running = false + cancelAnimationFrame(sampling.handle) + observer.disconnect() + return stopCommits() + }, + } +} + +const finish = (interaction: Interaction): InteractionMetrics => { + const commits = interaction.stop() + return { + frames: interaction.frames.length, + frameP95Ms: percentile(interaction.frames, 95), + droppedFrames: interaction.frames.filter((duration) => duration > FRAME_MS * 1.5).length, + longTasks: interaction.longTasks.length, + totalBlockingMs: interaction.longTasks.reduce((sum, entry) => sum + Math.max(0, entry.duration - 50), 0), + react: reactMetrics(commits), + } +} + +/** The open interaction, and the readiness poll's frame. */ +interface HarnessState { + interaction: Interaction | null + handle: number +} + +/** Installs the harness on the bench root; `ready` flips once every tile's grip is on the page. */ +export const installDashboardGridBench = (root: Element, count: number, requestRemount: () => void): (() => void) => { + const state: HarnessState = { interaction: null, handle: 0 } + const harness: DashboardGridBenchHarness = { + ready: false, + beginInteraction: () => { + performance.clearMarks(TILE_RENDER_MARK) + state.interaction?.stop() + state.interaction = startInteraction(root) + }, + endInteraction: () => + nextPaint().then(() => { + const interaction = state.interaction ?? startInteraction(root) + state.interaction = null + return finish(interaction) + }), + measureMount: () => measureMount(root, requestRemount), + contentRenders: () => performance.getEntriesByName(TILE_RENDER_MARK).length, + layoutCommits: () => counter(root, LAYOUT_COMMITS_ATTRIBUTE), + lastCommitSize: () => counter(root, LAST_COMMIT_SIZE_ATTRIBUTE), + } + window.__dashboardGridBench = harness + const poll = () => { + harness.ready = root.querySelectorAll("[data-bench-board] .widget-drag-handle").length >= count + if (!harness.ready) state.handle = requestAnimationFrame(poll) + } + state.handle = requestAnimationFrame(poll) + return () => { + cancelAnimationFrame(state.handle) + state.interaction?.stop() + if (window.__dashboardGridBench === harness) delete window.__dashboardGridBench + } +} diff --git a/apps/web-foldkit/src/lab/bench/infra-bench.ts b/apps/web-foldkit/src/lab/bench/infra-bench.ts new file mode 100644 index 0000000000..139d9a79c4 --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/infra-bench.ts @@ -0,0 +1,114 @@ +import { Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Chart from "../../charts/chart" +import { INFRA_CHART_TOKENS } from "../../charts/infra-metric-series" +import { metricView } from "../../charts/infra-metric-chart" +import { LINKED_CURSOR_STYLE, MountLinkedCursor } from "../../charts/linked-cursor" +import { ResolveThemeColors } from "../../charts/theme-colors" +import { hostSeriesSummary } from "../../page/infra/hosts/detail/view-panels" +import { k8sChart } from "../../page/infra/kit/k8s-chart" +import type { SeriesSlot } from "../../page/infra/kit/series" +import { chartLoading } from "../../ui/chart-state" +import { isReady, Message, type Model, toChart } from "./chart-bench" + +// apps/web `lab/bench/infra-chart-bench.tsx` (`/lab/bench/infra`): the host and k8s detail +// ChartViews in one linked-cursor group, over synthetic rows. + +const BUCKET_COUNT = 145 +const BUCKET_MS = 60 * 1000 +const END_TIME_MS = Date.UTC(2026, 6, 17, 18, 30) + +/** `makeRows`: a phase-shifted sine per series, every bucket. */ +const makeRows = (seriesNames: ReadonlyArray, scale: number, offset: number) => + Arr.makeBy(BUCKET_COUNT, (index) => { + const bucket = new Date(END_TIME_MS - (BUCKET_COUNT - 1 - index) * BUCKET_MS).toISOString() + const phase = (index / (BUCKET_COUNT - 1)) * Math.PI * 6 + return seriesNames.map((attributeValue, s) => ({ + bucket, + attributeValue, + value: Math.max(0, (0.25 + 0.2 * Math.sin(phase + s * 1.3 + offset)) * scale), + })) + }).flat() + +const HOST_CPU_ROWS = makeRows(["user", "system", "iowait"], 1, 0) +const HOST_LOAD_ROWS = makeRows(["value"], 8, 1.1) +const slotOf = (rows: ReturnType, unit: string): SeriesSlot => ({ + key: null, + data: AsyncData.succeed({ rows, unit }), +}) +const POD_CPU = slotOf(makeRows(["api", "worker", "ingest"], 1, 2.2), "cores") +const POD_LIMIT = slotOf(makeRows(["value"], 1, 3.3), "percent") + +type H = HtmlBuilder + +/** `HostMetricChartView`: CPU stacks with the 80% rule, load is a line. */ +const hostChart = (h: H, model: Model, metric: "cpu" | "load15", label: string): Html => { + const theme = model.themeColors + if (theme === null || !isReady(model)) return chartLoading(h, { variant: "area" }) + const isStacked = metric === "cpu" + return h.submodel({ + slotId: `host-chart-${metric}`, + model: model.charts[`host-${metric}`] ?? Chart.init(), + view: metricView, + toParentMessage: toChart(`host-${metric}`), + viewInputs: { + chartId: `host-${metric}`, + rows: metric === "cpu" ? HOST_CPU_ROWS : HOST_LOAD_ROWS, + unit: metric === "cpu" ? "percent" : "load", + seriesLabel: label, + stacked: isStacked, + showThreshold: isStacked, + linkedChartId: `host-${metric}`, + header: hostSeriesSummary, + theme, + timeZone: model.timeZone, + }, + }) +} + +const podChart = ( + h: H, + model: Model, + chartId: string, + slot: SeriesSlot, + label: string, + isStacked: boolean, +): Html => + k8sChart(h, { + slot, + chart: model.charts[chartId], + toChart: toChart(chartId), + chartId, + seriesLabel: label, + stacked: isStacked, + showThreshold: !isStacked, + linkedChartId: chartId, + theme: model.themeColors, + runtime: model.chartRuntime, + timeZone: model.timeZone, + }) + +export const view = (model: Model, h: H): Html => + h.div( + [ + h.DataAttribute("testid", "infra-chart-bench"), + h.Class("min-h-screen bg-background p-6 text-foreground"), + h.OnMount(ResolveThemeColors({ tokens: INFRA_CHART_TOKENS })), + ], + [ + h.div( + [ + h.Class("grid grid-cols-1 gap-4 md:grid-cols-2"), + h.Style(LINKED_CURSOR_STYLE), + h.OnMount(MountLinkedCursor()), + ], + [ + hostChart(h, model, "cpu", "CPU"), + hostChart(h, model, "load15", "Load (15m)"), + podChart(h, model, "pod-cpu_usage", POD_CPU, "CPU usage", true), + podChart(h, model, "pod-cpu_limit", POD_LIMIT, "CPU / limit", false), + ], + ), + ], + ) diff --git a/apps/web-foldkit/src/lab/bench/logs-harness.ts b/apps/web-foldkit/src/lab/bench/logs-harness.ts new file mode 100644 index 0000000000..bb154da65e --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/logs-harness.ts @@ -0,0 +1,98 @@ +/** + * `window.__logsBench` from apps/web's `LogsTableBench`, for `perf/logs.perf.spec.ts`: the same + * 121-step scroll, frame and long-task sampling. React's Profiler has no Foldkit counterpart, so + * `reactCommits` counts DOM commits (mutation batches under the stream) and `reactDurationMs` is + * left at 0; script time comes from the CDP metrics the perf runner records for both apps. + */ +export interface LogsBenchMetrics { + readonly frames: number + readonly frameP95Ms: number + readonly longTasks: number + readonly totalBlockingMs: number + readonly reactCommits: number + readonly reactDurationMs: number +} + +export interface LogsBenchHarness { + ready: boolean + readonly runScroll: () => Promise +} + +declare global { + interface Window { + __logsBench?: LogsBenchHarness + } +} + +const SCROLL_STEPS = 120 + +const percentile = (values: ReadonlyArray, p: number): number => { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.round((p / 100) * (sorted.length - 1)))] ?? 0 +} + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + +const scrollThrough = (scroller: HTMLElement, maxScroll: number, step: number): Promise => { + if (step > SCROLL_STEPS) return Promise.resolve() + scroller.scrollTop = maxScroll * (step / SCROLL_STEPS) + return nextFrame().then(() => scrollThrough(scroller, maxScroll, step + 1)) +} + +const runScroll = (root: HTMLElement): Promise => { + const log = root.querySelector("[role='log']") + const scroller = log?.parentElement + if (log === null || scroller == null) + return Promise.reject(new Error("Logs benchmark scroller not found")) + const frames: Array = [] + const longTasks: Array = [] + const commits = { count: 0 } + const sampling = { previous: performance.now(), handle: 0, running: true } + const observer = new PerformanceObserver((list) => longTasks.push(...list.getEntries())) + observer.observe({ entryTypes: ["longtask"] }) + const mutations = new MutationObserver(() => { + commits.count++ + }) + mutations.observe(log, { childList: true, subtree: true, attributes: true, characterData: true }) + const sample = (now: number) => { + frames.push(now - sampling.previous) + sampling.previous = now + if (sampling.running) sampling.handle = requestAnimationFrame(sample) + } + sampling.handle = requestAnimationFrame(sample) + const maxScroll = scroller.scrollHeight - scroller.clientHeight + return scrollThrough(scroller, maxScroll, 0) + .then(nextFrame) + .then(nextFrame) + .then(() => { + sampling.running = false + cancelAnimationFrame(sampling.handle) + observer.disconnect() + mutations.disconnect() + return { + frames: frames.length, + frameP95Ms: percentile(frames, 95), + longTasks: longTasks.length, + totalBlockingMs: longTasks.reduce((sum, entry) => sum + Math.max(0, entry.duration - 50), 0), + reactCommits: commits.count, + reactDurationMs: 0, + } + }) +} + +/** Installs the harness for the bench root; `ready` flips once the first rows are on screen. */ +export const installLogsBench = (root: HTMLElement): (() => void) => { + const harness: LogsBenchHarness = { ready: false, runScroll: () => runScroll(root) } + window.__logsBench = harness + const state = { handle: 0 } + const waitForRows = () => { + harness.ready = root.querySelector("[role='log'] > [data-index]") !== null + if (!harness.ready) state.handle = requestAnimationFrame(waitForRows) + } + state.handle = requestAnimationFrame(waitForRows) + return () => { + cancelAnimationFrame(state.handle) + if (window.__logsBench === harness) delete window.__logsBench + } +} diff --git a/apps/web-foldkit/src/lab/bench/service-detail-bench.ts b/apps/web-foldkit/src/lab/bench/service-detail-bench.ts new file mode 100644 index 0000000000..b459dc1eb4 --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/service-detail-bench.ts @@ -0,0 +1,63 @@ +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { FixedMetricRow } from "../../charts/chart-spec" +import { MountLinkedCursor } from "../../charts/linked-cursor" +import { ResolveThemeColors } from "../../charts/theme-colors" +import { CHART_TOKENS, metricsGridOf } from "../../page/services/detail/view-charts" +import { isReady, Message, type Model } from "./chart-bench" + +// apps/web `lab/bench/service-detail-chart-bench.tsx` (`/lab/bench/service-detail` and +// `/lab/bench/overview`): the service-detail chart grid over synthetic rows. + +const BUCKET_COUNT = 145 +const BUCKET_MS = 5 * 60 * 1000 +const END_TIME_MS = Date.UTC(2026, 6, 17, 18, 30) + +/** `SERVICE_DETAIL_POINTS`, the same formulas. */ +export const SERVICE_DETAIL_POINTS: ReadonlyArray = Arr.makeBy(BUCKET_COUNT, (index) => { + const phase = (index / (BUCKET_COUNT - 1)) * Math.PI * 6 + const traffic = 1_250 + Math.sin(phase) * 410 + Math.cos(phase * 0.37) * 190 + const errorRate = 0.18 + Math.max(0, Math.sin(phase * 0.73)) * 0.42 + const p50LatencyMs = 320 + Math.sin(phase * 0.63) * 85 + return { + bucket: new Date(END_TIME_MS - (BUCKET_COUNT - 1 - index) * BUCKET_MS).toISOString(), + p50LatencyMs, + p95LatencyMs: p50LatencyMs * 1.8, + p99LatencyMs: p50LatencyMs * 2.75, + throughput: traffic * (BUCKET_MS / 1000), + tracedThroughput: traffic * 0.14 * (BUCKET_MS / 1000), + hasSampling: true, + errorRate, + apdexScore: Math.max(0.72, 0.97 - errorRate * 0.12 - Math.sin(phase * 0.41) * 0.025), + } +}) + +/** The bench page: `MetricsGrid` with `yAxisWidth={72}` under a padded full-height frame. */ +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.DataAttribute("testid", "service-detail-chart-bench"), + h.Class("min-h-screen bg-background p-6 text-foreground"), + h.OnMount(ResolveThemeColors({ tokens: CHART_TOKENS })), + ], + [ + metricsGridOf( + h, + { + charts: model.charts, + rows: SERVICE_DETAIL_POINTS, + markers: [], + toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), + linkedCursorMount: MountLinkedCursor(), + }, + { + isLoading: !isReady(model), + isWaiting: false, + context: + model.themeColors === null + ? null + : { theme: model.themeColors, timeZone: model.timeZone, nowMs: model.nowMs }, + }, + ), + ], + ) diff --git a/apps/web-foldkit/src/lab/bench/tanstack-bench.ts b/apps/web-foldkit/src/lab/bench/tanstack-bench.ts new file mode 100644 index 0000000000..7643bf9af4 --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/tanstack-bench.ts @@ -0,0 +1,112 @@ +import { overviewBenchRows } from "@maple/web/lab/bench/tanstack/bench-data" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Chart from "../../charts/chart" +import { bucketTooltipBody } from "../../charts/bucket-tooltip" +import { ChartSpec } from "../../charts/chart-spec" +import { plotFrame } from "../../charts/plot-frame" +import { resolveColor, ResolveThemeColors, type ThemeColors } from "../../charts/theme-colors" +import { chartSkeleton } from "../../ui/chart-skeleton" +import { isReady, type Message, type Model, toChart } from "./chart-bench" + +// apps/web `lab/bench/tanstack-chart-bench.tsx` (`/lab/bench/tanstack`): the three `/` overview +// charts on the canvas renderer off identical rows, deliberately without the linked cursor. + +const CHART_CLASS = "h-[220px] w-full" +const TOKENS = [ + "--chart-throughput", + "--chart-error", + "--chart-p99", + "--chart-p95", + "--chart-p50", + "--border", + "--background", +] + +type Kind = "Throughput" | "ErrorRate" | "Latency" + +/** `overviewBenchRows` as chart rows (its index signature is `unknown`). */ +const BENCH_ROWS = overviewBenchRows.map((row) => ({ + bucket: row.bucket, + throughput: row.throughput, + errorRate: row.errorRate, + p50LatencyMs: row.p50LatencyMs, + p95LatencyMs: row.p95LatencyMs, + p99LatencyMs: row.p99LatencyMs, +})) + +const CHARTS: ReadonlyArray> = [ + { + id: "bench-throughput", + kind: "Throughput", + ariaLabel: "Request volume", + gradientId: "benchThroughput", + }, + { id: "bench-error-rate", kind: "ErrorRate", ariaLabel: "Error rate", gradientId: "benchErrorRate" }, + { id: "bench-latency", kind: "Latency", ariaLabel: "Latency percentiles", gradientId: "benchLatency" }, +] + +const colorsOf = (theme: ThemeColors): Readonly> => ({ + throughput: resolveColor(theme, "--chart-throughput"), + error: resolveColor(theme, "--chart-error"), + p99: resolveColor(theme, "--chart-p99"), + p95: resolveColor(theme, "--chart-p95"), + p50: resolveColor(theme, "--chart-p50"), + border: resolveColor(theme, "--border"), + background: resolveColor(theme, "--background"), +}) + +type ChartInputs = Readonly<{ chartId: string; ariaLabel: string; spec: ChartSpec }> + +/** `PlotFrame` with `PlotTooltipBody`. */ +const chartView = Submodel.defineView((model, inputs, h) => + plotFrame(h, { + chartId: inputs.chartId, + spec: inputs.spec, + ariaLabel: inputs.ariaLabel, + className: CHART_CLASS, + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + tooltip._tag === "Bucket" + ? Option.some(bucketTooltipBody(h, tooltip, portal)) + : Option.none(), + ), + }), +) + +const chart = (h: HtmlBuilder, model: Model, entry: (typeof CHARTS)[number]): Html => + model.themeColors === null || !isReady(model) + ? h.div([h.Class(CHART_CLASS)], [chartSkeleton(h, entry.kind === "Latency" ? "line" : "area")]) + : h.submodel({ + slotId: entry.id, + model: model.charts[entry.id] ?? Chart.init(), + view: chartView, + viewInputs: { + chartId: entry.id, + ariaLabel: entry.ariaLabel, + spec: ChartSpec.BenchOverview({ + chart: entry.kind, + rows: BENCH_ROWS, + colors: colorsOf(model.themeColors), + gradientId: entry.gradientId, + }), + }, + toParentMessage: toChart(entry.id), + }) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.DataAttribute("testid", "tanstack-chart-bench"), + h.DataAttribute("bench-renderer", "tanstack-canvas"), + h.Class("min-h-screen bg-background p-6 text-foreground"), + h.OnMount(ResolveThemeColors({ tokens: TOKENS })), + ], + [ + h.div( + [h.Class("grid grid-cols-1 gap-4")], + CHARTS.map((entry) => chart(h, model, entry)), + ), + ], + ) diff --git a/apps/web-foldkit/src/lab/bench/transcript-harness.ts b/apps/web-foldkit/src/lab/bench/transcript-harness.ts new file mode 100644 index 0000000000..634c1dedc8 --- /dev/null +++ b/apps/web-foldkit/src/lab/bench/transcript-harness.ts @@ -0,0 +1,133 @@ +/** + * `window.__transcriptBench` from apps/web's `AgentTranscriptBench`, for + * `perf/agent-transcript.perf.spec.ts`: the same sweep of the page scroller, one frame per step, + * frame and long-task sampling and the DOM the mounted rows hold. React's Profiler has no Foldkit + * counterpart: `reactCommits` counts DOM commits under the list, the durations stay 0. + */ +export interface TranscriptBenchMetrics { + readonly frames: number + readonly frameP95Ms: number + readonly droppedFrames: number + readonly longTasks: number + readonly totalBlockingMs: number + readonly reactCommits: number + readonly reactDurationMs: number + readonly reactMaxCommitMs: number + readonly mountedRows: number + readonly listNodes: number + readonly maxRowNodes: number +} + +type DomCounts = Pick + +export interface TranscriptBenchHarness { + ready: boolean + mountCommitMs: number + readonly runScroll: (steps?: number) => Promise + readonly countDom: () => DomCounts +} + +declare global { + interface Window { + __transcriptBench?: TranscriptBenchHarness + } +} + +/** The virtualizer's row wrappers, by slot: the tab panel around the list carries `data-index` too. */ +const LIST_SELECTOR = '[data-transcript-bench] [data-slot="transcript-row"]' +const SCROLLER_SELECTOR = '[data-transcript-bench] [data-slot="page-scroll-area"]' +const FRAME_MS = 1000 / 60 + +const percentile = (values: ReadonlyArray, p: number): number => { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.round((p / 100) * (sorted.length - 1)))] ?? 0 +} + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + +const countDom = (): DomCounts => { + const sizes = Array.from(document.querySelectorAll(LIST_SELECTOR)).map((row) => row.querySelectorAll("*").length) + return { + mountedRows: sizes.length, + listNodes: sizes.reduce((sum, size) => sum + size, 0), + maxRowNodes: sizes.reduce((max, size) => Math.max(max, size), 0), + } +} + +/** Re-reads the extent every step: measured rows grow the list as the sweep goes. */ +const sweep = (scroller: HTMLElement, steps: number, step: number): Promise => { + if (step > steps) return Promise.resolve() + scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) * (step / steps) + return nextFrame().then(() => sweep(scroller, steps, step + 1)) +} + +const runScroll = (steps: number): Promise => { + const scroller = document.querySelector(SCROLLER_SELECTOR) + if (scroller === null) return Promise.reject(new Error("Transcript benchmark scroller not found")) + const frames: Array = [] + const longTasks: Array = [] + const commits = { count: 0 } + const sampling = { previous: performance.now(), handle: 0, running: true } + // Firefox and WebKit expose no Long Tasks; the frame and DOM metrics stay valid there. + const observer = PerformanceObserver.supportedEntryTypes.includes("longtask") + ? new PerformanceObserver((list) => longTasks.push(...list.getEntries())) + : undefined + observer?.observe({ entryTypes: ["longtask"] }) + const mutations = new MutationObserver(() => { + commits.count++ + }) + mutations.observe(scroller, { childList: true, subtree: true }) + const sample = (now: number) => { + frames.push(now - sampling.previous) + sampling.previous = now + if (sampling.running) sampling.handle = requestAnimationFrame(sample) + } + sampling.handle = requestAnimationFrame(sample) + return sweep(scroller, steps, 0) + .then(nextFrame) + .then(nextFrame) + .then(() => { + sampling.running = false + cancelAnimationFrame(sampling.handle) + observer?.disconnect() + mutations.disconnect() + return { + frames: frames.length, + frameP95Ms: percentile(frames, 95), + droppedFrames: frames.filter((duration) => duration > FRAME_MS * 1.5).length, + longTasks: longTasks.length, + totalBlockingMs: longTasks.reduce((sum, entry) => sum + Math.max(0, entry.duration - 50), 0), + reactCommits: commits.count, + reactDurationMs: 0, + reactMaxCommitMs: 0, + ...countDom(), + } + }) +} + +/** Installs the harness; `ready` flips once the first rows are on screen. */ +export const installTranscriptBench = (): (() => void) => { + const startedAt = performance.now() + const harness: TranscriptBenchHarness = { + ready: false, + mountCommitMs: 0, + countDom, + runScroll: (steps = 160) => runScroll(steps), + } + window.__transcriptBench = harness + const state = { handle: 0 } + const poll = () => { + if (document.querySelector(LIST_SELECTOR) === null) { + state.handle = requestAnimationFrame(poll) + return + } + harness.mountCommitMs = performance.now() - startedAt + harness.ready = true + } + state.handle = requestAnimationFrame(poll) + return () => { + cancelAnimationFrame(state.handle) + if (window.__transcriptBench === harness) delete window.__transcriptBench + } +} diff --git a/apps/web-foldkit/src/lab/boot.ts b/apps/web-foldkit/src/lab/boot.ts new file mode 100644 index 0000000000..e3d01fc468 --- /dev/null +++ b/apps/web-foldkit/src/lab/boot.ts @@ -0,0 +1,24 @@ +import type { LabEntry } from "./define" + +/** + * One module per lab page in `./entries/.ts` (`/lab/bench/logs` is `entries/bench/logs.ts`). + * Lazy, so lab code never reaches the app's startup bundle. + */ +const entries = import.meta.glob<{ lab: LabEntry }>("./entries/**/*.ts") + +export const labEnabled = import.meta.env.DEV || import.meta.env.VITE_MAPLE_LAB === "on" + +/** `/lab` itself is the index entry (`entries/index.ts`). */ +export const labPathOf = (pathname: string): string | undefined => { + const match = pathname.match(/^\/lab(?:\/(.+?))?\/?$/) + return match === null ? undefined : (match[1] ?? "index") +} + +export const startLab = (path: string, container: HTMLElement): Promise => { + const load = entries[`./entries/${path}.ts`] + if (load === undefined) { + container.textContent = `Unknown lab page: ${path}. Known: ${Object.keys(entries).join(", ")}` + return Promise.resolve() + } + return load().then((module) => module.lab.start(container)) +} diff --git a/apps/web-foldkit/src/lab/charts/arms.ts b/apps/web-foldkit/src/lab/charts/arms.ts new file mode 100644 index 0000000000..160340eb7e --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/arms.ts @@ -0,0 +1,89 @@ +// The gallery's arms and card titles, verbatim from `cards` in apps/web/src/lab/charts/charts-lab.tsx. + +export const ARM_TITLES = { + "pie-production": "Pie — production (PORTED: polar + radialArc; was hand-rolled SVG arcs)", + "pie-tanstack": "Pie — TanStack (polar + pie + radialArc) · no hover affordance", + "histogram-production": "Histogram — production (PORTED: binX + rect; was JS binning → string labels)", + "histogram-tanstack": "Histogram — TanStack (binX + rect, numeric intervals)", + "heatmap-production": "Heatmap — production (PORTED: cell + scaleBand; was CSS grid + layout solver)", + "heatmap-tanstack": "Heatmap — TanStack (cell + scaleBand + sequential colour)", + "line-production": "Line — production (PORTED: lineY per series; was recharts LineChart)", + "line-tanstack": "Line — TanStack (lineY × 3, pinned point scale)", + "area-production": "Area — production (PORTED: fill-only areaY + lineY; was recharts AreaChart)", + "area-tanstack": "Area — TanStack (fill-only areaY + lineY, spec gradients)", + "stacked-bar-production": + "Stacked bar — production (PORTED: one barY, long-form z channel; was per service)", + "stacked-bar-tanstack": "Stacked bar — TanStack (one barY, z channel + stack layout)", + "line-incomplete-production": + "Line + partial tail — production (PORTED: second mark over a slice; was twin _incomplete columns)", + "line-incomplete-tanstack": "Line + partial tail — TanStack (second mark over a slice)", + "area-incomplete-production": + "Area + partial tail — production (PORTED: faded areaY + dashed lineY; was gradient + dashed )", + "area-incomplete-tanstack": "Area + partial tail — TanStack (faded areaY + dashed lineY)", + "stacked-bar-incomplete-tanstack": + "Stacked bar + partial tail — NEW (barY strokeDasharray channel; recharts bars have no incomplete support at all)", + "box-plot": "Box plot — NEW (boxY, Tukey summaries from raw observations)", + "trace-scatter": "Trace scatter — NEW (hexbin over 5,000 spans, screen-space density)", + sankey: "Service flow — NEW (sankeyDiagram, weighted by call volume)", + treemap: "Span volume — NEW (hierarchy/treemap, service → operation)", + "line-legend-tanstack": + "Line + legend — TanStack (click a series to bring it forward; the axis never moves)", + "area-legend-tanstack": "Area + legend — TanStack (per-mark fill/stroke opacity, one mark per series)", + "stacked-bar-legend-tanstack": + "Stacked bar + legend — TanStack (one mark, so emphasis is a muted FILL, not an opacity)", + "stacked-bar-legend-scene-tanstack": + "Stacked bar + legend — TanStack's OWN legend (interactiveColorLegend, zero DOM — but it can only HIDE, and on a stack that leaves a hole)", + "pie-legend-tanstack": + "Pie + stats legend — TanStack (hover grows the slice via onFocusChange + a second arc mark; click a row to pin it)", + "pie-solid-tanstack": "Pie — TanStack (donut hole closed: innerRadius 0)", + "pie-solid-legend-tanstack": + "Pie + stats legend — TanStack (no hole; the hovered wedge grows from the centre out)", + "treemap-legend": + "Span volume + legend — NEW (leaves only, so colour IS the grouping; muting keeps the tiling fixed)", + "sankey-legend": + "Service flow + status key — NEW (bands, not series; picking one lifts those ribbons out of the wall)", +} as const + +export type ChartsLabArm = keyof typeof ARM_TITLES + +/** The gallery layout: sections of pairs and grids, in legacy order. */ +export const SECTIONS: ReadonlyArray<{ + readonly title: string + readonly rows: ReadonlyArray> +}> = [ + { + title: "Consolidation — production beside its TanStack replacement", + rows: [ + ["pie-production", "pie-tanstack"], + ["histogram-production", "histogram-tanstack"], + ["heatmap-production", "heatmap-tanstack"], + ["line-production", "line-tanstack"], + ["area-production", "area-tanstack"], + ["stacked-bar-production", "stacked-bar-tanstack"], + ], + }, + { + title: "Trailing partial buckets — the dashed future segment", + rows: [ + ["line-incomplete-production", "line-incomplete-tanstack"], + ["area-incomplete-production", "area-incomplete-tanstack"], + ["stacked-bar-incomplete-tanstack"], + ], + }, + { + title: "Legends — production beside the TanStack series key", + rows: [ + ["line-production", "line-legend-tanstack"], + ["area-production", "area-legend-tanstack"], + ["stacked-bar-production", "stacked-bar-legend-tanstack"], + ["pie-production", "pie-legend-tanstack"], + ["stacked-bar-legend-tanstack", "stacked-bar-legend-scene-tanstack"], + ["pie-solid-tanstack", "pie-solid-legend-tanstack"], + ["treemap-legend", "sankey-legend"], + ], + }, + { + title: "Unlock — charts Maple cannot render today", + rows: [["box-plot", "trace-scatter", "sankey", "treemap"]], + }, +] diff --git a/apps/web-foldkit/src/lab/charts/spike-arms.ts b/apps/web-foldkit/src/lab/charts/spike-arms.ts new file mode 100644 index 0000000000..932d64e22c --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-arms.ts @@ -0,0 +1,125 @@ +import type { PlotLegendSeries } from "../../charts/plot-legend" +import { resolveColor, type ThemeColors } from "../../charts/theme-colors" +import { type ChartsLabArm, SECTIONS } from "./arms" +import { FLOW_BANDS, FLOW_TOKENS } from "./spike-sankey-data" +import { serviceToken, TILE_TOKENS, TREEMAP_SERVICES } from "./spike-treemap-data" +import { heatmapPrunedNote, heatmapSpikeModel, HEATMAP_SPIKE_ROWS } from "./spike-heatmap-model" +import { + AREA_TOKENS, + CHROME_TOKENS, + GROUT_TOKEN, + LINE_TOKENS, + RAMP_TOKENS, + STACK_TOKEN_BY_SERVICE, + STACK_TOKENS, + type TokenWithFallback, +} from "./spike-timeseries-tokens" + +// The prototypes the lab host draws (`spike-host.ts`), by arm: the tokens each resolves, its +// accessible name and, for the legend arms, the series key under it. + +export interface SpikeArm { + readonly tokens: ReadonlyArray + readonly ariaLabel: string + /** A note under the plot (`PlotFrame footer`). */ + readonly footer?: string + readonly legend?: Readonly<{ + label: string + series: (theme: ThemeColors) => ReadonlyArray + }> +} + +const series = + (entries: ReadonlyArray) => + (theme: ThemeColors): ReadonlyArray => + entries.map(([key, label, token]) => ({ key, label, color: resolveColor(theme, token) })) + +const LATENCY = { tokens: [...LINE_TOKENS, ...CHROME_TOKENS], ariaLabel: "Latency percentiles" } +const VOLUME = { tokens: [...AREA_TOKENS, ...CHROME_TOKENS], ariaLabel: "Request volume" } +const SPANS = { tokens: STACK_TOKENS, ariaLabel: "Spans by service" } + +const ARMS = { + "line-tanstack": LATENCY, + "line-incomplete-tanstack": LATENCY, + "line-legend-tanstack": { + ...LATENCY, + legend: { + label: "Latency percentiles", + series: series([ + ["p99", "P99", "--chart-p99"], + ["p95", "P95", "--chart-p95"], + ["p50", "P50", "--chart-p50"], + ]), + }, + }, + "area-tanstack": VOLUME, + "area-incomplete-tanstack": VOLUME, + "area-legend-tanstack": { + ...VOLUME, + legend: { + label: "Request volume", + series: series([ + ["throughput", "Throughput", "--chart-throughput"], + ["errors", "Errors", "--chart-error"], + ]), + }, + }, + "heatmap-tanstack": { + tokens: [...RAMP_TOKENS.amber, GROUT_TOKEN, "--foreground"], + ariaLabel: "Request count by hour and latency bucket", + ...((note) => (note === null ? {} : { footer: note }))( + heatmapPrunedNote(heatmapSpikeModel(HEATMAP_SPIKE_ROWS)), + ), + }, + "box-plot": { tokens: ["--chart-1"], ariaLabel: "Latency distribution by operation" }, + "trace-scatter": { tokens: RAMP_TOKENS.blues, ariaLabel: "Span duration by time, hex-binned by density" }, + sankey: { tokens: FLOW_TOKENS, ariaLabel: "Service call flow" }, + "sankey-legend": { + tokens: FLOW_TOKENS, + ariaLabel: "Service call flow", + legend: { + label: "Flow health", + series: series(FLOW_BANDS.map((band) => [band.key, band.label, band.token] as const)), + }, + }, + treemap: { tokens: TILE_TOKENS, ariaLabel: "Span volume by service and operation" }, + "treemap-legend": { + tokens: TILE_TOKENS, + ariaLabel: "Span volume by service and operation", + legend: { + label: "Span volume by service", + series: series( + TREEMAP_SERVICES.map((service) => [service, service, serviceToken(service)] as const), + ), + }, + }, + "stacked-bar-tanstack": SPANS, + "stacked-bar-incomplete-tanstack": SPANS, + "stacked-bar-legend-scene-tanstack": SPANS, + "stacked-bar-legend-tanstack": { + ...SPANS, + legend: { + label: "Spans by service", + series: series( + [...STACK_TOKEN_BY_SERVICE].map(([service, token]) => [service, service, token] as const), + ), + }, + }, +} satisfies Partial> + +export const SPIKE_ARMS: ReadonlyMap = new Map( + Object.entries(ARMS).flatMap(([arm, spike]) => { + const known = SECTIONS.flatMap((section) => section.rows.flat()).find( + (candidate) => candidate === arm, + ) + return known === undefined ? [] : [[known, spike] as const] + }), +) + +export const SPIKE_TOKENS_ALL: ReadonlyArray = [ + ...new Set( + [...SPIKE_ARMS.values()].flatMap((arm) => + arm.tokens.map((token) => (typeof token === "string" ? token : token[0])), + ), + ), +] diff --git a/apps/web-foldkit/src/lab/charts/spike-box-plot.ts b/apps/web-foldkit/src/lab/charts/spike-box-plot.ts new file mode 100644 index 0000000000..950673e2bc --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-box-plot.ts @@ -0,0 +1,121 @@ +import { formatLatency, formatNumber } from "@maple/ui/lib/format" +import { type BoxDatum, boxY, type ChartPoint, type ChartValue, defineChart } from "@tanstack/charts" +import { scaleBand } from "@tanstack/charts-scales/band" +import { scaleLog } from "d3-scale" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import { cursorTooltip } from "../../charts/definitions/plot-constants" +import type { SpikeSpec } from "./spike-spec" + +// `BoxPlotSpike` (apps/web/src/lab/charts/box-plot-spike.tsx), definition half: Tukey boxes over a +// seeded log-normal sample per operation, on a log y scale. Code copied, constants unchanged. + +interface BoxPlotRow { + readonly operation: string + readonly durationMs: number +} + +type Datum = BoxDatum + +const OPERATIONS: ReadonlyArray<{ name: string; medianMs: number; sigma: number }> = [ + { name: "GET /api/traces", medianMs: 8, sigma: 0.45 }, + { name: "GET /api/spans", medianMs: 21, sigma: 0.6 }, + { name: "POST /v1/traces", medianMs: 5.5, sigma: 0.35 }, + { name: "GET /api/services", medianMs: 46, sigma: 0.5 }, + { name: "POST /api/query", medianMs: 190, sigma: 0.75 }, + { name: "GET /api/dashboards", medianMs: 62, sigma: 0.55 }, + { name: "pg.select alert_rules", medianMs: 2.4, sigma: 0.4 }, + { name: "clickhouse.spans_scan", medianMs: 540, sigma: 0.85 }, +] + +const SAMPLES_PER_OPERATION = 120 + +/** `makeRandom`: an LCG, the same sequence as legacy's for the same seed. */ +const nextState = (state: number): number => (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + +/** `makeRows`: two uniforms per sample through Box-Muller, in operation order. */ +const makeRows = (): ReadonlyArray => + OPERATIONS.flatMap((operation) => Array.from({ length: SAMPLES_PER_OPERATION }, () => operation)).reduce<{ + state: number + rows: ReadonlyArray + }>( + (acc, operation) => { + const s1 = nextState(acc.state) + const s2 = nextState(s1) + const u1 = Math.max(1e-9, s1 / 0x1_0000_0000) + const u2 = s2 / 0x1_0000_0000 + const normal = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) + const durationMs = Math.max( + 0.2, + Math.round(operation.medianMs * Math.exp(operation.sigma * normal) * 10) / 10, + ) + return { state: s2, rows: [...acc.rows, { operation: operation.name, durationMs }] } + }, + { state: 0xb0c5_1234 >>> 0, rows: [] }, + ).rows + +/** The box or outlier under the pointer: its summary rows, the median emphasised. */ +const tooltipOf = ( + points: ReadonlyArray>, +): Option.Option => + Option.map(Option.fromNullishOr(points[0]?.datum), (datum) => { + const row = (label: string, value: string) => ({ label, color: "", dashed: false, value }) + return ChartTooltip.Bucket({ + heading: String(datum.category), + ...(datum.kind === "outlier" ? undefined : { highlighted: "Median" }), + rows: + datum.kind === "outlier" + ? [row("Outlier", formatLatency(datum.value))] + : [ + row("Max (whisker)", formatLatency(datum.whiskerHigh)), + row("Q3", formatLatency(datum.q3)), + row("Median", formatLatency(datum.median)), + row("Q1", formatLatency(datum.q1)), + row("Min (whisker)", formatLatency(datum.whiskerLow)), + row("Samples", formatNumber(datum.count)), + ], + }) + }) + +export const buildBoxPlotSpike = (spec: SpikeSpec): BuiltChart => { + const rows = makeRows() + const color = spec.colors["--chart-1"] ?? "" + const durations = rows.map((row) => row.durationMs) + const definition = defineChart({ + marks: [ + boxY(rows, { + x: (row: BoxPlotRow) => row.operation, + y: (row: BoxPlotRow) => row.durationMs, + fill: color, + fillOpacity: 0.35, + stroke: color, + strokeWidth: 1.5, + inset: 2, + r: 2.5, + }), + ], + scales: { + x: { + scale: scaleBand( + OPERATIONS.map((operation) => operation.name), + [0, 1], + ).paddingInner(0.55), + axis: { + line: false, + ticks: { size: 0, padding: 8 }, + tickLabels: { rotate: -30, anchor: "end", fontSize: 10 }, + }, + }, + y: { + scale: scaleLog().domain([Math.min(...durations) * 0.85, Math.max(0, ...durations) * 1.2]), + grid: true, + axis: { line: false, ticks: { size: 0, padding: 6, format: formatLatency } }, + }, + }, + focus: "nearest", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + return { definition, focusKeyOf: (point) => String(point.datum.category), tooltipOf } +} diff --git a/apps/web-foldkit/src/lab/charts/spike-heatmap-model.ts b/apps/web-foldkit/src/lab/charts/spike-heatmap-model.ts new file mode 100644 index 0000000000..d708b47cde --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-heatmap-model.ts @@ -0,0 +1,88 @@ +import { heatmapSampleData } from "@maple/ui/components/charts/_shared/sample-data" + +// `HeatmapSpike`'s model (apps/web/src/lab/charts/heatmap-spike.tsx): empty rows and columns +// pruned, the holes listed, the colour domain and the cell geometry. Pure, so the lab page can +// render the "hidden" footer without loading the chart library. + +export interface HeatmapSpikeRow { + readonly x: string + readonly y: string + readonly value: number +} + +export interface HeatmapHole { + readonly x: string + readonly y: string +} + +export interface HeatmapSpikeModel { + readonly xDomain: ReadonlyArray + readonly yDomain: ReadonlyArray + readonly domain: readonly [number, number] + readonly visibleRows: ReadonlyArray + readonly holes: ReadonlyArray + readonly prunedColumns: number + readonly prunedRows: number + readonly geometry: Readonly<{ radius: number; inset: number; padding: number }> +} + +export const HEATMAP_SPIKE_ROWS: ReadonlyArray = heatmapSampleData + +const cellKey = (x: string, y: string) => `${x}\u0000${y}` +const uniqueInOrder = (values: ReadonlyArray): ReadonlyArray => [...new Set(values)] + +const solveGeometry = (columns: number, rows: number): HeatmapSpikeModel["geometry"] => { + if (columns > 40 || rows > 24) return { radius: 1, inset: 0.5, padding: 0.04 } + if (columns > 18 || rows > 12) return { radius: 2, inset: 1, padding: 0.05 } + return { radius: 3, inset: 1, padding: 0.06 } +} + +const domainOf = (rows: ReadonlyArray): readonly [number, number] => { + const finite = rows.map((row) => row.value).filter((value) => Number.isFinite(value)) + if (finite.length === 0) return [0, 1] + const min = Math.min(...finite) + const max = Math.max(...finite) + return min === max ? [min, min + 1] : [min, max] +} + +/** `buildModel`. */ +export const heatmapSpikeModel = (rows: ReadonlyArray): HeatmapSpikeModel => { + const present = new Set(rows.map((row) => cellKey(row.x, row.y))) + const allX = uniqueInOrder(rows.map((row) => row.x)) + const allY = uniqueInOrder(rows.map((row) => row.y)) + const signal = new Set( + rows + .filter((row) => Number.isFinite(row.value) && row.value !== 0) + .map((row) => cellKey(row.x, row.y)), + ) + const nonEmptyX = allX.filter((x) => allY.some((y) => signal.has(cellKey(x, y)))) + const nonEmptyY = allY.filter((y) => allX.some((x) => signal.has(cellKey(x, y)))) + const xDomain = nonEmptyX.length > 0 ? nonEmptyX : allX + const yDomain = [...(nonEmptyY.length > 0 ? nonEmptyY : allY)].reverse() + const visibleX = new Set(xDomain) + const visibleY = new Set(yDomain) + const visibleRows = rows.filter((row) => visibleX.has(row.x) && visibleY.has(row.y)) + return { + xDomain, + yDomain, + domain: domainOf(visibleRows), + visibleRows, + holes: xDomain.flatMap((x) => yDomain.flatMap((y) => (present.has(cellKey(x, y)) ? [] : [{ x, y }]))), + prunedColumns: allX.length - nonEmptyX.length, + prunedRows: allY.length - nonEmptyY.length, + geometry: solveGeometry(xDomain.length, yDomain.length), + } +} + +const pluralize = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}` + +/** The footer under the plot when anything was pruned, else `null`. */ +export const heatmapPrunedNote = (model: HeatmapSpikeModel): string | null => + model.prunedColumns > 0 || model.prunedRows > 0 + ? `${[ + model.prunedColumns > 0 ? pluralize(model.prunedColumns, "empty column") : null, + model.prunedRows > 0 ? pluralize(model.prunedRows, "empty row") : null, + ] + .filter((part) => part !== null) + .join(" · ")} hidden` + : null diff --git a/apps/web-foldkit/src/lab/charts/spike-heatmap.ts b/apps/web-foldkit/src/lab/charts/spike-heatmap.ts new file mode 100644 index 0000000000..d75cc21a9a --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-heatmap.ts @@ -0,0 +1,85 @@ +import { createSequentialColorScale } from "@maple/ui/components/plot/color-scale" +import { formatNumber } from "@maple/ui/lib/format" +import { cell, type ChartPoint, type ChartValue, defineChart } from "@tanstack/charts" +import { colorGradientLegend } from "@tanstack/charts/legend" +import { tooltip } from "@tanstack/charts/tooltip" +import { scaleBand } from "@tanstack/charts-scales/band" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import { + HEATMAP_SPIKE_ROWS, + type HeatmapHole, + type HeatmapSpikeRow, + heatmapSpikeModel, +} from "./spike-heatmap-model" +import type { SpikeSpec } from "./spike-spec" +import { RAMP_TOKENS } from "./spike-timeseries-tokens" + +// `HeatmapSpike` (apps/web/src/lab/charts/heatmap-spike.tsx), definition half: grout cells for the +// holes, value cells on a sequential scale, a gradient legend. Code copied, constants unchanged. + +type Slot = HeatmapSpikeRow | HeatmapHole +type Point = ChartPoint + +export const buildHeatmapSpike = (spec: SpikeSpec): BuiltChart => { + const model = heatmapSpikeModel(HEATMAP_SPIKE_ROWS) + const grout = spec.colors["--heatmap-grout"] ?? "" + const foreground = spec.colors["--foreground"] ?? "" + const colorScale = createSequentialColorScale({ + stops: RAMP_TOKENS.amber.map(([token]) => spec.colors[token] ?? ""), + domain: model.domain, + scaleType: "linear", + }) + const { radius, inset, padding } = model.geometry + const holes = cell(model.holes, { + x: (hole: HeatmapHole) => hole.x, + y: (hole: HeatmapHole) => hole.y, + fill: grout, + inset, + radius, + }) + const data = cell(model.visibleRows, { + x: (row: HeatmapSpikeRow) => row.x, + y: (row: HeatmapSpikeRow) => row.y, + color: (row: HeatmapSpikeRow) => row.value, + inset, + radius, + states: [ + { when: { focus: "unmatched" }, style: { opacity: 0.28 } }, + { when: { focus: "x" }, style: { opacity: 1 } }, + { when: { focus: "y" }, style: { opacity: 1 } }, + { when: { focus: "primary" }, style: { stroke: foreground, strokeWidth: 1.5 } }, + ], + }) + const definition = defineChart({ + marks: [holes, data], + scales: { + x: { scale: scaleBand([...model.xDomain], [0, 1]).paddingInner(padding), grid: false }, + y: { scale: scaleBand([...model.yDomain], [0, 1]).paddingInner(padding), grid: false }, + }, + color: { + scale: colorScale, + legend: colorGradientLegend({ + label: "Count", + placement: "bottom", + format: (value) => formatNumber(Math.round(value)), + }), + }, + focus: "nearest", + focusRing: false, + tooltip: { use: tooltip, className: "maple-plot-tooltip" }, + }) + return { + definition, + focusKeyOf: (point) => `${point.datum.x}:${point.datum.y}`, + tooltipOf: (points: ReadonlyArray) => + Option.map(Option.fromNullishOr(points[0]?.datum), (slot) => + ChartTooltip.HeatmapSlot({ + x: slot.x, + y: slot.y, + value: "value" in slot ? slot.value : null, + }), + ), + } +} diff --git a/apps/web-foldkit/src/lab/charts/spike-host.ts b/apps/web-foldkit/src/lab/charts/spike-host.ts new file mode 100644 index 0000000000..cd666791a8 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-host.ts @@ -0,0 +1,85 @@ +import { Option } from "effect" +import { + type ChartHost, + type ChartHostOptions, + createChartHost as createProductionHost, + hostFor, +} from "../../charts/chart-host" +import type { ChartSpec } from "../../charts/chart-spec" +import type { SpikeSpec } from "./spike-spec" +import { buildAreaSpike, buildLineSpike } from "./spike-timeseries" +import { buildStackedBarSceneSpike, buildStackedBarSpike } from "./spike-stacked-bar" +import { buildHeatmapSpike } from "./spike-heatmap" +import { buildBoxPlotSpike } from "./spike-box-plot" +import { buildTraceScatterSpike } from "./spike-scatter" +import { buildSankeySpike } from "./spike-sankey" +import { buildTreemapSpike } from "./spike-treemap" + +/** + * The `/lab/charts` prototypes' chart host: a `LabSpike` spec is built by its arm's definition, + * anything else by the production host. Loaded by `MountChart host: "lab"`, only on the lab page. + */ + +const isSpike = (spec: ChartSpec): spec is SpikeSpec => spec._tag === "LabSpike" + +/** The spec, while it is still this arm's: an arm swap means a new host. */ +const armOf = + (arm: string) => + (spec: ChartSpec): Option.Option => + isSpike(spec) && spec.arm === arm ? Option.some(spec) : Option.none() + +const spikeHost = ( + container: HTMLElement, + spec: SpikeSpec, + height: number, + options: ChartHostOptions, +): ChartHost => { + const narrow = armOf(spec.arm) + switch (spec.arm) { + case "line-tanstack": + case "line-incomplete-tanstack": + case "line-legend-tanstack": + return hostFor(container, spec, height, narrow, buildLineSpike, options) + case "area-tanstack": + case "area-incomplete-tanstack": + case "area-legend-tanstack": + return hostFor(container, spec, height, narrow, buildAreaSpike, options) + case "box-plot": + return hostFor(container, spec, height, narrow, buildBoxPlotSpike, options) + case "trace-scatter": + return hostFor(container, spec, height, narrow, buildTraceScatterSpike, options) + case "sankey": + case "sankey-legend": + return hostFor(container, spec, height, narrow, buildSankeySpike, options) + case "treemap": + case "treemap-legend": + return hostFor(container, spec, height, narrow, buildTreemapSpike, options) + case "heatmap-tanstack": + return hostFor(container, spec, height, narrow, buildHeatmapSpike, options) + case "stacked-bar-tanstack": + case "stacked-bar-incomplete-tanstack": + case "stacked-bar-legend-tanstack": + return hostFor(container, spec, height, narrow, buildStackedBarSpike, options) + case "stacked-bar-legend-scene-tanstack": + return hostFor( + container, + spec, + height, + narrow, + buildStackedBarSceneSpike(options.callbacks.onLegendToggle ?? (() => undefined)), + options, + ) + default: + return { update: () => false, destroy: () => undefined } + } +} + +export const createChartHost = ( + container: HTMLElement, + spec: ChartSpec, + height: number, + options: ChartHostOptions, +): ChartHost => + isSpike(spec) + ? spikeHost(container, spec, height, options) + : createProductionHost(container, spec, height, options) diff --git a/apps/web-foldkit/src/lab/charts/spike-sankey-data.ts b/apps/web-foldkit/src/lab/charts/spike-sankey-data.ts new file mode 100644 index 0000000000..80b81b44a4 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-sankey-data.ts @@ -0,0 +1,57 @@ +// The service-flow fixture of `SankeySpike` (apps/web/src/lab/charts/sankey-spike.tsx) and its +// error bands, kept apart from the definition so the lab page can build the legend. + +export interface SankeyEdge { + readonly sourceService: string + readonly targetService: string + readonly callCount: number + readonly errorCount: number +} + +export interface SankeyNode { + readonly service: string +} + +export const SANKEY_EDGES: ReadonlyArray = [ + { sourceService: "edge-gateway", targetService: "web", callCount: 48_200, errorCount: 96 }, + { sourceService: "edge-gateway", targetService: "api", callCount: 31_400, errorCount: 210 }, + { sourceService: "web", targetService: "api", callCount: 26_800, errorCount: 134 }, + { sourceService: "web", targetService: "auth", callCount: 12_050, errorCount: 24 }, + { sourceService: "api", targetService: "auth", callCount: 18_900, errorCount: 57 }, + { sourceService: "api", targetService: "query-engine", callCount: 22_400, errorCount: 1_120 }, + { sourceService: "api", targetService: "billing", callCount: 4_300, errorCount: 602 }, + { sourceService: "api", targetService: "postgres", callCount: 15_600, errorCount: 31 }, + { sourceService: "auth", targetService: "postgres", callCount: 9_800, errorCount: 12 }, + { sourceService: "billing", targetService: "postgres", callCount: 3_150, errorCount: 189 }, + { sourceService: "query-engine", targetService: "clickhouse", callCount: 20_100, errorCount: 402 }, + { sourceService: "query-engine", targetService: "postgres", callCount: 2_400, errorCount: 8 }, + { sourceService: "billing", targetService: "clickhouse", callCount: 1_260, errorCount: 302 }, + { sourceService: "auth", targetService: "clickhouse", callCount: 890, errorCount: 4 }, +] + +export const SANKEY_NODES: ReadonlyArray = [ + { service: "edge-gateway" }, + { service: "web" }, + { service: "api" }, + { service: "auth" }, + { service: "query-engine" }, + { service: "billing" }, + { service: "postgres" }, + { service: "clickhouse" }, +] + +/** `FLOW_BANDS`: the status key, worst first. */ +export const FLOW_BANDS = [ + { key: "error", label: "≥ 5% errors", token: "--destructive" }, + { key: "warning", label: "1–5% errors", token: "--chart-3" }, + { key: "healthy", label: "< 1% errors", token: "--chart-1" }, +] as const + +export const FLOW_TOKENS = [ + "--chart-2", + "--foreground", + "--chart-1", + "--chart-3", + "--destructive", + "--background", +] diff --git a/apps/web-foldkit/src/lab/charts/spike-sankey.ts b/apps/web-foldkit/src/lab/charts/spike-sankey.ts new file mode 100644 index 0000000000..80e12b621c --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-sankey.ts @@ -0,0 +1,190 @@ +import { muteColor } from "@maple/ui/components/plot/color-scale" +import { type ChartCurve, type ChartValue, defineChart } from "@tanstack/charts" +import { link } from "@tanstack/charts/link" +import { + type SankeyLink, + type SankeyNode as SankeyLayoutNode, + sankeyDiagram, +} from "@tanstack/charts/network/sankey" +import { rect } from "@tanstack/charts/rect" +import { text } from "@tanstack/charts/text" +import { tooltip } from "@tanstack/charts/tooltip" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import { MUTED_COLOR_AMOUNT } from "../../charts/definitions/plot-constants" +import { FLOW_BANDS, SANKEY_EDGES, SANKEY_NODES, type SankeyEdge, type SankeyNode } from "./spike-sankey-data" +import { mutedOf, type SpikeSpec } from "./spike-spec" + +// `SankeySpike` / `SankeyLegendSpike` (apps/web/src/lab/charts/sankey-spike.tsx), definition half: +// service call flow weighted by call volume, ribbons coloured by error band. Code copied. + +const NODE_WIDTH = 14 + +const compactCount = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }) + +/** A horizontal bump curve: each segment a cubic with both controls at the mid x. */ +const bumpLine = (points: ReadonlyArray): string => { + const [first, ...rest] = points + if (first === undefined) return "" + return rest.reduce( + (acc, point) => { + const midX = (acc.previous[0] + point[0]) / 2 + return { + path: `${acc.path}C${midX},${acc.previous[1]},${midX},${point[1]},${point[0]},${point[1]}`, + previous: point, + } + }, + { path: `M${first[0]},${first[1]}`, previous: first }, + ).path +} + +const bumpX: ChartCurve = { + line: (points) => bumpLine(points), + area: (top, bottom) => { + if (top.length === 0) return "" + const back = [...bottom].reverse() + return `${bumpLine(top)}${bumpLine(back).replace("M", "L")}Z` + }, +} + +/** `bandForEdge`: the error band an edge's ribbon is coloured by. */ +const bandForEdge = (edge: SankeyEdge): (typeof FLOW_BANDS)[number]["key"] => { + const rate = edge.callCount === 0 ? 0 : edge.errorCount / edge.callCount + return rate >= 0.05 ? "error" : rate >= 0.01 ? "warning" : "healthy" +} + +/** Every listed node some edge touches, then any service only the edges name. */ +const nodesOf = (edges: ReadonlyArray): ReadonlyArray => { + const listed = SANKEY_NODES.filter((node) => + edges.some((edge) => edge.sourceService === node.service || edge.targetService === node.service), + ) + const extra = [...new Set(edges.flatMap((edge) => [edge.sourceService, edge.targetService]))] + .filter((service) => !listed.some((node) => node.service === service)) + .map((service) => ({ service })) + return [...listed, ...extra] +} + +type Datum = SankeyLayoutNode | SankeyLink + +/** The node's call total, or the link's calls and errors (packed for the lab's body). */ +const tooltipOfDatum = ( + datum: Datum, + nodeColor: string, + strokeForEdge: (edge: SankeyEdge) => string, +): ChartTooltip => { + if (datum.kind !== "link") + return ChartTooltip.Bucket({ + heading: "", + rows: [ + { + label: datum.data.service, + color: nodeColor, + dashed: false, + value: `${compactCount.format(datum.value)} calls`, + }, + ], + }) + const edge = datum.data + const rate = edge.callCount === 0 ? 0 : edge.errorCount / edge.callCount + return ChartTooltip.Bucket({ + heading: `${edge.sourceService} → ${edge.targetService}`, + rows: [ + { + label: "calls", + color: strokeForEdge(edge), + dashed: false, + value: compactCount.format(edge.callCount), + }, + { + label: "errors", + color: strokeForEdge(edge), + dashed: false, + value: `${compactCount.format(edge.errorCount)} (${Math.round(rate * 1000) / 10}%)`, + }, + ], + }) +} + +export const buildSankeySpike = (spec: SpikeSpec): BuiltChart => { + const color = (token: string) => spec.colors[token] ?? "" + const muted = mutedOf( + spec, + FLOW_BANDS.map((band) => band.key), + ) + const bandColor = (band: (typeof FLOW_BANDS)[number]["key"]) => + color(FLOW_BANDS.find((candidate) => candidate.key === band)?.token ?? "--chart-1") + const strokeForEdge = (edge: SankeyEdge): string => { + const band = bandForEdge(edge) + return muted.has(band) + ? muteColor(bandColor(band), color("--background"), MUTED_COLOR_AMOUNT) + : bandColor(band) + } + const definition = defineChart({ + marks: [ + sankeyDiagram({ + nodes: nodesOf(SANKEY_EDGES), + links: SANKEY_EDGES, + nodeKey: (node: SankeyNode) => node.service, + source: (edge: SankeyEdge) => edge.sourceService, + target: (edge: SankeyEdge) => edge.targetService, + value: (edge: SankeyEdge) => edge.callCount, + nodeWidth: NODE_WIDTH, + nodePadding: 14, + inset: { top: 8, right: 96, bottom: 8, left: 8 }, + nodeSort: null, + marks: (context) => [ + link(context.links, { + x1: (l) => l.x1, + y1: (l) => l.y1, + x2: (l) => l.x2, + y2: (l) => l.y2, + curve: bumpX, + stroke: (l) => strokeForEdge(l.data), + strokeWidth: (l) => Math.max(1, l.width), + strokeOpacity: 0.38, + lineCap: "butt", + key: (l) => l.key, + }), + rect(context.nodes, { + x1: (n) => n.x0, + x2: (n) => n.x1, + y1: (n) => n.y0, + y2: (n) => n.y1, + fill: color("--chart-2"), + radius: 2, + key: (n) => n.key, + states: [ + { when: { focus: "primary" }, style: { fillOpacity: 1 } }, + { when: { focus: "unmatched" }, style: { fillOpacity: 0.4 } }, + ], + }), + text(context.nodes, { + x: (n) => n.x1 + 6, + y: (n) => n.y, + text: (n) => n.data.service, + anchor: "start", + fill: color("--foreground"), + fontSize: 11, + fontWeight: 500, + dy: 4, + key: (n) => n.key, + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + focus: "nearest", + focusRing: false, + tooltip: { use: tooltip, className: "maple-plot-tooltip" }, + }) + return { + definition, + focusKeyOf: (point) => String(point.datum.key), + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]?.datum), (datum) => + tooltipOfDatum(datum, color("--chart-2"), strokeForEdge), + ), + } +} diff --git a/apps/web-foldkit/src/lab/charts/spike-scatter.ts b/apps/web-foldkit/src/lab/charts/spike-scatter.ts new file mode 100644 index 0000000000..49840fab7b --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-scatter.ts @@ -0,0 +1,165 @@ +import { createSequentialColorScale } from "@maple/ui/components/plot/color-scale" +import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { type ChartPoint, type ChartValue, defineChart } from "@tanstack/charts" +import { colorGradientLegend } from "@tanstack/charts/legend" +import { type HexbinDatum, hexbin } from "@tanstack/charts/spatial/hexbin" +import { tooltip } from "@tanstack/charts/tooltip" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { scaleLog } from "d3-scale" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import type { SpikeSpec } from "./spike-spec" +import { RAMP_TOKENS } from "./spike-timeseries-tokens" + +// `TraceScatterSpike` (apps/web/src/lab/charts/trace-scatter-spike.tsx), definition half: 5,000 +// seeded spans hex-binned by screen-space density. Code copied, constants unchanged. + +interface ScatterRow { + readonly startMs: number + readonly durationMs: number +} + +const SPAN_COUNT = 5_000 +const WINDOW_MS = 30 * 60 * 1000 +const WINDOW_END_MS = Date.UTC(2026, 7, 17, 14, 0) +const WINDOW_START_MS = WINDOW_END_MS - WINDOW_MS +const DURATION_DOMAIN: readonly [number, number] = [0.4, 10_000] +const DENSITY_DOMAIN: readonly [number, number] = [1, 90] +const BIN_WIDTH_PX = 13 +const EDGE_PAD_FRACTION = 0.016 + +const OUTPUTS = { + count: { reduce: "count" }, + slowestMs: { value: (row: ScatterRow) => row.durationMs, reduce: "max" }, +} as const +type Bin = HexbinDatum + +const nextState = (state: number): number => (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + +/** `makeSpans`: four uniforms per span (jitter, two Box-Muller, the mode roll), in order. */ +const makeSpans = (): ReadonlyArray => { + const burstStart = WINDOW_START_MS + WINDOW_MS * 0.62 + const burstEnd = burstStart + 3 * 60 * 1000 + return Array.from({ length: SPAN_COUNT }, (_, index) => index).reduce<{ + state: number + rows: Array + }>( + (acc, index) => { + const s1 = nextState(acc.state) + const s2 = nextState(s1) + const s3 = nextState(s2) + const s4 = nextState(s3) + const uniform = (state: number) => state / 0x1_0000_0000 + const phase = (index / SPAN_COUNT) * Math.PI * 4 + const jitter = (uniform(s1) - 0.5) * 0.04 + const position = Math.min( + 0.9999, + Math.max(0, index / SPAN_COUNT + 0.03 * Math.sin(phase) + jitter), + ) + const startMs = WINDOW_START_MS + position * WINDOW_MS + const u1 = Math.max(1e-9, uniform(s2)) + const normal = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * uniform(s3)) + const roll = uniform(s4) + const durationMs = + startMs >= burstStart && startMs <= burstEnd && roll < 0.28 + ? 900 * Math.exp(0.45 * normal) + : roll < 0.05 + ? 320 * Math.exp(0.7 * normal) + : roll < 0.22 + ? 70 * Math.exp(0.5 * normal) + : 12 * Math.exp(0.55 * normal) + acc.rows.push({ startMs, durationMs: Math.min(9_500, Math.max(0.4, durationMs)) }) + return { state: s4, rows: acc.rows } + }, + { state: 0x5eed_1234 >>> 0, rows: [] }, + ).rows +} + +const padTimeDomain = (min: number, max: number): readonly [number, number] => { + const pad = (max - min) * EDGE_PAD_FRACTION + return [min - pad, max + pad] +} + +const timeDomainOf = (rows: ReadonlyArray): readonly [number, number] => { + const starts = rows.map((row) => row.startMs).filter((value) => Number.isFinite(value)) + const min = Math.min(...starts) + const max = Math.max(...starts) + return !Number.isFinite(min) || !Number.isFinite(max) || min === max + ? padTimeDomain(WINDOW_START_MS, WINDOW_END_MS) + : padTimeDomain(min, max) +} + +const clockOf = (ms: number, withSeconds: boolean): string => + new Date(ms).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + ...(withSeconds ? { second: "2-digit" } : undefined), + }) + +export const buildTraceScatterSpike = (spec: SpikeSpec) => { + const rows = makeSpans() + const colorScale = createSequentialColorScale({ + stops: RAMP_TOKENS.blues.map(([token]) => spec.colors[token] ?? ""), + domain: DENSITY_DOMAIN, + scaleType: "log", + }) + const timeDomain = timeDomainOf(rows) + const marks = [ + hexbin(rows, { + x: (row: ScatterRow) => row.startMs, + y: (row: ScatterRow) => row.durationMs, + binWidth: BIN_WIDTH_PX, + outputs: OUTPUTS, + color: (bin) => bin.count, + strokeWidth: 0, + }), + ] as const + const definition = defineChart({ + clip: true, + marks, + scales: { + x: { + scale: scaleLinear([timeDomain[0], timeDomain[1]], [0, 1]), + grid: false, + axis: { ticks: { count: 6, format: (value: number) => clockOf(value, false) } }, + }, + y: { + scale: scaleLog().domain([...DURATION_DOMAIN]), + grid: true, + axis: { line: false, ticks: { padding: 10, format: formatDuration } }, + }, + }, + color: { + scale: colorScale, + legend: colorGradientLegend({ + label: "Spans per bin", + placement: "bottom", + format: (value) => formatNumber(Math.round(value)), + }), + }, + focus: "nearest", + focusRing: true, + tooltip: { use: tooltip, className: "maple-plot-tooltip" }, + }) + const built: BuiltChart = { + definition, + focusKeyOf: (point) => `${point.datum.x}:${point.datum.y}`, + // Packed for the lab's body: the clock, then the count with its swatch and the reading. + tooltipOf: (points: ReadonlyArray>) => + Option.map(Option.fromNullishOr(points[0]?.datum), (bin) => + ChartTooltip.Bucket({ + heading: clockOf(bin.x, true), + rows: [ + { + label: `${formatNumber(bin.count)} spans`, + color: colorScale(bin.count), + dashed: false, + value: `~${formatDuration(bin.y)} · slowest ${formatDuration(bin.slowestMs)}`, + }, + ], + }), + ), + } + return built +} diff --git a/apps/web-foldkit/src/lab/charts/spike-spec.ts b/apps/web-foldkit/src/lab/charts/spike-spec.ts new file mode 100644 index 0000000000..8d07a9aa08 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-spec.ts @@ -0,0 +1,12 @@ +import type { ChartSpec } from "../../charts/chart-spec" + +// The `/lab/charts` prototypes' spec (`ChartSpec.LabSpike`) and the emphasis helpers they share. + +export type SpikeSpec = Extract + +/** `MUTED_OPACITY` (plot-legend.tsx): how far a non-highlighted series fades. */ +export const MUTED_OPACITY = 0.22 + +/** The series a pinned legend row pushes back: every other one. */ +export const mutedOf = (spec: SpikeSpec, ids: ReadonlyArray): ReadonlySet => + new Set(spec.highlighted === null ? [] : ids.filter((id) => id !== spec.highlighted)) diff --git a/apps/web-foldkit/src/lab/charts/spike-stacked-bar.ts b/apps/web-foldkit/src/lab/charts/spike-stacked-bar.ts new file mode 100644 index 0000000000..d47fd6e155 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-stacked-bar.ts @@ -0,0 +1,205 @@ +import { muteColor } from "@maple/ui/components/plot/color-scale" +import { formatBucketLabel, formatNumber } from "@maple/ui/lib/format" +import { + STACKED_BAR_PARTIAL_ROWS, + STACKED_BAR_SERVICES, + STACKED_BAR_SPIKE_ROWS, + stackedBarAxisContext, + type StackedBarSpikeRow, +} from "@maple/web/lab/charts/timeseries-data" +import { barY, type ChartPoint, type ChartValue, defineChart, stack } from "@tanstack/charts" +import { scaleBand } from "@tanstack/charts-scales/band" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { controlledSignal } from "@tanstack/charts/interaction/signal" +import { type InteractiveColorLegendChange, interactiveColorLegend } from "@tanstack/charts/legend" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import { cursorTooltip, MUTED_COLOR_AMOUNT } from "../../charts/definitions/plot-constants" +import type { SpikeSpec } from "./spike-spec" +import { STACK_TOKEN_BY_SERVICE } from "./spike-timeseries-tokens" + +// `StackedBarSpike`, `StackedBarLegendSpike` and `StackedBarSceneLegendSpike` +// (apps/web/src/lab/charts/stacked-bar-spike.tsx), definition half. Code copied, constants unchanged. + +type Point = ChartPoint + +const PARTIAL_DASHARRAY = "4 4" +const SOLID_DASHARRAY = "0" + +const isIncomplete = (spec: SpikeSpec): boolean => spec.arm === "stacked-bar-incomplete-tanstack" +const rowsOf = (spec: SpikeSpec) => (isIncomplete(spec) ? STACKED_BAR_PARTIAL_ROWS : STACKED_BAR_SPIKE_ROWS) + +/** `useServiceColor`, with the legend arm's emphasis applied (`emphasisedColorFor`). */ +const colorForOf = (spec: SpikeSpec) => { + const base = (service: string): string => + spec.colors[STACK_TOKEN_BY_SERVICE.get(service) ?? "--chart-1"] ?? "" + const highlighted = spec.highlighted + if (highlighted === null) return base + const background = spec.colors["--background"] ?? "" + return (service: string) => + service === highlighted ? base(service) : muteColor(base(service), background, MUTED_COLOR_AMOUNT) +} + +const byBucketOf = ( + rows: ReadonlyArray, +): ReadonlyMap> => + rows.reduce( + (map, row) => map.set(row.bucket, [...(map.get(row.bucket) ?? []), row]), + new Map>(), + ) + +const stackMaxOf = (byBucket: ReadonlyMap>): number => + [...byBucket.values()].reduce( + (max, rows) => + Math.max( + max, + rows.reduce((sum, row) => sum + row.spans, 0), + ), + 0, + ) + +const scalesOf = ( + buckets: ReadonlyArray, + stackMax: number, + axisContext: ReturnType, +) => ({ + x: { + scale: scaleBand([...buckets], [0, 1]).paddingInner(0.2), + axis: { + line: false, + ticks: { + size: 0, + padding: 8, + spacing: 72, + format: (value: string) => formatBucketLabel(value, axisContext, "tick"), + }, + }, + }, + y: { + scale: scaleLinear().domain([0, stackMax]), + nice: true, + grid: true, + axis: { line: false, ticks: { size: 0, padding: 6, format: formatNumber } }, + }, +}) + +/** The bucket's rows, the hovered service emphasised, and their total. */ +const bucketTooltip = + ( + byBucket: ReadonlyMap>, + axisContext: ReturnType, + colorFor: (service: string) => string, + ) => + (points: ReadonlyArray): Option.Option => + Option.map(Option.fromNullishOr(points[0]?.datum), (datum) => { + const rows = byBucket.get(datum.bucket) ?? [] + return ChartTooltip.Bucket({ + heading: `${formatBucketLabel(datum.bucket, axisContext, "tooltip")}${datum.partial ? " · partial" : ""}`, + highlighted: datum.service, + rows: rows.map((row) => ({ + label: row.service, + color: colorFor(row.service), + dashed: row.partial, + value: formatNumber(row.spans), + })), + total: formatNumber(rows.reduce((sum, row) => sum + row.spans, 0)), + }) + }) + +/** `StackedBarFigure`. */ +export const buildStackedBarSpike = (spec: SpikeSpec): BuiltChart => { + const rows = rowsOf(spec) + const incomplete = isIncomplete(spec) + const colorFor = colorForOf(spec) + const foreground = spec.colors["--foreground"] ?? "" + const axisContext = stackedBarAxisContext(rows) + const byBucket = byBucketOf(rows) + const definition = defineChart({ + marks: [ + barY(rows, { + x: (d: StackedBarSpikeRow) => d.bucket, + y: (d: StackedBarSpikeRow) => d.spans, + z: (d: StackedBarSpikeRow) => d.service, + fill: (d: StackedBarSpikeRow) => colorFor(d.service), + layout: stack({ order: STACKED_BAR_SERVICES }), + inset: 0.5, + radius: 0, + stroke: (d: StackedBarSpikeRow) => (incomplete && d.partial ? foreground : "transparent"), + strokeDasharray: (d: StackedBarSpikeRow) => + incomplete && d.partial ? PARTIAL_DASHARRAY : SOLID_DASHARRAY, + strokeWidth: 1.5, + fillOpacity: 1, + states: [{ when: { focus: "primary" }, style: { fillOpacity: 1 } }], + }), + ], + scales: scalesOf([...byBucket.keys()], stackMaxOf(byBucket), axisContext), + focus: "nearest", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + return { + definition, + focusKeyOf: (point) => `${point.datum.bucket}:${point.datum.service}`, + tooltipOf: bucketTooltip(byBucket, axisContext, colorFor), + } +} + +/** `StackedBarSceneLegendSpike`: the library's own legend, which can only hide a band. */ +export const buildStackedBarSceneSpike = + (onToggle: (key: string, keys: ReadonlyArray) => void) => + (spec: SpikeSpec): BuiltChart => { + const rows = STACKED_BAR_SPIKE_ROWS + const colorFor = colorForOf({ ...spec, highlighted: null }) + const axisContext = stackedBarAxisContext(rows) + const byBucket = byBucketOf(rows) + const visible = STACKED_BAR_SERVICES.filter((service) => !spec.hidden.includes(service)) + const definition = defineChart({ + marks: [ + barY(rows, { + x: (d: StackedBarSpikeRow) => d.bucket, + y: (d: StackedBarSpikeRow) => d.spans, + color: (d: StackedBarSpikeRow) => d.service, + layout: stack({ order: STACKED_BAR_SERVICES }), + inset: 0.5, + radius: 0, + states: [{ when: { focus: "primary" }, style: { fillOpacity: 1 } }], + }), + ], + color: { + domain: STACKED_BAR_SERVICES, + range: STACKED_BAR_SERVICES.map(colorFor), + legend: interactiveColorLegend({ + visible: controlledSignal, InteractiveColorLegendChange>( + visible, + (_next, { reason }) => onToggle(reason.value, STACKED_BAR_SERVICES), + ), + placement: "bottom", + ariaLabel: "Spans by service", + }), + }, + scales: scalesOf([...byBucket.keys()], stackMaxOf(byBucket) || 1, axisContext), + focus: "nearest", + focusRing: false, + tooltip: cursorTooltip("pointer"), + }) + return { + definition, + focusKeyOf: (point) => `${point.datum.bucket}:${point.datum.service}`, + // One row, no heading: the hovered band alone. + tooltipOf: (points) => + Option.map(Option.fromNullishOr(points[0]?.datum), (datum) => + ChartTooltip.Bucket({ + heading: "", + rows: [ + { + label: datum.service, + color: colorFor(datum.service), + dashed: false, + value: formatNumber(datum.spans), + }, + ], + }), + ), + } + } diff --git a/apps/web-foldkit/src/lab/charts/spike-timeseries-tokens.ts b/apps/web-foldkit/src/lab/charts/spike-timeseries-tokens.ts new file mode 100644 index 0000000000..398bc6779b --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-timeseries-tokens.ts @@ -0,0 +1,39 @@ +// The colour tokens the prototypes read, kept apart from their definitions so the lab page can +// resolve them without loading `@tanstack/charts`. + +export const LINE_TOKENS = ["--chart-p99", "--chart-p95", "--chart-p50"] as const +export const AREA_TOKENS = ["--chart-throughput", "--chart-error"] as const +export const CHROME_TOKENS = ["--border", "--background"] as const + +/** `STACK_TOKENS` (stacked-bar-spike.tsx): one chart token per service. */ +export const STACK_TOKEN_BY_SERVICE: ReadonlyMap = new Map([ + ["api", "--chart-1"], + ["web", "--chart-2"], + ["ingest", "--chart-3"], + ["worker", "--chart-4"], + ["db", "--chart-5"], +]) +export const STACK_TOKENS = [...STACK_TOKEN_BY_SERVICE.values(), "--foreground", "--background"] + +/** A token with the literal `resolvePlotColor` falls back to when the property is unset. */ +export type TokenWithFallback = readonly [token: string, fallback: string] + +/** `HEATMAP_RAMP_TOKENS` and `DENSITY_RAMP_TOKENS` (packages/ui/src/components/plot/color-scale.ts). */ +export const RAMP_TOKENS = { + amber: [ + ["--heatmap-amber-0", "oklch(0.32 0.035 70)"], + ["--heatmap-amber-1", "oklch(0.45 0.075 65)"], + ["--heatmap-amber-2", "oklch(0.58 0.115 60)"], + ["--heatmap-amber-3", "oklch(0.71 0.15 57)"], + ["--heatmap-amber-4", "oklch(0.85 0.13 78)"], + ], + blues: [ + ["--heatmap-blues-0", "oklch(0.3 0.04 250)"], + ["--heatmap-blues-1", "oklch(0.43 0.09 250)"], + ["--heatmap-blues-2", "oklch(0.56 0.13 248)"], + ["--heatmap-blues-3", "oklch(0.69 0.15 240)"], + ["--heatmap-blues-4", "oklch(0.84 0.12 220)"], + ], +} as const satisfies Readonly>> + +export const GROUT_TOKEN: TokenWithFallback = ["--heatmap-grout", "oklch(0.175 0.008 62)"] diff --git a/apps/web-foldkit/src/lab/charts/spike-timeseries.ts b/apps/web-foldkit/src/lab/charts/spike-timeseries.ts new file mode 100644 index 0000000000..13cf512cff --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-timeseries.ts @@ -0,0 +1,270 @@ +import { focusCrosshair, focusDot } from "@maple/ui/components/plot/plot-focus" +import { muteColor } from "@maple/ui/components/plot/color-scale" +import { formatBucketLabel, formatLatency, formatNumber } from "@maple/ui/lib/format" +import { + errorThroughput, + splitAtFirstPartial, + timeseriesAxisContext, + TIMESERIES_PARTIAL_ROWS, + TIMESERIES_SPIKE_ROWS, + type TimeseriesSpikeRow, +} from "@maple/web/lab/charts/timeseries-data" +import { areaY, type ChartPoint, type ChartValue, defineChart, lineY } from "@tanstack/charts" +import { scaleLinear } from "@tanstack/charts-scales/linear" +import { scaleTime } from "d3-scale" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import { roundCapDasharray, verticalGradient } from "../../charts/definitions/fixed-metric-axes" +import { + cursorTooltip, + MUTED_COLOR_AMOUNT, + tooltipFocusCapture, +} from "../../charts/definitions/plot-constants" +import { type SpikeSpec, mutedOf, MUTED_OPACITY } from "./spike-spec" + +// `LineSpike` / `LineLegendSpike` and `AreaSpike` / `AreaLegendSpike` +// (apps/web/src/lab/charts/{line,area}-spike.tsx), definition half. Code copied, constants unchanged. + +type Point = ChartPoint + +const STROKE_WIDTH = 2.5 +const INCOMPLETE_DASHARRAY = roundCapDasharray(4.5, 7, STROKE_WIDTH) +const FADED_START_OPACITY = 0.15 +const FADED_END_OPACITY = 0 + +interface Series { + readonly id: string + readonly label: string + readonly color: string + readonly value: (row: TimeseriesSpikeRow) => number +} + +const color = (spec: SpikeSpec, token: string): string => spec.colors[token] ?? "" + +export const latencySeries = (spec: SpikeSpec): ReadonlyArray => [ + { id: "p99", label: "P99", color: color(spec, "--chart-p99"), value: (row) => row.p99LatencyMs }, + { id: "p95", label: "P95", color: color(spec, "--chart-p95"), value: (row) => row.p95LatencyMs }, + { id: "p50", label: "P50", color: color(spec, "--chart-p50"), value: (row) => row.p50LatencyMs }, +] + +export const throughputSeries = (spec: SpikeSpec): ReadonlyArray => [ + { + id: "throughput", + label: "Throughput", + color: color(spec, "--chart-throughput"), + value: (row) => row.throughput, + }, + { id: "errors", label: "Errors", color: color(spec, "--chart-error"), value: errorThroughput }, +] + +const chromeOf = (spec: SpikeSpec) => ({ + border: color(spec, "--border"), + background: color(spec, "--background"), +}) + +const xScale = (axisContext: ReturnType) => ({ + scale: scaleTime, + axis: { + line: false, + ticks: { + size: 0, + padding: 8, + spacing: 72, + format: (value: Date) => formatBucketLabel(value.toISOString(), axisContext, "tick"), + }, + }, +}) + +/** `PlotTooltipBody` reduced to data, emphasising the series nearest the cursor. */ +const tooltipOf = + ( + series: ReadonlyArray, + axisContext: ReturnType, + format: (value: number) => string, + highlight: (plotted: ReadonlyArray) => string | undefined, + ) => + (points: ReadonlyArray): Option.Option => + Option.map(Option.fromNullishOr(points[0]), ({ datum }) => { + const highlighted = highlight(series.map((entry) => [entry.label, entry.value(datum)] as const)) + return ChartTooltip.Bucket({ + heading: `${formatBucketLabel(datum.bucket, axisContext, "tooltip")}${datum.partial ? " · partial" : ""}`, + ...(highlighted === undefined ? undefined : { highlighted }), + rows: series.map((entry) => ({ + label: entry.label, + color: entry.color, + dashed: false, + value: format(entry.value(datum)), + })), + }) + }) + +const rowsOf = (spec: SpikeSpec): ReadonlyArray => + spec.arm.includes("incomplete") ? TIMESERIES_PARTIAL_ROWS : TIMESERIES_SPIKE_ROWS + +const splitOf = (spec: SpikeSpec, rows: ReadonlyArray) => + spec.arm.includes("incomplete") ? splitAtFirstPartial(rows) : { solid: rows, dashed: [] } + +/** The y domain over every series, muted ones included: emphasis never moves the axis. */ +const dataMaxOf = (rows: ReadonlyArray, series: ReadonlyArray): number => + rows.reduce( + (max, row) => series.reduce((seriesMax, entry) => Math.max(seriesMax, entry.value(row)), max), + 0, + ) + +/** `LineFigure`. */ +export const buildLineSpike = (spec: SpikeSpec): BuiltChart => { + const rows = rowsOf(spec) + const series = latencySeries(spec) + const muted = mutedOf( + spec, + series.map((entry) => entry.id), + ) + const chrome = chromeOf(spec) + const axisContext = timeseriesAxisContext(rows) + const focus = tooltipFocusCapture() + const { solid, dashed } = splitOf(spec, rows) + const opacityFor = (id: string) => (muted.has(id) ? MUTED_OPACITY : 1) + const line = (data: ReadonlyArray, entry: Series, isDashed: boolean) => + lineY(data, { + id: isDashed ? `${entry.id}Incomplete` : entry.id, + x: (d: TimeseriesSpikeRow) => d.date, + y: entry.value, + stroke: entry.color, + strokeWidth: STROKE_WIDTH, + strokeOpacity: opacityFor(entry.id), + ...(isDashed ? { strokeDasharray: INCOMPLETE_DASHARRAY } : undefined), + }) + const definition = defineChart({ + marks: [ + ...series.map((entry) => line(solid, entry, false)), + ...series.map((entry) => line(dashed, entry, true)), + ...series.map((entry) => + focusDot( + rows, + (d: TimeseriesSpikeRow) => d.date, + entry.value, + muted.has(entry.id) + ? muteColor(entry.color, chrome.background, MUTED_COLOR_AMOUNT) + : entry.color, + chrome, + ), + ), + focusCrosshair(chrome), + ], + scales: { + x: xScale(axisContext), + y: { + scale: scaleLinear().domain([0, dataMaxOf(rows, series)]), + nice: true, + grid: true, + axis: { + line: false, + ticks: { size: 0, padding: 6, format: (value: number) => formatLatency(value) }, + }, + }, + }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip(focus.anchor), + }) + return { + definition, + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: tooltipOf(series, axisContext, formatLatency, focus.highlight), + } +} + +/** `AreaFigure`. */ +export const buildAreaSpike = (spec: SpikeSpec): BuiltChart => { + const rows = rowsOf(spec) + const series = throughputSeries(spec) + const muted = mutedOf( + spec, + series.map((entry) => entry.id), + ) + const chrome = chromeOf(spec) + const axisContext = timeseriesAxisContext(rows) + const focus = tooltipFocusCapture() + const { solid, dashed } = splitOf(spec, rows) + const idPrefix = `areaSpike-${spec.arm}-` + const opacityFor = (id: string) => (muted.has(id) ? MUTED_OPACITY : 1) + const x = (d: TimeseriesSpikeRow) => d.date + const definition = defineChart({ + gradients: series.flatMap((entry) => [ + verticalGradient(`${idPrefix}${entry.id}`, entry.color), + verticalGradient( + `${idPrefix}${entry.id}Faded`, + entry.color, + FADED_START_OPACITY, + FADED_END_OPACITY, + ), + ]), + marks: [ + ...series.flatMap((entry) => [ + areaY(solid, { + id: `${entry.id}Area`, + x, + y: entry.value, + fill: `url(#${idPrefix}${entry.id})`, + stroke: "none", + fillOpacity: opacityFor(entry.id), + }), + areaY(dashed, { + id: `${entry.id}AreaIncomplete`, + x, + y: entry.value, + fill: `url(#${idPrefix}${entry.id}Faded)`, + stroke: "none", + fillOpacity: opacityFor(entry.id), + }), + lineY(solid, { + id: `${entry.id}Line`, + x, + y: entry.value, + stroke: entry.color, + strokeWidth: STROKE_WIDTH, + strokeOpacity: opacityFor(entry.id), + }), + lineY(dashed, { + id: `${entry.id}LineIncomplete`, + x, + y: entry.value, + stroke: entry.color, + strokeWidth: STROKE_WIDTH, + strokeOpacity: opacityFor(entry.id), + strokeDasharray: INCOMPLETE_DASHARRAY, + }), + ]), + ...series.map((entry) => + focusDot( + rows, + x, + entry.value, + muted.has(entry.id) + ? muteColor(entry.color, chrome.background, MUTED_COLOR_AMOUNT) + : entry.color, + chrome, + ), + ), + focusCrosshair(chrome), + ], + scales: { + x: xScale(axisContext), + y: { + scale: scaleLinear().domain([0, dataMaxOf(rows, series)]), + nice: true, + grid: true, + axis: { line: false, ticks: { size: 0, padding: 6, format: formatNumber } }, + }, + }, + focus: "group-x", + focusRing: false, + tooltip: cursorTooltip(focus.anchor), + }) + return { + definition, + focusKeyOf: (point) => point.datum.bucket, + tooltipOf: tooltipOf(series, axisContext, formatNumber, focus.highlight), + } +} diff --git a/apps/web-foldkit/src/lab/charts/spike-treemap-data.ts b/apps/web-foldkit/src/lab/charts/spike-treemap-data.ts new file mode 100644 index 0000000000..ff7d6ced16 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-treemap-data.ts @@ -0,0 +1,43 @@ +// The span-volume fixture of `TreemapSpike` (apps/web/src/lab/charts/treemap-spike.tsx) and its +// service palette, kept apart from the definition so the lab page can build the legend. + +export interface TreemapRow { + readonly service: string + readonly operation: string + readonly spanCount: number +} + +export const TREEMAP_ROWS: ReadonlyArray = [ + { service: "api", operation: "GET /v2/traces", spanCount: 184_200 }, + { service: "api", operation: "POST /v2/query", spanCount: 96_400 }, + { service: "api", operation: "GET /v2/dashboards", spanCount: 41_800 }, + { service: "api", operation: "GET /health", spanCount: 22_600 }, + { service: "api", operation: "POST /v2/alerts", spanCount: 8_150 }, + { service: "query-engine", operation: "executeSql", spanCount: 142_700 }, + { service: "query-engine", operation: "compileCH", spanCount: 88_300 }, + { service: "query-engine", operation: "cache.lookup", spanCount: 61_500 }, + { service: "query-engine", operation: "decodeRows", spanCount: 30_900 }, + { service: "ingest", operation: "otlp.traces", spanCount: 268_400 }, + { service: "ingest", operation: "otlp.logs", spanCount: 74_100 }, + { service: "ingest", operation: "key.resolve", spanCount: 52_300 }, + { service: "web", operation: "ssr.render", spanCount: 39_600 }, + { service: "web", operation: "loader.dashboard", spanCount: 27_200 }, + { service: "web", operation: "loader.traces", spanCount: 19_450 }, + { service: "web", operation: "loader.alerts", spanCount: 11_300 }, + { service: "auth", operation: "token.validate", spanCount: 58_900 }, + { service: "auth", operation: "org.resolve", spanCount: 24_700 }, + { service: "auth", operation: "session.refresh", spanCount: 6_050 }, + { service: "alerting", operation: "rule.evaluate", spanCount: 33_800 }, + { service: "alerting", operation: "incident.upsert", spanCount: 9_400 }, + { service: "alerting", operation: "notify.dispatch", spanCount: 4_120 }, + { service: "alerting", operation: "check.persist", spanCount: 2_980 }, +] + +export const TILE_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5", "--background"] + +/** The services in first-seen order. */ +export const TREEMAP_SERVICES: ReadonlyArray = [...new Set(TREEMAP_ROWS.map((row) => row.service))] + +/** `serviceColor`'s token: the palette by first-seen service order. */ +export const serviceToken = (service: string): string => + TILE_TOKENS[Math.max(0, TREEMAP_SERVICES.indexOf(service)) % 5] ?? "--chart-1" diff --git a/apps/web-foldkit/src/lab/charts/spike-treemap.ts b/apps/web-foldkit/src/lab/charts/spike-treemap.ts new file mode 100644 index 0000000000..86842fb8c7 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-treemap.ts @@ -0,0 +1,91 @@ +import { muteColor } from "@maple/ui/components/plot/color-scale" +import { type ChartPoint, type ChartValue, defineChart } from "@tanstack/charts" +import { type TreemapNode, treemap } from "@tanstack/charts/hierarchy/treemap" +import { tooltip } from "@tanstack/charts/tooltip" +import { Option } from "effect" +import { ChartTooltip } from "../../charts/chart-spec" +import type { BuiltChart } from "../../charts/definitions/built-chart" +import { MUTED_COLOR_AMOUNT } from "../../charts/definitions/plot-constants" +import type { SpikeSpec } from "./spike-spec" +import { serviceToken, TREEMAP_ROWS, type TreemapRow } from "./spike-treemap-data" + +// `TreemapSpike` / `TreemapLegendSpike` (apps/web/src/lab/charts/treemap-spike.tsx), definition +// half: span volume tiled service → operation, the legend muting by colour. Code copied. + +const PATH_DELIMITER = "\u0000" +const MUTED_LABEL_AMOUNT = 0.45 + +const compactCount = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }) + +type Node = TreemapNode + +export const buildTreemapSpike = (spec: SpikeSpec): BuiltChart => { + const color = (token: string) => spec.colors[token] ?? "" + const background = color("--background") + const baseColor = (service: string) => color(serviceToken(service)) + const highlighted = spec.highlighted + const serviceColor = + highlighted === null + ? baseColor + : (service: string) => + service === highlighted + ? baseColor(service) + : muteColor(baseColor(service), background, MUTED_COLOR_AMOUNT) + const labelColorFor = (service: string) => + highlighted === null || service === highlighted + ? background + : muteColor(baseColor(service), background, MUTED_LABEL_AMOUNT) + const total = TREEMAP_ROWS.reduce((sum, row) => sum + row.spanCount, 0) + const definition = defineChart({ + marks: [ + treemap(TREEMAP_ROWS, { + path: (row: TreemapRow) => `${row.service}${PATH_DELIMITER}${row.operation}`, + delimiter: PATH_DELIMITER, + value: (row: TreemapRow) => row.spanCount, + method: "squarify", + paddingInner: 2, + round: true, + radius: 3, + fill: (node) => serviceColor(node.data?.service ?? node.name), + fillOpacity: 0.82, + stroke: background, + strokeWidth: 1, + states: [ + { when: { focus: "primary" }, style: { fillOpacity: 1 } }, + { when: { focus: "unmatched" }, style: { fillOpacity: 0.45 } }, + ], + label: (node) => node.name, + labelFill: (node) => labelColorFor(node.data?.service ?? node.name), + labelFontSize: 10, + labelFontWeight: 600, + labelPadding: 6, + }), + ], + scales: { x: null, y: null }, + guides: false, + focus: "nearest", + focusRing: false, + tooltip: { use: tooltip, className: "maple-plot-tooltip" }, + }) + return { + definition, + focusKeyOf: (point) => point.datum.name, + // Packed for the lab's body: the service, then the operation with its spans and share. + tooltipOf: (points: ReadonlyArray>) => + Option.map(Option.fromNullishOr(points[0]?.datum), (node) => { + const service = node.data?.service ?? node.name + const share = total === 0 ? 0 : node.value / total + return ChartTooltip.Bucket({ + heading: service, + rows: [ + { + label: node.data?.operation ?? node.name, + color: serviceColor(service), + dashed: false, + value: `${compactCount.format(node.value)} (${Math.round(share * 1000) / 10}%)`, + }, + ], + }) + }), + } +} diff --git a/apps/web-foldkit/src/lab/charts/spike-view.ts b/apps/web-foldkit/src/lab/charts/spike-view.ts new file mode 100644 index 0000000000..79ee9f8ed7 --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spike-view.ts @@ -0,0 +1,297 @@ +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Message, Model } from "../../charts/chart" +import { ChartSpec, ChartTooltip } from "../../charts/chart-spec" +import { type BucketTooltip, bucketTooltipBody } from "../../charts/bucket-tooltip" +import { plotFrame } from "../../charts/plot-frame" +import { legendRow, type PlotLegendSeries } from "../../charts/plot-legend" +import { sequentialColorScale } from "../../charts/sequential-colors" +import { resolveColor, type ThemeColors } from "../../charts/theme-colors" +import { formatNumber } from "@maple/ui/lib/format" +import { HEATMAP_SPIKE_ROWS, heatmapSpikeModel } from "./spike-heatmap-model" +import { RAMP_TOKENS, type TokenWithFallback } from "./spike-timeseries-tokens" + +// A `/lab/charts` prototype in its `PlotFrame`: the lab host draws it from `ChartSpec.LabSpike`, +// the frame carries its DOM legend (`PlotSeriesLegend`) and the tooltip body. + +export interface SpikeInputs { + readonly chartId: string + readonly arm: string + readonly theme: ThemeColors + /** The tokens the arm's definition reads, resolved here (canvas cannot read `var()`). */ + readonly tokens: ReadonlyArray + readonly footer?: string + readonly ariaLabel: string + readonly className?: string + /** `PlotSeriesLegend`: pinned emphasis by click, nothing on hover. */ + readonly legend?: Readonly<{ label: string; series: ReadonlyArray }> +} + +type H = HtmlBuilder + +const swatch = (h: H, color: string, isDashed: boolean): Html => + h.span([ + h.Class( + isDashed ? "size-2.5 shrink-0 rounded-xs border border-dashed" : "size-2.5 shrink-0 rounded-xs", + ), + h.Style(isDashed ? { "border-color": color } : { "background-color": color }), + ]) + +const figure = (h: H, value: string): Html => + h.span([h.Class("font-mono font-semibold text-foreground tabular-nums")], [value]) + +/** The stacked bar's body: the bucket, one row per service (the hovered one bold), the total. */ +const stackTooltipBody = (h: H, bucket: BucketTooltip, total: string, portal: Attribute): Html => + h.div( + [h.Class("grid min-w-[9rem] items-start gap-1.5"), portal], + [ + h.div( + [h.Class("border-border/50 border-b pb-1 font-medium text-muted-foreground tracking-tight")], + [bucket.heading], + ), + h.div( + [h.Class("grid gap-1.5")], + bucket.rows.map((row) => + h.div( + [ + h.Key(row.label), + h.Class( + row.label === bucket.highlighted + ? "flex w-full items-center gap-2 [&_*]:font-semibold" + : "flex w-full items-center gap-2", + ), + ], + [ + swatch(h, row.color, row.dashed), + h.div( + [h.Class("flex flex-1 items-center justify-between gap-3 leading-none")], + [ + h.span([h.Class("text-muted-foreground")], [row.label]), + figure(h, row.value), + ], + ), + ], + ), + ), + ), + h.div( + [ + h.Class( + "flex w-full items-center justify-between gap-3 border-border/50 border-t pt-1 leading-none", + ), + ], + [h.span([h.Class("text-muted-foreground")], ["Total"]), figure(h, total)], + ), + ], + ) + +/** The scene-legend arm's body: the hovered band alone. */ +const bandTooltipBody = (h: H, bucket: BucketTooltip, portal: Attribute): Html => + h.div( + [h.Class("flex items-center gap-2"), portal], + bucket.rows.flatMap((row) => [ + swatch(h, row.color, false), + h.span([h.Class("text-muted-foreground")], [row.label]), + figure(h, row.value), + ]), + ) + +/** `HeatmapSpike`'s body: the cell's coordinates, its swatch and count, or "no data" on a hole. */ +const slotTooltipBody = ( + h: H, + slot: Extract, + inputs: SpikeInputs, + portal: Attribute, +): Html => { + const label = h.span([h.Class("text-muted-foreground")], [`${slot.x} · ${slot.y}`]) + if (slot.value === null) + return h.div( + [h.Class("flex items-center gap-2"), portal], + [label, h.span([h.Class("text-muted-foreground italic")], ["no data"])], + ) + const model = heatmapSpikeModel(HEATMAP_SPIKE_ROWS) + const colorAt = sequentialColorScale({ + stops: RAMP_TOKENS.amber.map(([token, fallback]) => resolveColor(inputs.theme, token, fallback)), + domain: model.domain, + scaleType: "linear", + }) + return h.div( + [h.Class("flex items-center gap-2"), portal], + [ + h.span([ + h.Class("size-2.5 shrink-0 rounded-xs"), + h.Style({ "background-color": colorAt(slot.value) }), + ]), + label, + h.span([h.Class("font-mono font-semibold tabular-nums")], [formatNumber(slot.value)]), + ], + ) +} + +const muted = (h: H, text: string): Html => h.span([h.Class("text-muted-foreground")], [text]) +const mono = (h: H, text: string): Html => h.span([h.Class("font-mono font-semibold tabular-nums")], [text]) +const keyValue = (h: H, label: string, value: string, className: string): Html => + h.div([h.Class(className)], [muted(h, label), mono(h, value)]) + +/** `BoxPlotSpike`'s body: the operation, then its outlier or its five-number summary. */ +const boxTooltipBody = (h: H, bucket: BucketTooltip, portal: Attribute): Html => { + const heading = h.div( + [h.Class("border-border/50 border-b pb-1 font-medium text-muted-foreground tracking-tight")], + [bucket.heading], + ) + const row = (label: string, value: string, className: string) => + h.div([h.Key(label), h.Class(className)], [muted(h, label), figure(h, value)]) + const base = "flex w-full items-center justify-between gap-3 leading-none" + if (bucket.highlighted === undefined) + return h.div( + [h.Class("grid min-w-[10rem] items-start gap-1.5"), portal], + [heading, ...bucket.rows.map((entry) => row(entry.label, entry.value, base))], + ) + return h.div( + [h.Class("grid min-w-[11rem] items-start gap-1.5"), portal], + [ + heading, + h.div( + [h.Class("grid gap-1.5")], + bucket.rows.map((entry) => + row( + entry.label, + entry.value, + entry.label === bucket.highlighted ? `${base} [&_*]:font-semibold` : base, + ), + ), + ), + ], + ) +} + +/** The body each arm's `renderTooltipBody` draws, from the heading and rows its host packed. */ +const bucketBody = (h: H, arm: string, bucket: BucketTooltip, portal: Attribute): Html => { + const [first, second] = bucket.rows + switch (arm) { + case "box-plot": + return boxTooltipBody(h, bucket, portal) + case "trace-scatter": + return h.div( + [h.Class("flex flex-col gap-0.5"), portal], + [ + h.div( + [h.Class("flex items-center gap-2")], + [swatch(h, first?.color ?? "", false), mono(h, first?.label ?? "")], + ), + muted(h, first?.value ?? ""), + muted(h, bucket.heading), + ], + ) + case "sankey": + case "sankey-legend": + return bucket.heading === "" + ? h.div( + [h.Class("flex items-center gap-2"), portal], + [ + swatch(h, first?.color ?? "", false), + muted(h, first?.label ?? ""), + mono(h, first?.value ?? ""), + ], + ) + : h.div( + [h.Class("flex flex-col gap-1"), portal], + [ + h.div( + [h.Class("flex items-center gap-2")], + [swatch(h, first?.color ?? "", false), muted(h, bucket.heading)], + ), + keyValue( + h, + first?.label ?? "", + first?.value ?? "", + "flex items-center justify-between gap-4 pl-[18px]", + ), + keyValue( + h, + second?.label ?? "", + second?.value ?? "", + "flex items-center justify-between gap-4 pl-[18px]", + ), + ], + ) + case "treemap": + case "treemap-legend": + return h.div( + [h.Class("flex flex-col gap-1"), portal], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + swatch(h, first?.color ?? "", false), + muted(h, bucket.heading), + h.span([h.Class("font-medium")], [first?.label ?? ""]), + ], + ), + keyValue( + h, + "spans", + first?.value ?? "", + "flex items-center justify-between gap-4 pl-[18px]", + ), + ], + ) + default: + return bucket.total !== undefined + ? stackTooltipBody(h, bucket, bucket.total, portal) + : bucket.heading === "" + ? bandTooltipBody(h, bucket, portal) + : bucketTooltipBody(h, bucket, portal) + } +} + +export const spikeView = Submodel.defineView((model, inputs, h): Html => { + const legend = inputs.legend + return plotFrame(h, { + chartId: inputs.chartId, + className: inputs.className, + ariaLabel: inputs.ariaLabel, + host: "lab", + spec: ChartSpec.LabSpike({ + arm: inputs.arm, + colors: Object.fromEntries( + inputs.tokens.map((token) => + typeof token === "string" + ? [token, resolveColor(inputs.theme, token)] + : [token[0], resolveColor(inputs.theme, token[0], token[1])], + ), + ), + highlighted: legend === undefined ? null : Option.getOrNull(model.highlightedKey), + active: null, + hidden: model.hiddenKeys, + }), + ...(inputs.footer === undefined + ? undefined + : { + footer: h.p( + [h.Class("pt-1 text-right text-3xs text-muted-foreground/70")], + [inputs.footer], + ), + }), + legend: + legend === undefined + ? undefined + : legendRow(h, { + series: legend.series, + highlighted: model.highlightedKey, + active: Option.none(), + label: legend.label, + }), + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + ChartTooltip.match(tooltip, { + Bucket: (bucket) => Option.some(bucketBody(h, inputs.arm, bucket, portal)), + PieSlice: () => Option.none(), + NumericBin: () => Option.none(), + CategoricalBin: () => Option.none(), + HeatmapSlot: (slot) => Option.some(slotTooltipBody(h, slot, inputs, portal)), + }), + ), + }) +}) diff --git a/apps/web-foldkit/src/lab/charts/spikes.ts b/apps/web-foldkit/src/lab/charts/spikes.ts new file mode 100644 index 0000000000..836db495fc --- /dev/null +++ b/apps/web-foldkit/src/lab/charts/spikes.ts @@ -0,0 +1,206 @@ +import { histogramSampleData } from "@maple/ui/components/charts/_shared/sample-data" +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { Submodel } from "foldkit" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Message, Model } from "../../charts/chart" +import { ChartSpec, ChartTooltip } from "../../charts/chart-spec" +import { countTooltipBody } from "../../charts/histogram-chart" +import { formatInterval } from "../../charts/histogram-format" +import { plotFrame } from "../../charts/plot-frame" +import { statsLegend } from "../../charts/plot-legend" +import { muteColor } from "../../charts/sequential-colors" +import { resolveColor, type ThemeColors } from "../../charts/theme-colors" + +// Ports of `PieSpike` (apps/web/src/lab/charts/pie-spike.tsx) and `HistogramSpike` +// (apps/web/src/lab/charts/histogram-spike.tsx), canvas arm. + +export interface PieSpikeRow { + readonly name: string + readonly value: number +} + +/** `MUTED_COLOR_AMOUNT` (plot-legend.tsx). */ +const MUTED_COLOR_AMOUNT = 0.78 + +const SLICE_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const +export const SPIKE_TOKENS: ReadonlyArray = [...SLICE_TOKENS] + +/** `useSliceColor`: a slice's colour by its row's position in the full list. */ +const sliceColorFor = (theme: ThemeColors, rows: ReadonlyArray) => { + const palette = SLICE_TOKENS.map((token) => resolveColor(theme, token)) + const order = new Map() + for (const row of rows) { + if (!order.has(row.name)) order.set(row.name, order.size) + } + return (name: string): string => palette[(order.get(name) ?? 0) % palette.length] ?? palette[0] ?? "" +} + +export interface PieSpikeInputs { + readonly chartId: string + readonly rows: ReadonlyArray + readonly theme: ThemeColors + readonly donut?: boolean + readonly className?: string +} + +const pieSpikeTooltip = ( + h: HtmlBuilder, + tooltip: ChartTooltip, + portal: Attribute, +): Option.Option => + ChartTooltip.match(tooltip, { + PieSlice: (slice) => + Option.some( + h.div( + [h.Class("flex items-center gap-2"), portal], + [ + h.span([ + h.Class("size-2.5 shrink-0 rounded-xs"), + h.Style({ "background-color": slice.color }), + ]), + h.span([h.Class("text-muted-foreground")], [slice.name]), + h.span( + [h.Class("font-mono font-semibold tabular-nums")], + [`${Math.round(slice.fraction * 1000) / 10}%`], + ), + ], + ), + ), + NumericBin: () => Option.none(), + CategoricalBin: () => Option.none(), + HeatmapSlot: () => Option.none(), + Bucket: () => Option.none(), + }) + +/** `PieSpike` = `PieFigure` with no legend, so `activeName` is never wired. */ +export const pieSpikeView = Submodel.defineView((model, inputs, h) => { + const colorFor = sliceColorFor(inputs.theme, inputs.rows) + return plotFrame(h, { + chartId: inputs.chartId, + className: inputs.className, + ariaLabel: "Share by category", + spec: ChartSpec.PieSpike({ + rows: inputs.rows.map((row) => ({ name: row.name, value: row.value, color: colorFor(row.name) })), + donut: inputs.donut ?? true, + }), + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => pieSpikeTooltip(h, tooltip, portal)), + }) +}) + +/** + * `PieLegendSpike`: the pie beside its stats legend. Plot focus and legend hover share the active + * slice (it grows); a legend click pins one slice and mutes the rest by colour. + */ +export const pieLegendSpikeView = Submodel.defineView((model, inputs, h) => { + const colorFor = sliceColorFor(inputs.theme, inputs.rows) + const background = resolveColor(inputs.theme, "--background") + const emphasised = Option.match(model.highlightedKey, { + onNone: () => colorFor, + onSome: (highlighted) => (name: string) => + name === highlighted ? colorFor(name) : muteColor(colorFor(name), background, MUTED_COLOR_AMOUNT), + }) + const total = inputs.rows.reduce((sum, row) => sum + row.value, 0) + const legend = statsLegend(h, { + series: inputs.rows.map((row) => ({ + key: row.name, + label: row.name, + color: colorFor(row.name), + value: formatNumber(row.value), + secondary: total === 0 ? "—" : `${Math.round((row.value / total) * 1000) / 10}%`, + })), + highlighted: model.highlightedKey, + active: model.activeKey, + label: "Share by category", + }) + return h.div( + [h.Class(cn("flex min-h-0 items-center gap-3", inputs.className))], + [ + plotFrame(h, { + chartId: inputs.chartId, + className: "h-full min-w-0 flex-1", + ariaLabel: "Share by category", + spec: ChartSpec.PieSpike({ + rows: inputs.rows.map((row) => ({ + name: row.name, + value: row.value, + color: emphasised(row.name), + })), + donut: inputs.donut ?? true, + activeName: Option.getOrNull(model.activeKey), + }), + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => pieSpikeTooltip(h, tooltip, portal)), + }), + h.div([h.Class("w-56 shrink-0 overflow-auto")], Option.toArray(legend)), + ], + ) +}) + +/** `expandSampleData`: the pre-bucketed fixture spread back into evenly spaced raw observations. */ +const expandSampleData = (): ReadonlyArray => { + const values: number[] = [] + let previousWidth = 50 + for (const bucket of histogramSampleData) { + const [rawLower, rawUpper] = bucket.name.split("-") + const lower = Number.parseFloat(rawLower ?? "") + if (!Number.isFinite(lower)) continue + const parsedUpper = Number.parseFloat(rawUpper ?? "") + const upper = Number.isFinite(parsedUpper) ? parsedUpper : lower + previousWidth + previousWidth = upper - lower + for (let index = 0; index < bucket.value; index++) { + values.push(lower + ((index + 0.5) / bucket.value) * (upper - lower)) + } + } + return values +} + +/** `histogramSpikeRows`, as the plain numbers they wrap. */ +export const histogramSpikeValues: ReadonlyArray = expandSampleData() + +export interface HistogramSpikeInputs { + readonly chartId: string + readonly values: ReadonlyArray + readonly theme: ThemeColors + readonly logScaleY?: boolean + readonly className?: string +} + +export const histogramSpikeView = Submodel.defineView( + (model, inputs, h) => { + const color = resolveColor(inputs.theme, "--chart-1") + if (inputs.values.length === 0) { + return h.div(inputs.className === undefined ? [] : [h.Class(inputs.className)], [ + h.div( + [h.Class("grid h-full w-full place-items-center")], + [h.span([h.Class("text-2xs text-muted-foreground")], ["No data"])], + ), + ]) + } + return plotFrame(h, { + chartId: inputs.chartId, + className: inputs.className, + ariaLabel: "Value distribution", + spec: ChartSpec.BinnedHistogram({ + values: inputs.values, + color, + logScaleY: inputs.logScaleY ?? false, + }), + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + ChartTooltip.match(tooltip, { + NumericBin: (bin) => + Option.some( + countTooltipBody(h, formatInterval(bin.x1, bin.x2), bin.value, color, portal), + ), + CategoricalBin: () => Option.none(), + HeatmapSlot: () => Option.none(), + PieSlice: () => Option.none(), + Bucket: () => Option.none(), + }), + ), + }) + }, +) diff --git a/apps/web-foldkit/src/lab/chat-fixture.ts b/apps/web-foldkit/src/lab/chat-fixture.ts new file mode 100644 index 0000000000..8bd7363290 --- /dev/null +++ b/apps/web-foldkit/src/lab/chat-fixture.ts @@ -0,0 +1,191 @@ +import { TextPart, ToolPart, type UIMessage } from "../page/chat/kit/ui-message" + +// apps/web `lab/chat-fixture.ts` as Schema-typed messages: a merged tool run, a standalone call, +// prose with all four inline cards, a table, two charts, an approval, two sub-agents and a burst +// mid-flight. Inline cards are written the way the model emits them, so the real parser runs. + +const text = (value: string) => TextPart.make({ text: value, state: "done" }) + +const tool = ( + toolCallId: string, + toolName: string, + state: ToolPart["state"], + input: unknown, + rest: { readonly output?: unknown; readonly errorText?: string } = {}, +): ToolPart => ToolPart.make({ toolCallId, toolName, state, input, ...rest }) + +/** Eight minutes of the paywall worker's error count, ending at `nowMs` (the in-flight tail). */ +const errorsPerMinute = (nowMs: number) => + [4, 6, 5, 31, 88, 140, 132, 96].map((value, index, all) => ({ + bucket: new Date(Math.floor(nowMs / 60_000 - (all.length - index)) * 60_000).toISOString(), + series: { "web-paywall-worker": value }, + })) + +const INCIDENT_TEXT = [ + "**Three services are in trouble.** The paywall worker is failing outright; the other two are degraded.", + "", + '<>', + '<>', + '<>', + "", + "Nearly all of the paywall failures share one fingerprint:", + "", + '<>', + "", + "A representative trace and the log that precedes every failure:", + "", + '<>', + '<>', + "", + "Everything downstream of them is holding:", + "", + "| Service | Errors | P95 | Change vs last hour |", + "| --- | --- | --- | --- |", + "| subs-writer | 0.00% | 180ms | flat |", + "| entitlement-cache | 0.31% | 240ms | +0.1pp |", + "| mmp-api | 0.12% | 95ms | flat |", +].join("\n") + +const chartFence = (spec: unknown) => ["```chart", JSON.stringify(spec), "```"].join("\n") + +export const buildChatLabMessages = (nowMs: number): ReadonlyArray => { + // A fixed clock walked forward a minute per turn, so the footer's timestamp is exercised. + const at = (turn: number) => Date.UTC(2026, 8, 11, 14, 3) + turn * 60_000 + return [ + { id: "m1", role: "user", createdAt: at(1), parts: [text("Which services are unhealthy right now?")] }, + { + id: "m2", + role: "assistant", + createdAt: at(2), + parts: [tool("c1", "list_services", "output-available", { limit: 50 }, { output: { text: "18 services" } })], + }, + { + id: "m3", + role: "assistant", + createdAt: at(3), + parts: [ + tool("c2", "find_errors", "output-available", { service: "web-paywall-worker" }, { output: { text: "4 fingerprints" } }), + tool("c3", "find_slow_traces", "output-available", { service: "subscriptions-api", limit: 5 }, { output: { text: "5 traces" } }), + ], + }, + { + id: "m3b", + role: "assistant", + createdAt: at(4), + parts: [ + text("The paywall worker looks worst — pulling its slowest traces before I say anything about the other two."), + tool("c3b", "inspect_trace", "output-available", { traceId: "9f2c7ae41b6d5c08f31a" }, { output: { text: "37 spans" } }), + text("That trace spends 6.1s of its 8.4s inside the entitlement lookup."), + ], + }, + { id: "m4", role: "assistant", createdAt: at(5), parts: [text(INCIDENT_TEXT)] }, + { + id: "m4b", + role: "assistant", + createdAt: at(6), + parts: [ + text( + [ + "The failures start at 14:03 and hold — this is not a spike that recovered.", + "", + chartFence({ type: "area", title: "web-paywall-worker errors per minute", unit: "number", data: errorsPerMinute(nowMs) }), + ].join("\n"), + ), + ], + }, + { + id: "m4c", + role: "assistant", + createdAt: at(7), + parts: [ + text( + [ + "They are concentrated in four operations:", + "", + chartFence({ + type: "ranked", + title: "Failures by operation", + unit: "number", + data: [ + { name: "GET /api/paywall/check", value: 14208 }, + { name: "GET /api/paywall/bundle", value: 2611 }, + { name: "POST /api/paywall/refresh", value: 1102 }, + { name: "GET /api/paywall/trial", value: 501 }, + ], + }), + ].join("\n"), + ), + ], + }, + { id: "m5", role: "user", createdAt: at(8), parts: [text(":D")] }, + { + id: "m6", + role: "assistant", + createdAt: at(9), + parts: [ + text( + "Glad someone's in a good mood — the paywall worker isn't. Let me know if you want to dig into any of those failing services.", + ), + ], + }, + { id: "m7", role: "user", createdAt: at(10), parts: [text("yeah, mute it for an hour")] }, + { + id: "m8", + role: "assistant", + createdAt: at(11), + parts: [tool("c4", "update_alert_rule", "proposed", { ruleId: "rule_9182", muteUntil: "2026-09-11T18:00:00Z" })], + }, + { + id: "m9", + role: "assistant", + parts: [ + { + _tag: "task", + toolCallId: "t0", + agent: "explore", + prompt: 'Deep-dive the service "subscriptions-api": which operation regressed after the 14:20 deploy, and by how much?', + status: "running", + messages: [], + }, + { + _tag: "task", + toolCallId: "t1", + agent: "trace-analyst", + prompt: "Find every trace where the entitlement lookup returned an empty body", + status: "completed", + answer: + "212 traces hit the empty-body path, all of them on `web-paywall-worker`.\n\n" + + "Every one carries `entitlement.cache_hit=false`, so the lookup is reaching the " + + "upstream and getting a 204 back rather than falling back to the cached grant.", + budgetExhausted: true, + messages: [ + { + id: "t1-1", + role: "assistant", + parts: [ + tool("t1-c1", "search_traces", "output-available", { service: "web-paywall-worker" }, { output: { text: "212 traces" } }), + ], + }, + ], + }, + tool( + "c5", + "run_sql", + "output-error", + { sql: "SELECT count() FROM spans WHERE ServiceName = 'web-paywall-worker'" }, + { errorText: "Raw SQL is not enabled for this organization." }, + ), + ], + }, + { + id: "m10", + role: "assistant", + createdAt: at(12), + parts: [ + tool("c6", "list_error_issues", "output-available", { service: "web-paywall-worker" }, { output: { text: "6 issues" } }), + tool("c7", "error_detail", "output-available", { issueId: "iss_41ba" }, { output: { text: "18422 events" } }), + tool("c8", "search_traces", "input-available", { service: "subscriptions-api", query: "entitlements" }), + ], + }, + ] +} diff --git a/apps/web-foldkit/src/lab/cloudflare-lab/fixture.ts b/apps/web-foldkit/src/lab/cloudflare-lab/fixture.ts new file mode 100644 index 0000000000..a906b0352a --- /dev/null +++ b/apps/web-foldkit/src/lab/cloudflare-lab/fixture.ts @@ -0,0 +1,164 @@ +import { OrgId } from "@maple/domain" +import { + CloudflareAnalyticsZoneStatus, + CloudflareConnectedAccountStatus, + CloudflareIntegrationStatus, +} from "@maple/domain/http" +import { cloudflareAccounts } from "@maple/web/components/infra/cloudflare/accounts" +import { mergeWorkerRows } from "@maple/web/components/infra/cloudflare/list-model" +import { + DEMO_ACCOUNT_STATUSES, + DEMO_DURABLE_OBJECTS, + DEMO_NOW, + DEMO_WORKERS, + MAKI_ACCOUNT_ID, + MAKI_DURABLE_OBJECTS, + MAKI_QUEUES, + MAKI_WORKERS, + MAKI_ZONE_SPARKS, + MAKI_ZONES, + MAPLE_API_DETAIL, + MAPLE_API_RELEASES, + MAPLE_DEV_CACHE, + MAPLE_DEV_PATHS, + MAPLE_DEV_ROW, + MAPLE_DEV_STATUS, +} from "@maple/web/lab/cloudflare-fixture" +import { Schema } from "effect" +import { AsyncData, type Command } from "foldkit" +import type { Keyed } from "../../data/keyed" +import type { PageContext } from "../../page/contract" +import type { Resources } from "../../platform/resources" +import type { ListFrame } from "../../page/infra-integrations/cloudflare/list/view" +import type { Model as WorkerModel } from "../../page/infra-integrations/cloudflare/worker/model" +import type { Model as ZoneModel } from "../../page/infra-integrations/cloudflare/zone/model" + +// apps/web `lab/cloudflare-fixture.ts` as the Cloudflare pages' slots: a copy of production data. + +/** The pages run against a fixture org; their fetch Commands are dropped, so it is never asked. */ +export const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab_cloudflare"), + pinnedNamespace: null, + timeZone: "UTC", +} + +export const ACCOUNT_NAME = "Maki Account" + +/** The window the fixtures cover (legacy's breakdown `startTime`/`endTime`). */ +const WINDOW = { startTime: "2026-10-14 00:00:00", endTime: "2026-10-14 12:00:00" } + +const loaded = (data: A): Keyed => ({ key: null, data: AsyncData.Success({ data }) }) + +/** `ListPage`'s props: one account, or the three-account demo split. */ +export const listFrame = (multiAccount: boolean): ListFrame => ({ + data: { + zones: MAKI_ZONES, + sparks: MAKI_ZONE_SPARKS, + workerRows: multiAccount + ? mergeWorkerRows(DEMO_WORKERS, DEMO_DURABLE_OBJECTS) + : mergeWorkerRows(MAKI_WORKERS, MAKI_DURABLE_OBJECTS), + platform: { + queues: multiAccount ? MAKI_QUEUES.map((q) => ({ ...q, accountId: "acct-maple" })) : MAKI_QUEUES, + durableObjects: [], + }, + }, + accounts: multiAccount ? cloudflareAccounts(DEMO_ACCOUNT_STATUSES, DEMO_NOW) : [], + singleAccountName: ACCOUNT_NAME, + banner: null, +}) + +/** One account holding maple.dev: the zone page's plain "account" chip and rail name. */ +const ZONE_STATUS = new CloudflareIntegrationStatus({ + connected: true, + accountId: MAKI_ACCOUNT_ID, + accountName: ACCOUNT_NAME, + connectedByUserId: null, + scope: "analytics", + analyticsCapable: true, + accounts: [ + new CloudflareConnectedAccountStatus({ + accountId: MAKI_ACCOUNT_ID, + accountName: ACCOUNT_NAME, + connectedByUserId: null, + scope: "analytics", + analyticsCapable: true, + revoked: false, + zones: [ + new CloudflareAnalyticsZoneStatus({ + id: MAPLE_DEV_ROW.zoneName, + name: MAPLE_DEV_ROW.zoneName, + enabled: true, + lastSyncedAt: DEMO_NOW, + lastError: null, + watermarkAt: DEMO_NOW, + }), + ], + workers: null, + }), + ], + zones: [], + workers: null, +}) + +const WORKER_STATUS = new CloudflareIntegrationStatus({ + connected: true, + accountId: MAKI_ACCOUNT_ID, + accountName: ACCOUNT_NAME, + connectedByUserId: null, + scope: "analytics", + analyticsCapable: true, + accounts: [], + zones: [], + workers: null, +}) + +/** Legacy's rail gets `dns={{ queries: 4_400_000, alwaysNxdomain: 4 }}`: names that only NXDOMAIN. */ +const MAPLE_DEV_DNS = { + buckets: [{ bucket: "2026-10-14 00:00:00", responseCode: "NOERROR", queries: 4_400_000 }], + names: ["a", "b", "c", "d"].map((name) => ({ + queryName: `${name}.maple.dev`, + queries: 1, + nxdomain: 1, + })), + ignoredFilters: [], +} + +/** `ZonePage`'s reads, held whatever the page asked for (legacy passes them as props). */ +export const pinZone = (model: ZoneModel): ZoneModel => ({ + ...model, + window: WINDOW, + nowMs: DEMO_NOW, + status: loaded(ZONE_STATUS), + usage: { key: null, data: AsyncData.Idle() }, + detail: loaded({ + statusBuckets: MAPLE_DEV_STATUS, + cacheBuckets: MAPLE_DEV_CACHE, + latencyBuckets: [], + ignoredFilters: [], + latencyIgnoredFilters: [], + }), + dns: loaded(MAPLE_DEV_DNS), + security: loaded({ buckets: [], top: [], ignoredFilters: [] }), + zones: loaded([MAPLE_DEV_ROW]), + breakdown: loaded(MAPLE_DEV_PATHS), +}) + +/** `WorkerPage`'s reads: maple-api's rollup, outcomes and releases. */ +export const pinWorker = (model: WorkerModel): WorkerModel => ({ + ...model, + // Legacy's lab snaps deploys to 30-minute buckets from 00:00; a 48h window is what buckets so. + window: { startTime: WINDOW.startTime, endTime: "2026-10-16 00:00:00" }, + nowMs: DEMO_NOW, + status: loaded(WORKER_STATUS), + usage: { key: null, data: AsyncData.Idle() }, + detail: loaded(MAPLE_API_DETAIL), + workers: loaded(MAKI_WORKERS), + platform: loaded({ queues: MAKI_QUEUES, durableObjects: MAKI_DURABLE_OBJECTS }), + releases: loaded(MAPLE_API_RELEASES.map((release) => ({ ...release, serviceName: "maple-api" }))), +}) + +/** The pages' own UI Commands (charts, popovers, focus), without their warehouse and API reads. */ +export const withoutReads = ( + commands: ReadonlyArray> | undefined, +): ReadonlyArray> => + (commands ?? []).filter((command) => !/^(Resolve|Load|Fetch|Complete)Cloudflare/.test(command.name)) diff --git a/apps/web-foldkit/src/lab/cloudflare-lab/model.ts b/apps/web-foldkit/src/lab/cloudflare-lab/model.ts new file mode 100644 index 0000000000..3c38183e63 --- /dev/null +++ b/apps/web-foldkit/src/lab/cloudflare-lab/model.ts @@ -0,0 +1,44 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as List from "../../page/infra-integrations/cloudflare/list/model" +import * as Worker from "../../page/infra-integrations/cloudflare/worker/model" +import * as Zone from "../../page/infra-integrations/cloudflare/zone/model" + +// `/lab/cloudflare` (apps/web `lab/cloudflare-lab.tsx`): the Cloudflare pages, whole, over a copy of +// production data, switched by the floating "Lab page" pivot. + +export const LAB_PAGES = ["zones", "workers", "queues", "zone", "worker", "accounts"] as const +export const LabPage = Schema.Literals(LAB_PAGES) +export type LabPage = typeof LabPage.Type + +export const PAGE_LABEL = { + zones: "List · Zones", + workers: "List · Workers", + queues: "List · Queues", + zone: "Zone · maple.dev", + worker: "Worker · maple-api", + accounts: "3 accounts (demo)", +} as const satisfies Record + +/** The browser's zone, read before init: the chart axes and tooltips print in it. */ +export const Flags = Schema.Struct({ timeZone: Schema.String }) +export type Flags = typeof Flags.Type + +export const Model = Schema.Struct({ + timeZone: Schema.String, + page: LabPage, + /** The "3 accounts" page's own view, kept apart from the single-account pages. */ + multiView: List.View, + list: List.Model, + zone: Zone.Model, + worker: Worker.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + ClickedLabPage: { page: LabPage }, + GotListMessage: { message: List.Message }, + GotZoneMessage: { message: Zone.Message }, + GotWorkerMessage: { message: Worker.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/cloudflare-lab/update.test.ts b/apps/web-foldkit/src/lab/cloudflare-lab/update.test.ts new file mode 100644 index 0000000000..9e2f067922 --- /dev/null +++ b/apps/web-foldkit/src/lab/cloudflare-lab/update.test.ts @@ -0,0 +1,59 @@ +import { AsyncData } from "foldkit" +import { describe, expect, test } from "vitest" +import * as List from "../../page/infra-integrations/cloudflare/list/model" +import * as Zone from "../../page/infra-integrations/cloudflare/zone/model" +import { Message } from "./model" +import { init, update } from "./update" + +const opened = () => init({ timeZone: "UTC" }) + +describe("cloudflare lab", () => { + test("boots on the zone list, sending only the chart runtime and UI Commands", () => { + const { model, commands } = opened() + expect([model.page, model.list.search.view]).toEqual(["zones", "zones"]) + expect((commands ?? []).filter((command) => /Cloudflare/.test(command.name))).toEqual([]) + }) + + test("the list's view tabs move the lab pivot, and the pivot moves the list", () => { + const viaTabs = update( + opened().model, + Message.GotListMessage({ message: List.Message.ClickedView({ view: "queues" }) }), + ).model + expect([viaTabs.page, viaTabs.list.search.view]).toEqual(["queues", "queues"]) + const viaPivot = update(viaTabs, Message.ClickedLabPage({ page: "workers" })).model + expect([viaPivot.page, viaPivot.list.search.view]).toEqual(["workers", "workers"]) + }) + + test("the three-account page keeps its own view", () => { + const demo = update(opened().model, Message.ClickedLabPage({ page: "accounts" })).model + const switched = update( + demo, + Message.GotListMessage({ message: List.Message.ClickedView({ view: "workers" }) }), + ).model + expect([switched.page, switched.multiView, switched.list.search.view]).toEqual([ + "accounts", + "workers", + "workers", + ]) + }) + + test("a zone dimension change keeps the fixture breakdown and asks the warehouse nothing", () => { + const zone = update(opened().model, Message.ClickedLabPage({ page: "zone" })).model + const next = update( + zone, + Message.GotZoneMessage({ message: Zone.Message.ClickedDimension({ dimension: "country" }) }), + ) + expect(next.model.zone.dimension).toBe("country") + expect(AsyncData.isSuccess(next.model.zone.breakdown.data)).toBe(true) + expect((next.commands ?? []).map((command) => command.name)).toEqual([]) + }) + + test("the zone's Traffic and DNS pivot stays on Traffic, as legacy's no-op onChange", () => { + const zone = update(opened().model, Message.ClickedLabPage({ page: "zone" })).model + const next = update( + zone, + Message.GotZoneMessage({ message: Zone.Message.ClickedTab({ tab: "dns" }) }), + ) + expect(next.model.zone.search.tab).toBeUndefined() + }) +}) diff --git a/apps/web-foldkit/src/lab/cloudflare-lab/update.ts b/apps/web-foldkit/src/lab/cloudflare-lab/update.ts new file mode 100644 index 0000000000..836084e7ae --- /dev/null +++ b/apps/web-foldkit/src/lab/cloudflare-lab/update.ts @@ -0,0 +1,122 @@ +import { Effect, Option } from "effect" +import { Command, type Update } from "foldkit" +import { fromString as urlFromString } from "foldkit/url" +import type * as List from "../../page/infra-integrations/cloudflare/list/model" +import * as ListUpdate from "../../page/infra-integrations/cloudflare/list/update" +import * as WorkerUpdate from "../../page/infra-integrations/cloudflare/worker/update" +import * as ZoneUpdate from "../../page/infra-integrations/cloudflare/zone/update" +import type { Resources } from "../../platform/resources" +import { urlToAppRoute } from "../../route" +import { LAB_CONTEXT, pinWorker, pinZone, withoutReads } from "./fixture" +import { type Flags, type LabPage, Message, type Model } from "./model" + +export type Return = Update.Return + +const toList = (message: List.Message): Message => Message.GotListMessage({ message }) + +const isListPage = (page: LabPage): boolean => page !== "zone" && page !== "worker" + +const initList = (view: List.View) => ListUpdate.init({ search: { view } }, LAB_CONTEXT) +const initZone = () => ZoneUpdate.init({ zoneName: "maple.dev", search: {} }, LAB_CONTEXT) +const initWorker = () => WorkerUpdate.init({ scriptName: "maple-api", search: {} }, LAB_CONTEXT) + +/** A remounted page keeps the chart runtime and the resolved theme; its own state starts over. */ +const remount = (fresh: A, previous: A): A => ({ + ...fresh, + chartRuntime: previous.chartRuntime, + themeColors: previous.themeColors, +}) + +export const flags: Effect.Effect = Effect.sync(() => ({ + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, +})) + +export const init = (flags: Flags): Return => { + const list = initList("zones") + const zone = initZone() + const worker = initWorker() + return { + model: { + timeZone: flags.timeZone, + page: "zones", + multiView: "zones", + list: list.model, + zone: pinZone(zone.model), + worker: pinWorker(worker.model), + }, + commands: [ + ...Command.mapMessages(withoutReads(list.commands), toList), + ...Command.mapMessages(withoutReads(zone.commands), (message) => + Message.GotZoneMessage({ message }), + ), + ...Command.mapMessages(withoutReads(worker.commands), (message) => + Message.GotWorkerMessage({ message }), + ), + ], + } +} + +/** Legacy keeps one `ListPage` across the list pages and remounts the zone and Worker pages. */ +const showPage = (model: Model, page: LabPage): Model => { + if (page === model.page) return model + if (page === "zone") return { ...model, page, zone: pinZone(remount(initZone().model, model.zone)) } + if (page === "worker") + return { ...model, page, worker: pinWorker(remount(initWorker().model, model.worker)) } + const view = page === "accounts" ? model.multiView : page + const list = isListPage(model.page) + ? { ...model.list, search: { ...model.list.search, view } } + : remount(initList(view).model, model.list) + return { ...model, page, list } +} + +/** A list URL write, applied to the list's own `search` the way a route change would. */ +const followNavigation = (list: List.Model, url: string): List.Model => + Option.match(urlFromString(new URL(url, "http://localhost").href), { + onNone: () => list, + onSome: (parsed) => { + const route = urlToAppRoute(parsed) + return route._tag === "InfraCloudflare" ? { ...list, search: route.search } : list + }, + }) + +/** The list's view tabs drive the lab pivot (`onViewChange`), or the demo page's own view. */ +const listStep = (model: Model, message: List.Message): Return => { + const next = ListUpdate.update(model.list, message, LAB_CONTEXT) + const out = next.outMessage + const list = + out !== undefined && out._tag === "RequestedNavigation" + ? followNavigation(next.model, out.url) + : next.model + const view = list.search.view + const synced: Model = + model.page === "accounts" + ? { ...model, list, multiView: view ?? model.multiView } + : { ...model, list, page: view ?? model.page } + return { model: synced, commands: Command.mapMessages(withoutReads(next.commands), toList) } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + ClickedLabPage: ({ page }) => ({ model: showPage(model, page) }), + GotListMessage: ({ message: inner }) => listStep(model, inner), + // Legacy's "Zone view" pivot is fixed on Traffic (`onChange={() => undefined}`). + GotZoneMessage: ({ message: inner }) => { + if (inner._tag === "ClickedTab") return { model } + const next = ZoneUpdate.update(model.zone, inner, LAB_CONTEXT) + return { + model: { ...model, zone: pinZone(next.model) }, + commands: Command.mapMessages(withoutReads(next.commands), (child) => + Message.GotZoneMessage({ message: child }), + ), + } + }, + GotWorkerMessage: ({ message: inner }) => { + const next = WorkerUpdate.update(model.worker, inner, LAB_CONTEXT) + return { + model: { ...model, worker: pinWorker(next.model) }, + commands: Command.mapMessages(withoutReads(next.commands), (child) => + Message.GotWorkerMessage({ message: child }), + ), + } + }, + }) diff --git a/apps/web-foldkit/src/lab/cloudflare-lab/view.ts b/apps/web-foldkit/src/lab/cloudflare-lab/view.ts new file mode 100644 index 0000000000..7c76f417a3 --- /dev/null +++ b/apps/web-foldkit/src/lab/cloudflare-lab/view.ts @@ -0,0 +1,99 @@ +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { ResolveThemeColors } from "../../charts/theme-colors" +import type * as List from "../../page/infra-integrations/cloudflare/list/model" +import { listBody, THEME_TOKENS } from "../../page/infra-integrations/cloudflare/list/view" +import type * as Worker from "../../page/infra-integrations/cloudflare/worker/model" +import { workerPage } from "../../page/infra-integrations/cloudflare/worker/view" +import type * as Zone from "../../page/infra-integrations/cloudflare/zone/model" +import { zonePage } from "../../page/infra-integrations/cloudflare/zone/view" +import { dashboardPage } from "../../page/kit/dashboard-page" +import type * as Shell from "../../shell" +import { segmentedSelect } from "../../ui/segmented-select" +import { listFrame } from "./fixture" +import { LAB_PAGES, Message, type Model, PAGE_LABEL } from "./model" + +type H = HtmlBuilder + +/** `ListPage`: `DashboardPage gap="lg"` around `CloudflareListPage`, without the time controls. */ +const listView = Submodel.defineView>( + (model, inputs, h) => + h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: THEME_TOKENS }))], + [ + dashboardPage(h, { gap: "lg" }, [ + listBody(h, model, listFrame(inputs.multiAccount), { + theme: model.themeColors, + isChartReady: model.chartRuntime._tag === "Ready", + }), + ]), + ], + ), +) + +const zoneView = Submodel.defineView>( + (model, inputs, h) => zonePage(h, model, inputs.timeZone, { isTimed: false, hasFilters: false }), +) + +const workerView = Submodel.defineView>( + (model, inputs, h) => workerPage(h, model, inputs.timeZone, false), +) + +export const view = (model: Model, h: H): Html => { + switch (model.page) { + case "zone": + return h.submodel({ + slotId: "cloudflare-lab-zone", + model: model.zone, + view: zoneView, + viewInputs: { timeZone: model.timeZone }, + toParentMessage: (message) => Message.GotZoneMessage({ message }), + }) + case "worker": + return h.submodel({ + slotId: "cloudflare-lab-worker", + model: model.worker, + view: workerView, + viewInputs: { timeZone: model.timeZone }, + toParentMessage: (message) => Message.GotWorkerMessage({ message }), + }) + default: + return h.submodel({ + slotId: "cloudflare-lab-list", + model: model.list, + view: listView, + viewInputs: { multiAccount: model.page === "accounts" }, + toParentMessage: (message) => Message.GotListMessage({ message }), + }) + } +} + +/** The floating "Lab page" pivot, before the shell in `#app`. */ +export const toolbar = (model: Model, h: H): Html => + h.div( + [ + h.Class( + "fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg border bg-popover px-3 py-2 shadow-lg", + ), + ], + [ + h.span([h.Class("font-mono text-2xs text-muted-foreground")], ["Cloudflare lab"]), + segmentedSelect(h, { + size: "xs", + ariaLabel: "Lab page", + options: LAB_PAGES.map((value) => ({ value, label: PAGE_LABEL[value] })), + value: model.page, + onChange: (page) => Message.ClickedLabPage({ page }), + }), + ], + ) + +export const breadcrumbs = (model: Model): ReadonlyArray => [ + { label: "Infrastructure" }, + { label: "Cloudflare" }, + ...(model.page === "zone" + ? [{ label: "maple.dev" }] + : model.page === "worker" + ? [{ label: "maple-api" }] + : []), +] diff --git a/apps/web-foldkit/src/lab/data-probe/command.ts b/apps/web-foldkit/src/lab/data-probe/command.ts new file mode 100644 index 0000000000..b5a5a10fc6 --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/command.ts @@ -0,0 +1,53 @@ +import { Effect, Option, Schema } from "effect" +import { Command } from "foldkit" +import { getServicesFacets } from "@maple/web-data/warehouse/services" +import { currentWindow, TimeWindow } from "../../data/time-range" +import { toWarehouseFailure } from "../../data/warehouse-error" +import { MapleAuthApi, readSessionToken } from "../../platform/auth-api" +import { Message, RequestMode } from "./model" + +/** The lab boots without the app shell, so it resolves its own org from the stored session. */ +export const CheckProbeSession = Command.define("CheckProbeSession", { + messages: [Message.SucceededCheckProbeSession, Message.FailedCheckProbeSession], + execute: Effect.gen(function* () { + const maybeToken = yield* readSessionToken + if (Option.isNone(maybeToken)) return Message.FailedCheckProbeSession({ session: "SignedOut" }) + const auth = yield* MapleAuthApi + const tenant = yield* auth.session(maybeToken.value) + return Message.SucceededCheckProbeSession({ orgId: tenant.orgId }) + }).pipe( + Effect.catchTag("@maple/web-foldkit/SessionCheckError", (error) => + Effect.succeed( + Message.FailedCheckProbeSession({ + session: error.reason === "Rejected" ? "SignedOut" : "Unreachable", + }), + ), + ), + ), +}) + +/** `/services`' default window ("12h"), snapped on entry, re-resolved unsnapped on refresh. */ +export const ResolveWindow = Command.define("ResolveWindow", { + args: { mode: RequestMode }, + messages: [Message.CompletedResolveWindow], + execute: ({ mode }) => + Effect.map(currentWindow({ preset: "12h", snap: mode === "Load" }), (window) => + Message.CompletedResolveWindow({ window, mode }), + ), +}) + +/** + * Carries its request key back so `update` drops a superseded answer. Not interruptible: the query + * already rides its batch and the warehouse runs it anyway, so cancelling would save nothing. + */ +export const FetchServicesFacets = Command.define("FetchServicesFacets", { + args: { key: Schema.String, window: TimeWindow }, + messages: [Message.CompletedFetchServicesFacets], + execute: ({ key, window }) => + getServicesFacets({ data: window }).pipe( + Effect.map((response) => response.data), + Effect.mapError(toWarehouseFailure), + Effect.result, + Effect.map((result) => Message.CompletedFetchServicesFacets({ key, result })), + ), +}) diff --git a/apps/web-foldkit/src/lab/data-probe/data-probe.scene.test.ts b/apps/web-foldkit/src/lab/data-probe/data-probe.scene.test.ts new file mode 100644 index 0000000000..c4027c9a12 --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/data-probe.scene.test.ts @@ -0,0 +1,122 @@ +import { OrgId } from "@maple/domain" +import { Result, Schema } from "effect" +import { click, Command, expect, given, Mount, role, Subscription, scene, text } from "foldkit/scene" +import { expect as assert, describe, test } from "vitest" +import { requestKey } from "../../data/request-key" +import { Message as ToastMessage, PortalToasts } from "../../ui/toast" +import { CheckProbeSession, FetchServicesFacets, init, Message, ResolveWindow, update, view } from "./index" + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const windowOf = (endHour: string) => ({ + startTime: "2026-10-01 00:00:00", + endTime: `2026-10-01 ${endHour}:00:00`, +}) +const keyOf = (window: ReturnType) => + requestKey(orgId, "getServicesFacets", { data: window }) + +const facets = (services: number) => ({ + environments: [{ name: "production", count: 10 }], + namespaces: [], + commitShas: [], + services: Array.from({ length: services }, (_, index) => ({ name: `svc-${index}`, count: 1 })), +}) + +/** The page's two Notifications regions portal themselves out of `#app`. */ +const portalsMounted = () => [ + Mount.resolve(PortalToasts, ToastMessage.CompletedPortalToasts()), + Mount.resolve(PortalToasts, ToastMessage.CompletedPortalToasts()), +] + +const first = windowOf("12") +const second = windowOf("13") +const third = windowOf("14") +const refresh = role("button", { name: "Refresh" }) + +/** Entry: init asks for the session; the session asks for the window; the window for the facets. */ +const named = (commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined) => + (commands ?? []).map(({ name, args }) => ({ name, args })) + +const entered = () => { + const { model, commands } = init() + assert(named(commands)).toEqual(named([CheckProbeSession()])) + const session = update(model, Message.SucceededCheckProbeSession({ orgId })) + assert(named(session.commands)).toEqual(named([ResolveWindow({ mode: "Load" })])) + const windowed = update(session.model, Message.CompletedResolveWindow({ window: first, mode: "Load" })) + assert(named(windowed.commands)).toEqual( + named([FetchServicesFacets({ key: keyOf(first), window: first })]), + ) + return update( + windowed.model, + Message.CompletedFetchServicesFacets({ key: keyOf(first), result: Result.succeed(facets(6)) }), + ).model +} + +describe("data probe", () => { + test("entry checks the session, resolves the window, then shows the facets fetched under its key", () => { + scene( + { update, view }, + given(entered()), + ...portalsMounted(), + expect(text("Services: 6")).toExist(), + expect(text("Environments: 1")).toExist(), + ) + }) + + test("an alert rule count read for another org is dropped", () => { + const otherOrg = Schema.decodeSync(OrgId)("org_other") + scene( + { update, view }, + given(entered()), + ...portalsMounted(), + Subscription.emit(Message.UpdatedAlertRules({ orgId: otherOrg, count: 9 })), + expect(text("Alert rules: loading")).toExist(), + Subscription.emit(Message.UpdatedAlertRules({ orgId, count: 4 })), + expect(text("Alert rules: 4")).toExist(), + ) + }) + + test("refresh keeps the counts on screen until the new window's answer lands", () => { + scene( + { update, view }, + given(entered()), + ...portalsMounted(), + click(refresh), + Command.resolve( + ResolveWindow({ mode: "Refresh" }), + Message.CompletedResolveWindow({ window: second, mode: "Refresh" }), + ), + expect(text("Services: 6 (refreshing)")).toExist(), + Command.resolve( + FetchServicesFacets({ key: keyOf(second), window: second }), + Message.CompletedFetchServicesFacets({ + key: keyOf(second), + result: Result.succeed(facets(7)), + }), + ), + expect(text("Services: 7")).toExist(), + ) + }) + + // Scene refuses an interaction while a Command is unresolved, so the race is driven through update. + test("an answer still in flight when a newer window was asked for is dropped", () => { + const refreshed = update( + entered(), + Message.CompletedResolveWindow({ window: second, mode: "Refresh" }), + ).model + const superseded = update( + refreshed, + Message.CompletedResolveWindow({ window: third, mode: "Refresh" }), + ).model + const late = update( + superseded, + Message.CompletedFetchServicesFacets({ key: keyOf(second), result: Result.succeed(facets(1)) }), + ) + assert(late.model).toEqual(superseded) + scene( + { update, view }, + given(late.model), + ...portalsMounted(), + expect(text("Services: 6 (refreshing)")).toExist(), + ) + }) +}) diff --git a/apps/web-foldkit/src/lab/data-probe/index.ts b/apps/web-foldkit/src/lab/data-probe/index.ts new file mode 100644 index 0000000000..07003ffdbd --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/index.ts @@ -0,0 +1,5 @@ +export { CheckProbeSession, FetchServicesFacets, ResolveWindow } from "./command" +export { Message, Model } from "./model" +export { ruleCounts, subscriptions } from "./subscription" +export { init, update } from "./update" +export { view } from "./view" diff --git a/apps/web-foldkit/src/lab/data-probe/model.ts b/apps/web-foldkit/src/lab/data-probe/model.ts new file mode 100644 index 0000000000..f72f735235 --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/model.ts @@ -0,0 +1,55 @@ +import { OrgId } from "@maple/domain" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Toast from "../../ui/toast" +import { KeyedSchema } from "../../data/keyed" + +import { TimeWindow } from "../../data/time-range" +import { WarehouseFailure } from "../../data/warehouse-error" + +// MODEL + +export const Session = Schema.Literals(["Checking", "SignedIn", "SignedOut", "Unreachable"]) +export type Session = typeof Session.Type + +/** How the window was asked for: `Load` on entry, `Refresh` from the button (no cache snap). */ +export const RequestMode = Schema.Literals(["Load", "Refresh"]) + +/** `getServicesFacets`' result (`ServicesFacetsResponse["data"]` in `@maple/web-data/warehouse/services`). */ +const FacetCount = Schema.Struct({ name: Schema.String, count: Schema.Number }) +export const ServicesFacets = Schema.Struct({ + environments: Schema.Array(FacetCount), + namespaces: Schema.Array(FacetCount), + commitShas: Schema.Array(FacetCount), + services: Schema.Array(FacetCount), +}) +export type ServicesFacets = typeof ServicesFacets.Type + +/** + * S6 data probe: one warehouse query through the batcher (`/services`' facets) and one Electric + * collection through a liveQuery Subscription (alert rules), rendered as counts. + */ +export const Model = Schema.Struct({ + session: Session, + orgId: Schema.NullOr(OrgId), + facets: KeyedSchema(ServicesFacets, WarehouseFailure), + alertRuleCount: Schema.NullOr(Schema.Number), + toasts: Toast.Model, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + SucceededCheckProbeSession: { orgId: OrgId }, + FailedCheckProbeSession: { session: Session }, + CompletedResolveWindow: { window: TimeWindow, mode: RequestMode }, + CompletedFetchServicesFacets: { + key: Schema.String, + result: Schema.Result(ServicesFacets, WarehouseFailure), + }, + UpdatedAlertRules: { orgId: OrgId, count: Schema.Number }, + ClickedRefresh: {}, + GotToastMessage: { message: Toast.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/data-probe/subscription.test.ts b/apps/web-foldkit/src/lab/data-probe/subscription.test.ts new file mode 100644 index 0000000000..af6fab2afd --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/subscription.test.ts @@ -0,0 +1,107 @@ +import { OrgId } from "@maple/domain" +import { createCollection, localOnlyCollectionOptions } from "@tanstack/db" +import { Effect, Fiber, Queue, Schema, Stream } from "effect" +import { describe, expect, test } from "vitest" +import type { AlertRuleRow } from "@maple/web-data/collections/alerts" +import { init, Message, ruleCounts, subscriptions, update } from "./index" + +const orgA = Schema.decodeSync(OrgId)("org_a") +const orgB = Schema.decodeSync(OrgId)("org_b") + +const rule = (id: string, orgId: string): AlertRuleRow => ({ + id, + org_id: orgId, + name: id, + notes: null, + notification_template_json: null, + enabled: true, + severity: "warning", + service_names_json: [], + exclude_service_names_json: [], + environments_json: [], + tags_json: [], + signal_type: "error_rate", + comparator: "gt", + threshold: 1, + threshold_upper: null, + window_minutes: 5, + minimum_sample_count: 1, + consecutive_breaches_required: 1, + consecutive_healthy_required: 1, + renotify_interval_minutes: 60, + apdex_threshold_ms: null, + query_builder_draft_json: null, + raw_query_sql: null, + reducer: "identity", + group_by: null, + destination_ids_json: [], + query_spec_json: null, + sample_count_strategy: null, + no_data_behavior: "skip", + last_scheduled_at: null, + created_at: "2026-10-01T00:00:00.000Z", + updated_at: "2026-10-01T00:00:00.000Z", + created_by: "user", + updated_by: "user", +}) + +/** A local stand-in for one org's synced `alert_rules` collection. */ +const rulesOf = (orgId: string, count: number) => + createCollection( + // Keyed `string | number` like the Electric collections. + localOnlyCollectionOptions({ + getKey: (row) => row.id, + initialData: Array.from({ length: count }, (_, index) => rule(`${orgId}-${index}`, orgId)), + }), + ) + +/** Runs a stream into a queue on its own fiber, as the Subscription runtime does. */ +const subscribe = (stream: Stream.Stream) => + Effect.gen(function* () { + const received = yield* Queue.unbounded() + const fiber = yield* Effect.forkChild( + Stream.runForEach(stream, (message) => Queue.offer(received, message)), + ) + return { received, fiber } + }) + +const next = (received: Queue.Queue) => Queue.take(received).pipe(Effect.timeout("1 second")) + +describe("alert rules liveQuery", () => { + test("emits the org's rows when ready and again after every change", () => + Effect.runPromise( + Effect.gen(function* () { + const rules = rulesOf("org_a", 2) + const { received } = yield* subscribe(ruleCounts(orgA, rules)) + expect(yield* next(received)).toEqual(Message.UpdatedAlertRules({ orgId: orgA, count: 2 })) + rules.insert(rule("org_a-new", "org_a")) + expect(yield* next(received)).toEqual(Message.UpdatedAlertRules({ orgId: orgA, count: 3 })) + }), + )) + + test("an org switch re-keys the Subscription: the old live query is released, the new one reads the new org", () => + Effect.runPromise( + Effect.gen(function* () { + const before = { ...init().model, session: "SignedIn" as const, orgId: orgA } + const after = { ...before, orgId: orgB } + const deps = subscriptions.alertRules.modelToDependencies + expect(deps(before)).not.toEqual(deps(after)) + + const rulesA = rulesOf("org_a", 2) + const first = yield* subscribe(ruleCounts(orgA, rulesA)) + yield* next(first.received) + expect(rulesA.subscriberCount).toBeGreaterThan(0) + yield* Fiber.interrupt(first.fiber) + yield* Effect.sleep("10 millis") + expect(rulesA.subscriberCount).toBe(0) + + const second = yield* subscribe(ruleCounts(orgB, rulesOf("org_b", 3))) + expect(yield* next(second.received)).toEqual( + Message.UpdatedAlertRules({ orgId: orgB, count: 3 }), + ) + // An emission the old Subscription queued before it stopped is dropped by update. + const late = update(after, Message.UpdatedAlertRules({ orgId: orgA, count: 2 })) + expect(late.model.alertRuleCount).toBeNull() + }), + )) +}) diff --git a/apps/web-foldkit/src/lab/data-probe/subscription.ts b/apps/web-foldkit/src/lab/data-probe/subscription.ts new file mode 100644 index 0000000000..f91445e3e2 --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/subscription.ts @@ -0,0 +1,39 @@ +import { OrgId } from "@maple/domain" +import type { Collection } from "@tanstack/db" +import { Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { AlertRuleRow } from "@maple/web-data/collections/alerts" +import { OrgCollections } from "../../data/collections/org-collections" +import { liveQueryStream } from "../../data/live-query" +import type { Resources } from "../../platform/resources" +import { Message, type Model } from "./model" + +/** Every emission names the org it was read for, so `update` can drop the previous org's rows. */ +export const ruleCounts = ( + orgId: OrgId, + rules: Collection, +): Stream.Stream => + liveQueryStream( + (q) => q.from({ rule: rules }), + (rows) => Message.UpdatedAlertRules({ orgId, count: rows.length }), + ) + +const alertRuleCounts = (orgId: OrgId): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const collections = yield* OrgCollections + const set = yield* collections.forOrg(orgId) + return ruleCounts(orgId, set.alertRules) + }), + ) + +export const subscriptions = Subscription.make()((entry) => ({ + /** `useLiveQuery((q) => q.from({ rule: alertRules }), [alertRules])`, re-keyed by org. */ + alertRules: entry( + { orgId: Schema.NullOr(OrgId) }, + { + modelToDependencies: (model) => ({ orgId: model.orgId }), + dependenciesToStream: ({ orgId }) => (orgId === null ? Stream.empty : alertRuleCounts(orgId)), + }, + ), +})) diff --git a/apps/web-foldkit/src/lab/data-probe/update.ts b/apps/web-foldkit/src/lab/data-probe/update.ts new file mode 100644 index 0000000000..bb9c201e1f --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/update.ts @@ -0,0 +1,61 @@ +import type { OrgId } from "@maple/domain" +import { Option } from "effect" +import type { Update } from "foldkit" +import * as Keyed from "../../data/keyed" +import { requestKey } from "../../data/request-key" +import type { TimeWindow } from "../../data/time-range" +import type { Resources } from "../../platform/resources" +import { CheckProbeSession, FetchServicesFacets, ResolveWindow } from "./command" +import { foldLabToasts, initLabToasts } from "../toasts" +import { Message, type Model } from "./model" + +type UpdateReturn = Update.Return + +export const init = (): UpdateReturn => ({ + model: { + session: "Checking", + orgId: null, + facets: Keyed.idle(), + alertRuleCount: null, + toasts: initLabToasts(), + }, + commands: [CheckProbeSession()], +}) + +/** What `getServicesFacetsResultAtom({ data: window })` becomes: a keyed request on the slot. */ +const requestFacets = ( + model: Model, + orgId: OrgId, + window: TimeWindow, + mode: Keyed.RequestMode, +): UpdateReturn => { + const key = requestKey(orgId, "getServicesFacets", { data: window }) + return Option.match(Keyed.request(model.facets, key, mode), { + onNone: () => ({ model }), + onSome: (facets) => ({ + model: { ...model, facets }, + commands: [FetchServicesFacets({ key, window })], + }), + }) +} + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +export const update = (model: Model, message: Message): UpdateReturn => + Message.match(message, { + SucceededCheckProbeSession: ({ orgId }) => ({ + model: { ...model, session: "SignedIn", orgId }, + commands: [ResolveWindow({ mode: "Load" })], + }), + FailedCheckProbeSession: ({ session }) => ({ model: { ...model, session } }), + CompletedResolveWindow: ({ window, mode }) => + model.orgId === null ? { model } : requestFacets(model, model.orgId, window, mode), + CompletedFetchServicesFacets: ({ key, result }) => ({ + model: { ...model, facets: Keyed.accept(model.facets, key, result) }, + }), + // A liveQuery emission from the previous org's Subscription can still be queued: drop it. + UpdatedAlertRules: ({ orgId, count }) => + orgId === model.orgId ? { model: { ...model, alertRuleCount: count } } : { model }, + ClickedRefresh: () => ({ model, commands: [ResolveWindow({ mode: "Refresh" })] }), + GotToastMessage: ({ message }) => foldToasts(model, message), + }) diff --git a/apps/web-foldkit/src/lab/data-probe/view.ts b/apps/web-foldkit/src/lab/data-probe/view.ts new file mode 100644 index 0000000000..e2a8e92b63 --- /dev/null +++ b/apps/web-foldkit/src/lab/data-probe/view.ts @@ -0,0 +1,49 @@ +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" + +import type { WarehouseFailure } from "../../data/warehouse-error" +import { labRoot } from "../toasts" +import { Message, type Model, type ServicesFacets } from "./model" + +const facetCount = ( + facets: AsyncData.AsyncData, + group: keyof ServicesFacets, +): string => + AsyncData.match(facets, { + onIdle: () => "idle", + onLoading: () => "loading", + onRefreshing: (data) => `${data[group].length} (refreshing)`, + onFailure: (failure) => `failed (${failure.code ?? failure.reason})`, + onStale: ({ data, error }) => `${data[group].length} (stale: ${error.code ?? error.reason})`, + onSuccess: (data) => String(data[group].length), + }) + +const line = (h: HtmlBuilder, label: string, value: string): Html => h.li([], [`${label}: ${value}`]) + +export const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +const page = (model: Model, h: HtmlBuilder): Html => + h.main( + [h.Class("flex flex-col gap-4 p-6 text-sm")], + [ + h.h1([h.Class("text-lg font-medium")], ["Data probe"]), + h.ul( + [h.AriaLabel("Counts"), h.AriaLive("polite")], + [ + line(h, "Session", model.session), + line(h, "Services", facetCount(model.facets.data, "services")), + line(h, "Environments", facetCount(model.facets.data, "environments")), + line( + h, + "Alert rules", + model.alertRuleCount === null ? "loading" : String(model.alertRuleCount), + ), + ], + ), + h.button( + [h.Type("button"), h.Class("w-fit underline"), h.OnClick(Message.ClickedRefresh())], + ["Refresh"], + ), + ], + ) diff --git a/apps/web-foldkit/src/lab/define.ts b/apps/web-foldkit/src/lab/define.ts new file mode 100644 index 0000000000..7b044536c5 --- /dev/null +++ b/apps/web-foldkit/src/lab/define.ts @@ -0,0 +1,47 @@ +import { Runtime } from "foldkit" +import { type Resources, ResourcesLive } from "../platform/resources" + +/** + * A lab entry is a self-contained Foldkit program booted on `/lab/` instead of the app, + * mirroring `apps/web/src/lab`. Closing over the program keeps it typed; the registry holds `start`. + */ +export interface LabEntry { + readonly title: string + readonly start: (container: HTMLElement) => void +} + +export const defineLab = ( + title: string, + program: Omit, "container" | "resources">, +): LabEntry => ({ + title, + start: (container) => { + Runtime.run( + Runtime.makeElement({ + ...program, + container, + resources: ResourcesLive, + }), + ) + }, +}) + +/** `defineLab` for a program that reads the browser (timezone, locale) through Flags before init. */ +export const defineLabWithFlags = ( + title: string, + program: Omit< + Runtime.ElementConfigWithFlags, + "container" | "resources" + >, +): LabEntry => ({ + title, + start: (container) => { + Runtime.run( + Runtime.makeElement({ + ...program, + container, + resources: ResourcesLive, + }), + ) + }, +}) diff --git a/apps/web-foldkit/src/lab/entries/agent-session.ts b/apps/web-foldkit/src/lab/entries/agent-session.ts new file mode 100644 index 0000000000..9951d2407c --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/agent-session.ts @@ -0,0 +1,170 @@ +import { + buildAgentSessionFixture, + buildCaptureOffFixture, + buildCleanFixture, +} from "@maple/agent-sessions/testing" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as SessionMessage } from "../../page/agent-sessions/session/message" +import { Model as SessionModel } from "../../page/agent-sessions/session/model" +import type { Resources } from "../../platform/resources" +import { ViewerFlags, viewerFlags } from "../../ui/logs/viewer" +import * as Toast from "../../ui/toast" +import { toggle } from "../../ui/toggle" +import { defineLabWithFlags } from "../define" +import { + enterSession, + type SessionStep, + sessionTitle, + sessionViews, + stepSession, + toWire, + withSpans, +} from "../session-lab" +import { foldLabToasts, initLabToasts, labRoot } from "../toasts" + +// `/lab/agent-session` (apps/web `lab/agent-session-lab.tsx`): the session page's header and four +// views over the package's fixture, with the Clean and Capture off toggles. No warehouse reads. + +const SESSION_ID = "lab-session-0f3c9a1e2b7d" + +const Flags = Schema.Struct({ ...ViewerFlags.fields, view: Schema.NullOr(Schema.String) }) +type Flags = typeof Flags.Type + +const Model = Schema.Struct({ + session: SessionModel, + isClean: Schema.Boolean, + isCaptureOff: Schema.Boolean, + timeZone: Schema.String, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + GotSessionMessage: { message: SessionMessage }, + ToggledClean: {}, + ToggledCaptureOff: {}, + GotToastMessage: { message: Toast.Message }, +}) +type Message = typeof Message.Type + +type Return = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +const toLab = (message: SessionMessage) => Message.GotSessionMessage({ message }) + +const spansOf = (isClean: boolean, isCaptureOff: boolean) => + toWire( + isClean ? buildCleanFixture() : isCaptureOff ? buildCaptureOffFixture() : buildAgentSessionFixture(), + SESSION_ID, + ) + +const landed = (model: Model, step: SessionStep): Return => ({ + model: { ...model, session: step.session }, + commands: Command.mapMessages(step.commands, toLab), +}) + +/** A toggle swaps the session's spans; the views keep their state, as legacy's do. */ +const swapped = (model: Model): Return => + landed(model, withSpans(model.session, spansOf(model.isClean, model.isCaptureOff), model.timeZone)) + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const init = (flags: Flags): Return => { + const entered = enterSession(SESSION_ID, flags.view ?? undefined, spansOf(false, false), flags.timeZone) + return landed( + { + session: entered.session, + isClean: false, + isCaptureOff: false, + timeZone: flags.timeZone, + toasts: initLabToasts(), + }, + entered, + ) +} + +const update = (model: Model, message: Message): Return => + Message.match(message, { + GotSessionMessage: ({ message: inner }) => + landed(model, stepSession(model.session, inner, model.timeZone)), + ToggledClean: () => swapped({ ...model, isClean: !model.isClean }), + ToggledCaptureOff: () => swapped({ ...model, isCaptureOff: !model.isCaptureOff }), + GotToastMessage: ({ message: inner }) => foldToasts(model, inner), + }) + +const labToggle = (h: HtmlBuilder, label: string, isPressed: boolean, onClick: Message): Html => + toggle(h, { variant: "outline", size: "sm", isPressed, onClick, className: "text-xs" }, [label]) + +const page = (model: Model, h: HtmlBuilder): Html => { + const selected = model.session.search.span + const inputs = { timeZone: model.timeZone } + return h.div( + [h.Class("flex h-screen flex-col bg-background")], + [ + h.div( + [h.Class("flex shrink-0 items-start gap-4 border-border border-b px-4 py-3")], + [ + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.submodel({ + slotId: "lab-session-header", + model: model.session, + view: sessionTitle, + viewInputs: inputs, + toParentMessage: toLab, + }), + ...(selected === undefined + ? [] + : [ + h.p( + [h.Class("mt-1 text-muted-foreground text-xs")], + ["selected ", selected], + ), + ]), + ], + ), + h.div( + [h.Class("ml-auto flex shrink-0 items-center gap-2")], + [ + labToggle(h, "Clean", model.isClean, Message.ToggledClean()), + labToggle(h, "Capture off", model.isCaptureOff, Message.ToggledCaptureOff()), + ], + ), + ], + ), + h.div( + [ + h.DataAttribute("slot", "page-scroll-area"), + h.Class("flex min-h-0 flex-1 flex-col overflow-auto px-4"), + ], + [ + h.submodel({ + slotId: "lab-session-views", + model: model.session, + view: sessionViews, + viewInputs: inputs, + toParentMessage: toLab, + }), + ], + ), + ], + ) +} + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +export const lab = defineLabWithFlags("Agent session", { + Model, + Flags, + flags: Effect.map(viewerFlags, (viewer) => ({ + ...viewer, + view: new URLSearchParams(window.location.search).get("view"), + })), + init, + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/agent-sessions.ts b/apps/web-foldkit/src/lab/entries/agent-sessions.ts new file mode 100644 index 0000000000..f6482bfbca --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/agent-sessions.ts @@ -0,0 +1,270 @@ +import { OrgId } from "@maple/domain" +import { Clock, Effect, Option, Schema } from "effect" +import { Command, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { + pageContentStyles, + pageScrollAreaStyles, + pageStickyAreaStyles, +} from "@maple/ui/styles/ui/page-layout" +import { cn } from "@maple/ui/lib/utils" +import type { PageContext } from "../../page/contract" +import { detectLookup, missingDetection } from "../../page/agent-sessions/kit/detected-models" +import { agentSessionsTabs } from "../../page/agent-sessions/kit/tabs" +import { CompleteReloadSpin, DetectModels, VirtualizeSessions } from "../../page/agent-sessions/list/command" +import { type DisplayRow, sortOf, sortPatch } from "../../page/agent-sessions/list/derive" +import { Message as ListMessage } from "../../page/agent-sessions/list/message" +import { Model as ListModel } from "../../page/agent-sessions/list/model" +import { init as listInit } from "../../page/agent-sessions/list/update" +import { sessionsTable } from "../../page/agent-sessions/list/view-table" +import { toolbar } from "../../page/agent-sessions/list/view-toolbar" +import type { Resources } from "../../platform/resources" +import * as Toast from "../../ui/toast" +import { ViewerFlags, viewerFlags } from "../../ui/logs/viewer" +import { buildToolAnalyticsFixture, buildToolCells } from "../agent-tools-fixture" +import { defineLabWithFlags } from "../define" +import { foldLabToasts, initLabToasts, labRoot } from "../toasts" +import { buildRows } from "../agent-sessions-rows" + +// `/lab/agent-sessions` (apps/web `lab/agent-sessions-list-lab.tsx`): the list's tab strip, toolbar +// and table over rows chosen to break its lanes. No server: a header click only marks the order. + +const WIDTHS = [ + { label: "Full", value: null }, + { label: "1300px", value: 1300 }, + { label: "1000px", value: 1000 }, + { label: "700px", value: 700 }, + { label: "380px", value: 380 }, +] as const + +const Model = Schema.Struct({ + list: ListModel, + width: Schema.NullOr(Schema.Number), + timeZone: Schema.String, + tabCounts: Schema.Struct({ sessions: Schema.Number, tools: Schema.Number }), + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + GotListMessage: { message: ListMessage }, + ClickedWidth: { width: Schema.NullOr(Schema.Number) }, + GotToastMessage: { message: Toast.Message }, +}) +type Message = typeof Message.Type + +type Return = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +const contextOf = (timeZone: string): PageContext => ({ + orgId: Schema.decodeSync(OrgId)("org_lab"), + pinnedNamespace: null, + timeZone, +}) + +const toLab = (message: ListMessage) => Message.GotListMessage({ message }) + +const rowsOf = (model: Model): ReadonlyArray => + buildRows(model.list.nowMs).map((row) => ({ ...row, hasDetails: false })) + +/** The rows' model marks, as the page asks for them. */ +const detect = (model: Model): Return => + Option.match( + missingDetection( + model.list.detections, + rowsOf(model).flatMap((row) => row.models), + ), + { + onNone: () => ({ model }), + onSome: (key) => ({ + model: { + ...model, + list: { ...model.list, detections: { ...model.list.detections, [key]: null } }, + }, + commands: Command.mapMessages([DetectModels({ key })], toLab), + }), + }, + ) + +/** The list's controls over local state: the toolbar's search and switch, the sort, Reload's spin. */ +const stepList = (model: Model, message: ListMessage): Return => { + const { list } = model + const set = (next: ListModel): Return => ({ model: { ...model, list: next } }) + switch (message._tag) { + case "ChangedSearchInput": + return set({ ...list, searchInput: message.value }) + case "ToggledErrorsOnly": + return set({ + ...list, + search: { ...list.search, hasErrors: list.search.hasErrors === true ? undefined : true }, + }) + case "ClickedSort": + return set({ ...list, search: { ...list.search, ...sortPatch(list.search, message.key) } }) + case "ClickedReload": { + const version = list.refreshVersion + 1 + return { + model: { ...model, list: { ...list, refreshVersion: version, isReloading: true } }, + commands: Command.mapMessages([CompleteReloadSpin({ version })], toLab), + } + } + case "CompletedReloadSpin": + return message.version === list.refreshVersion ? set({ ...list, isReloading: false }) : { model } + case "CompletedDetectModels": + return set({ ...list, detections: { ...list.detections, [message.key]: message.models } }) + case "ChangedListWindow": + return set({ ...list, listWindow: message.window }) + default: + return { model } + } +} + +const Flags = Schema.Struct({ ...ViewerFlags.fields, nowMs: Schema.Number }) +type Flags = typeof Flags.Type + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const init = (flags: Flags): Return => { + const list = listInit({ search: {} }, contextOf(flags.timeZone)).model + const all = buildToolAnalyticsFixture({}, flags.nowMs, buildToolCells(flags.nowMs)) + return detect({ + list: { ...list, nowMs: flags.nowMs }, + width: null, + timeZone: flags.timeZone, + tabCounts: { sessions: all.allSessions, tools: all.tools.length }, + toasts: initLabToasts(), + }) +} + +const update = (model: Model, message: Message): Return => + Message.match(message, { + GotListMessage: ({ message: inner }) => stepList(model, inner), + ClickedWidth: ({ width }) => ({ model: { ...model, width } }), + GotToastMessage: ({ message: inner }) => foldToasts(model, inner), + }) + +type ListInputs = Readonly<{ + timeZone: string + tabCounts: Model["tabCounts"] + rows: ReadonlyArray +}> + +/** The route's own layout: the tabs and toolbar pinned, the table in the scroll area. */ +const listPage = Submodel.defineView((list, inputs, h) => + h.div( + [h.Class("flex min-h-0 flex-1 flex-col"), h.DataAttribute("slot", "page-layout")], + [ + h.main( + [h.Id("main-content"), h.DataAttribute("slot", "page-content"), h.Class(pageContentStyles)], + [ + h.div( + [ + h.DataAttribute("slot", "page-sticky-area"), + h.Class(cn(pageStickyAreaStyles, "pb-3")), + ], + [ + h.div( + [h.Class("space-y-3")], + [ + agentSessionsTabs(h, { + active: "sessions", + counts: inputs.tabCounts, + className: "border-b border-border", + isOnRoute: false, + }), + toolbar(h, list, { sessionCount: inputs.rows.length, isWaiting: false }), + ], + ), + ], + ), + h.div( + [ + h.DataAttribute("slot", "page-scroll-area"), + h.Class(cn(pageScrollAreaStyles, "pt-0")), + ], + [ + sessionsTable(h, { + rows: inputs.rows, + sort: sortOf(list.search), + context: { + nowMs: list.nowMs, + timeZone: inputs.timeZone, + detect: detectLookup(list.detections), + }, + hasMore: false, + isLoadingMore: false, + isCapped: false, + virtual: { window: list.listWindow, mount: h.OnMount(VirtualizeSessions()) }, + }), + ], + ), + ], + ), + ], + ), +) + +const labButton = (h: HtmlBuilder, label: string, isActive: boolean, onClick: Message): Html => + h.button( + [ + h.Class( + isActive + ? "rounded border border-border bg-accent px-2 py-1 text-xs" + : "rounded border border-border px-2 py-1 text-xs text-muted-foreground", + ), + h.Type("button"), + h.OnClick(onClick), + ], + [label], + ) + +const page = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("flex h-svh flex-col gap-4 p-6")], + [ + h.div( + [h.Class("flex items-center gap-2")], + WIDTHS.map((option) => + labButton( + h, + option.label, + model.width === option.value, + Message.ClickedWidth({ width: option.value }), + ), + ), + ), + h.div( + [ + h.Class("flex min-h-0 flex-1 flex-col"), + ...(model.width === null ? [] : [h.Style({ width: `${model.width}px` })]), + ], + [ + h.submodel({ + slotId: "lab-agent-sessions-list", + model: model.list, + view: listPage, + viewInputs: { + timeZone: model.timeZone, + tabCounts: model.tabCounts, + rows: rowsOf(model), + }, + toParentMessage: toLab, + }), + ], + ), + ], + ) + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +export const lab = defineLabWithFlags("Agent sessions list", { + Model, + Flags, + flags: Effect.map(Effect.all([viewerFlags, Clock.currentTimeMillis]), ([viewer, nowMs]) => ({ + ...viewer, + nowMs, + })), + init, + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/agent-tools.ts b/apps/web-foldkit/src/lab/entries/agent-tools.ts new file mode 100644 index 0000000000..0717b0491a --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/agent-tools.ts @@ -0,0 +1,369 @@ +import { OrgId } from "@maple/domain" +import { buildToolAnalyticsFixture, buildToolCells, toolFixtureFacets } from "../agent-tools-fixture" +import { Option, Result, Schema } from "effect" +import { AsyncData, Command, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { fromString } from "foldkit/url" +import { ResolveThemeColors } from "../../charts/theme-colors" +import type { PageContext, PageOutMessage } from "../../page/contract" +import { Selection } from "../../page/agent-sessions/tools-kit/reads" +import { TOOL_CHART_TOKENS } from "../../page/agent-sessions/tools-kit/series-chart" +import { Message as ToolsMessage } from "../../page/agent-sessions/tools/message" +import { Model as ToolsModel } from "../../page/agent-sessions/tools/model" +import { + init as toolsInit, + routeChanged, + update as toolsUpdate, +} from "../../page/agent-sessions/tools/update" +import { toolsBody } from "../../page/agent-sessions/tools/view" +import { Message as ToolMessage } from "../../page/agent-sessions/tool/message" +import { Model as ToolModel } from "../../page/agent-sessions/tool/model" +import { init as toolInit, update as toolUpdate } from "../../page/agent-sessions/tool/update" +import { toolDetailBody } from "../../page/agent-sessions/tool/view" +import { GRID_TOKENS } from "../../page/agent-sessions/tool/view-charts" +import { contextOf as detailContextOf, settle as settleDetail } from "../agent-tools-detail" +import { DETAIL_TOOLS } from "../agent-tools-detail-fixture" +import type { Resources } from "../../platform/resources" +import { urlToAppRoute } from "../../route" +import * as Toast from "../../ui/toast" +import { ViewerFlags, viewerFlags } from "../../ui/logs/viewer" +import { defineLabWithFlags } from "../define" +import { foldLabToasts, initLabToasts, labRoot } from "../toasts" + +// `/lab/agent-tools` (apps/web `lab/agent-tools-lab.tsx`): the tools page over apps/web's synthetic +// week. The page's warehouse reads are answered from the fixture; its URL is the lab's state. The +// "detail" view is the tool detail page and its error modal over the fixture's failure groups. + +const WIDTHS = [ + { label: "Full", value: null }, + { label: "1400px", value: 1400 }, + { label: "1100px", value: 1100 }, + { label: "820px", value: 820 }, +] as const +const VIEWS = ["overview", "detail"] as const + +const Model = Schema.Struct({ + page: ToolsModel, + detail: ToolModel, + detailTool: Schema.Literals(DETAIL_TOOLS), + /** "errors loading": the Errors table as it draws before its read answers. */ + isErrorsLoading: Schema.Boolean, + view: Schema.Literals(VIEWS), + width: Schema.NullOr(Schema.Number), + timeZone: Schema.String, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + GotPageMessage: { message: ToolsMessage }, + GotDetailMessage: { message: ToolMessage }, + ClickedDetailTool: { tool: Schema.Literals(DETAIL_TOOLS) }, + ToggledErrorsLoading: {}, + ClickedView: { view: Schema.Literals(VIEWS) }, + ClickedWidth: { width: Schema.NullOr(Schema.Number) }, + GotToastMessage: { message: Toast.Message }, +}) +type Message = typeof Message.Type + +type PageCommand = Command.Command +type Return = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +const contextOf = (timeZone: string): PageContext => ({ + orgId: Schema.decodeSync(OrgId)("org_lab"), + pinnedNamespace: null, + timeZone, +}) + +const ReadArgs = Schema.Struct({ + key: Schema.String, + selection: Selection, + isScope: Schema.optionalKey(Schema.Boolean), + isTab: Schema.optionalKey(Schema.Boolean), +}) +const decodeRead = Schema.decodeUnknownOption(ReadArgs) +const FacetArgs = Schema.Struct({ key: Schema.String }) +const decodeFacetRead = Schema.decodeUnknownOption(FacetArgs) + +/** The fixture's reading of a selection: its search keys, as the lab's local state carries them. */ +const fixtureOf = (selection: Selection, nowMs: number) => + buildToolAnalyticsFixture( + { + q: selection.search, + service: selection.service, + env: selection.env, + failing: selection.failingOnly, + tool: selection.tool, + model: selection.model, + }, + nowMs, + buildToolCells(nowMs), + ) + +/** The fixture's answer to one of the page's reads; `None` for a Command that is not a read. */ +const answer = (command: PageCommand, nowMs: number): Option.Option => { + if (command.name === "FetchAgentToolsFacets") { + const facets = toolFixtureFacets(buildToolCells(nowMs)) + return Option.map(decodeFacetRead(command.args), ({ key }) => + ToolsMessage.CompletedFetchFacets({ + key, + result: Result.succeed({ vendors: [], agents: [], tools: [], ...facets }), + }), + ) + } + return Option.flatMap(decodeRead(command.args), ({ key, selection, isScope, isTab }) => { + const data = fixtureOf(selection, nowMs) + switch (command.name) { + case "FetchAgentToolsSeries": { + const series = + isScope === true + ? { data: data.scopeSeries, seriesKind: "none" as const } + : { data: data.series, seriesKind: data.seriesKind } + const result = Result.succeed(series) + return Option.some( + isScope === true + ? ToolsMessage.CompletedFetchScopeSeries({ key, result }) + : ToolsMessage.CompletedFetchSeries({ key, result }), + ) + } + case "FetchAgentToolsTotals": { + const result = Result.succeed({ + current: data.totals, + previous: data.previousTotals, + allSessions: data.allSessions, + firstSeen: 0, + lastSeen: 0, + description: undefined, + }) + return Option.some( + isTab === true + ? ToolsMessage.CompletedFetchTabTotals({ key, result }) + : ToolsMessage.CompletedFetchTotals({ key, result }), + ) + } + case "FetchAgentToolsBreakdowns": { + const result = Result.succeed({ tools: data.tools }) + return Option.some( + isTab === true + ? ToolsMessage.CompletedFetchTabBreakdowns({ key, result }) + : ToolsMessage.CompletedFetchBreakdowns({ key, result }), + ) + } + default: + return Option.none() + } + }) +} + +/** The page's navigations become the lab's state: the new search re-enters the page as the route would. */ +const followNavigation = (model: Model, outMessage: PageOutMessage | undefined) => { + if (outMessage?._tag !== "RequestedNavigation") return Option.none() + return Option.flatMap(Option.map(fromString(`http://lab${outMessage.url}`), urlToAppRoute), (route) => + route._tag === "AgentSessionTools" + ? Option.some(routeChanged(model.page, { search: route.search }, contextOf(model.timeZone))) + : Option.none(), + ) +} + +type PageStep = Readonly<{ + model: ToolsModel + commands?: ReadonlyArray + outMessage?: PageOutMessage +}> + +/** Runs a page step to rest: reads are answered in place, navigations re-enter, the rest is lifted. */ +const settle = (model: Model, step: PageStep): Return => { + const withPage: Model = { ...model, page: step.model } + const resolved = (step.commands ?? []).map( + (command) => [command, answer(command, step.model.nowMs)] as const, + ) + const answers = resolved.flatMap(([, maybe]) => Option.toArray(maybe)) + const rest = resolved.flatMap(([command, maybe]) => (Option.isNone(maybe) ? [command] : [])) + const navigated = Option.match(followNavigation(withPage, step.outMessage), { + onNone: () => ({ model: withPage, commands: [] }), + onSome: (next) => settle(withPage, next), + }) + return answers.reduce( + (result, message) => { + const next = settle( + result.model, + toolsUpdate(result.model.page, message, contextOf(result.model.timeZone)), + ) + return { model: next.model, commands: [...(result.commands ?? []), ...(next.commands ?? [])] } + }, + { + model: navigated.model, + commands: [ + ...Command.mapMessages(rest, (message) => Message.GotPageMessage({ message })), + ...(navigated.commands ?? []), + ], + }, + ) +} + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +/** The detail page entered on `tool`, its reads answered; the lab's Commands carry the rest. */ +const enterDetail = (tool: (typeof DETAIL_TOOLS)[number], timeZone: string, previous?: ToolModel) => { + // The toolbar filters carry over; the theme read stays, since its Mount does not run again. + const search = { service: previous?.search.service, env: previous?.search.env, model: previous?.search.model } + const entered = toolInit({ toolName: tool, search }, detailContextOf(timeZone)) + const carried = previous === undefined ? entered : { ...entered, model: { ...entered.model, themeColors: previous.themeColors } } + const settled = settleDetail(carried, timeZone) + return { + detail: settled.model, + commands: Command.mapMessages(settled.commands, (message) => Message.GotDetailMessage({ message })), + } +} + +const init = (flags: ViewerFlags): Return => { + const entered = toolsInit({ search: {} }, contextOf(flags.timeZone)) + const detail = enterDetail("submit_candidate", flags.timeZone) + const settled = settle( + { + page: entered.model, + detail: detail.detail, + detailTool: "submit_candidate", + isErrorsLoading: false, + view: "overview", + width: null, + timeZone: flags.timeZone, + toasts: initLabToasts(), + }, + entered, + ) + return { model: settled.model, commands: [...(settled.commands ?? []), ...detail.commands] } +} + +const update = (model: Model, message: Message): Return => + Message.match(message, { + GotPageMessage: ({ message: inner }) => + settle(model, toolsUpdate(model.page, inner, contextOf(model.timeZone))), + GotDetailMessage: ({ message: inner }) => { + const settled = settleDetail(toolUpdate(model.detail, inner, detailContextOf(model.timeZone)), model.timeZone) + return { + model: { ...model, detail: settled.model }, + commands: Command.mapMessages(settled.commands, (message) => Message.GotDetailMessage({ message })), + } + }, + // A different tool is a fresh page: its group, session and variant are not carried over. + ClickedDetailTool: ({ tool }) => { + const entered = enterDetail(tool, model.timeZone, model.detail) + return { model: { ...model, detail: entered.detail, detailTool: tool }, commands: entered.commands } + }, + ToggledErrorsLoading: () => ({ model: { ...model, isErrorsLoading: !model.isErrorsLoading } }), + ClickedView: ({ view }) => ({ model: { ...model, view } }), + ClickedWidth: ({ width }) => ({ model: { ...model, width } }), + GotToastMessage: ({ message: inner }) => foldToasts(model, inner), + }) + +const toggleClass = (isActive: boolean): string => + isActive + ? "rounded border border-border bg-accent px-2 py-1 text-xs" + : "rounded border border-border px-2 py-1 text-xs text-muted-foreground" + +const labButton = (h: HtmlBuilder, label: string, isActive: boolean, onClick: Message): Html => + h.button([h.Class(toggleClass(isActive)), h.Type("button"), h.OnClick(onClick)], [label]) + +const toolsPage = Submodel.defineView>( + (page, inputs, h) => + h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: TOOL_CHART_TOKENS }))], + [toolsBody(h, page, { timeZone: inputs.timeZone, isFilled: false })], + ), +) + +/** The search as the lab prints it: the keys set, in the page's order. */ +const searchJson = (model: Model): string => + JSON.stringify( + Object.fromEntries( + Object.entries(model.view === "detail" ? model.detail.search : model.page.search).filter( + ([key, value]) => value !== undefined && key !== "timePreset", + ), + ), + ) + +/** The detail page without the route's time controls, the Errors read held while "errors loading". */ +const detailPage = Submodel.defineView>( + (detail, inputs, h) => + h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: GRID_TOKENS }))], + [ + toolDetailBody( + h, + inputs.isErrorsLoading ? { ...detail, errors: { ...detail.errors, data: AsyncData.Loading() } } : detail, + { timeZone: inputs.timeZone, isRoute: false }, + ), + ], + ), +) + +const page = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("flex flex-col gap-4 p-6")], + [ + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + ...VIEWS.map((view) => + labButton(h, view, model.view === view, Message.ClickedView({ view })), + ), + h.span([h.Class("w-4")], []), + ...WIDTHS.map((option) => + labButton( + h, + option.label, + model.width === option.value, + Message.ClickedWidth({ width: option.value }), + ), + ), + ...(model.view === "detail" + ? [ + h.span([h.Class("w-4")], []), + ...DETAIL_TOOLS.map((tool) => + labButton(h, tool, model.detailTool === tool, Message.ClickedDetailTool({ tool })), + ), + labButton(h, "errors loading", model.isErrorsLoading, Message.ToggledErrorsLoading()), + ] + : []), + h.span([h.Class("ml-2 font-mono text-2xs text-muted-foreground")], [searchJson(model)]), + ], + ), + h.div( + [ + h.Class("@container/page min-w-0 overflow-hidden rounded-lg border border-border"), + ...(model.width === null ? [] : [h.Style({ width: `${model.width}px` })]), + ], + [ + model.view === "detail" + ? h.submodel({ + slotId: "lab-agent-tools-detail", + model: model.detail, + view: detailPage, + viewInputs: { timeZone: model.timeZone, isErrorsLoading: model.isErrorsLoading }, + toParentMessage: (message: ToolMessage) => Message.GotDetailMessage({ message }), + }) + : h.submodel({ + slotId: "lab-agent-tools-page", + model: model.page, + view: toolsPage, + viewInputs: { timeZone: model.timeZone }, + toParentMessage: (message: ToolsMessage) => Message.GotPageMessage({ message }), + }), + ], + ), + ], + ) + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +export const lab = defineLabWithFlags("Agent tools", { + Model, + Flags: ViewerFlags, + flags: viewerFlags, + init, + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/alerts.ts b/apps/web-foldkit/src/lab/entries/alerts.ts new file mode 100644 index 0000000000..75bea3ce02 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/alerts.ts @@ -0,0 +1,25 @@ +import { Effect, Schema } from "effect" +import * as Shell from "../../shell" +import { AlertsLabFlags, alertsLabFlags, init, update } from "../alerts-lab/update" +import { Message, Model } from "../alerts-lab/model" +import { view } from "../alerts-lab/view-page" +import { LabShellFlags, labShellFlags, labShellSubscriptions } from "../app-shell" +import { defineLabWithFlags } from "../define" + +/** + * `/lab/alerts`: the `/alerts` Inbox, Rules and Routing tabs and the incident page over one + * fixture org, inside the real shell. + */ +const toShell = (message: Shell.Message) => Message.GotShellMessage({ message }) + +const subscriptions = labShellSubscriptions(toShell) + +export const lab = defineLabWithFlags("Alerts", { + Model, + Flags: Schema.Struct({ ...AlertsLabFlags.fields, ...LabShellFlags.fields }), + flags: Effect.map(Effect.all([alertsLabFlags, labShellFlags]), ([alerts, shell]) => ({ ...alerts, ...shell })), + init, + update, + view, + subscriptions, +}) diff --git a/apps/web-foldkit/src/lab/entries/bench/agent-transcript.ts b/apps/web-foldkit/src/lab/entries/bench/agent-transcript.ts new file mode 100644 index 0000000000..235cce4321 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/agent-transcript.ts @@ -0,0 +1,151 @@ +import { buildSessionSummary, buildSessionTurns } from "@maple/agent-sessions" +import { Effect, Schema } from "effect" +import { Command, Mount } from "foldkit" +import { installTranscriptBench } from "../../bench/transcript-harness" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as SessionMessage } from "../../../page/agent-sessions/session/message" +import { Model as SessionModel } from "../../../page/agent-sessions/session/model" +import type { Resources } from "../../../platform/resources" +import { ViewerFlags, viewerFlags } from "../../../ui/logs/viewer" +import * as Toast from "../../../ui/toast" +import { buildLargeSessionFixture } from "../../agent-transcript-fixture" +import { defineLabWithFlags } from "../../define" +import { enterSession, type SessionStep, sessionViews, stepSession, toWire } from "../../session-lab" +import { foldLabToasts, initLabToasts, labRoot } from "../../toasts" + +// `/lab/bench/agent-transcript` (apps/web `lab/bench/agent-transcript-bench.tsx`): the transcript +// over a session big enough to hurt, `?turns=` turns of it, with the perf spec's harness. + +const SESSION_ID = "transcript-bench" +const DEFAULT_TURNS = 40 + +const Flags = Schema.Struct({ ...ViewerFlags.fields, turns: Schema.Number }) +type Flags = typeof Flags.Type + +const Model = Schema.Struct({ + session: SessionModel, + turns: Schema.Number, + spanCount: Schema.Number, + megabytes: Schema.String, + timeZone: Schema.String, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + GotSessionMessage: { message: SessionMessage }, + GotToastMessage: { message: Toast.Message }, + CompletedBenchInstall: {}, +}) +type Message = typeof Message.Type + +/** `window.__transcriptBench` for the perf spec, for as long as the bench is on the page. */ +const InstallTranscriptBench = Mount.define("InstallTranscriptBench", { + messages: [Message.CompletedBenchInstall], + execute: () => + Effect.acquireRelease(Effect.sync(installTranscriptBench), (uninstall) => Effect.sync(uninstall)).pipe( + Effect.as(Message.CompletedBenchInstall()), + ), +}) + +type Return = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +const toLab = (message: SessionMessage) => Message.GotSessionMessage({ message }) + +const landed = (model: Model, step: SessionStep): Return => ({ + model: { ...model, session: step.session }, + commands: Command.mapMessages(step.commands, toLab), +}) + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +/** `?turns=`: a positive integer, else the default size. */ +const turnsOf = (raw: string | null): number => { + const turns = Number(raw ?? Number.NaN) + return Number.isInteger(turns) && turns > 0 ? turns : DEFAULT_TURNS +} + +const init = (flags: Flags): Return => { + const spans = buildLargeSessionFixture(flags.turns) + const summary = buildSessionSummary({ spans, turns: buildSessionTurns(spans) }) + const entered = enterSession(SESSION_ID, "transcript", toWire(spans, SESSION_ID), flags.timeZone) + return landed( + { + session: entered.session, + turns: flags.turns, + spanCount: summary.spanCount, + megabytes: (JSON.stringify(spans).length / 1_048_576).toFixed(1), + timeZone: flags.timeZone, + toasts: initLabToasts(), + }, + entered, + ) +} + +const update = (model: Model, message: Message): Return => + Message.match(message, { + GotSessionMessage: ({ message: inner }) => + landed(model, stepSession(model.session, inner, model.timeZone)), + GotToastMessage: ({ message: inner }) => foldToasts(model, inner), + CompletedBenchInstall: () => ({ model }), + }) + +const page = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.DataAttribute("transcript-bench", ""), + h.Class("flex h-screen flex-col bg-background text-foreground"), + h.OnMount(InstallTranscriptBench()), + ], + [ + h.div( + [h.Class("flex shrink-0 items-center gap-3 border-border border-b px-4 py-2 text-xs")], + [ + h.span([h.Class("font-semibold")], ["Transcript bench"]), + h.span( + [h.Class("text-muted-foreground")], + [ + String(model.turns), + " turns · ", + String(model.spanCount), + " spans ·", + " ", + model.megabytes, + " MB of spans", + ], + ), + ], + ), + h.div( + [ + h.DataAttribute("slot", "page-scroll-area"), + h.Class("flex min-h-0 flex-1 flex-col overflow-auto px-4"), + ], + [ + h.submodel({ + slotId: "lab-transcript-bench", + model: model.session, + view: sessionViews, + viewInputs: { timeZone: model.timeZone }, + toParentMessage: toLab, + }), + ], + ), + ], + ) + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +export const lab = defineLabWithFlags("Agent transcript bench", { + Model, + Flags, + flags: Effect.map(viewerFlags, (viewer) => ({ + ...viewer, + turns: turnsOf(new URLSearchParams(window.location.search).get("turns")), + })), + init, + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/bench/dashboard-grid.ts b/apps/web-foldkit/src/lab/entries/bench/dashboard-grid.ts new file mode 100644 index 0000000000..353b2b712c --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/dashboard-grid.ts @@ -0,0 +1,274 @@ +import { DashboardDocument } from "@maple/domain/http" +import { OrgId } from "@maple/domain" +import { Effect, Option, Queue, Schema, Stream } from "effect" +import { Command, Mount, Submodel, Subscription } from "foldkit" +import { createKeyedLazy, type Html, type HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { + installDashboardGridBench, + LAST_COMMIT_SIZE_ATTRIBUTE, + LAYOUT_COMMITS_ATTRIBUTE, + TILE_RENDER_MARK, +} from "../../bench/dashboard-grid-harness" +import type { PageContext } from "../../../page/contract" +import { canvasGridOf } from "../../../page/dashboards/view/grid" +import { Message } from "../../../page/dashboards/view/message" +import { Model } from "../../../page/dashboards/view/model" +import { pointerEvents } from "../../../page/dashboards/view/subscription" +import { init as pageInit, update as pageUpdate } from "../../../page/dashboards/view/update" +import { dashboardSections } from "../../../page/dashboards/view/view-grid" +import type { Resources } from "../../../platform/resources" +import { defineLabWithFlags } from "../../define" + +/** + * `/lab/bench/dashboard-grid` (apps/web's `DashboardGridBench`): the dashboard grid in edit mode + * over a `?n=`-widget board (default 50), each tile ~150 elements standing in for a chart. Writes + * stay local: a layout commit moves the board here instead of reaching the API. + */ + +type Widget = DashboardDocument["widgets"][number] +type Layout = Widget["layout"] + +const CONTEXT: PageContext = { orgId: OrgId.make("lab"), pinnedNamespace: null, timeZone: "UTC" } +const DASHBOARD_ID = "bench" + +/** `buildBoard`: rows of stats, pairs of charts, a wide table, as real boards are shaped. */ +const buildBoard = (count: number): ReadonlyArray<{ id: string; title: string; layout: Layout }> => { + const band = (y: number) => Math.floor(y / 15) % 3 + const step = ( + widgets: ReadonlyArray<{ id: string; title: string; layout: Layout }>, + y: number, + ): ReadonlyArray<{ id: string; title: string; layout: Layout }> => { + if (widgets.length >= count) return widgets + const row = (kind: "stat" | "chart", label: string, xs: ReadonlyArray, w: number, h: number) => + xs.slice(0, count - widgets.length).map((x, index) => ({ + id: `${kind}-${widgets.length + index}`, + title: `${label} ${widgets.length + index}`, + layout: { x, y, w, h }, + })) + if (band(y) === 0) return step([...widgets, ...row("stat", "Stat", [0, 3, 6, 9], 3, 2)], y + 2) + if (band(y) === 1) return step([...widgets, ...row("chart", "Chart", [0, 6], 6, 6)], y + 6) + return step( + [...widgets, { id: `table-${widgets.length}`, title: `Table ${widgets.length}`, layout: { x: 0, y, w: 12, h: 5 } }], + y + 5, + ) + } + return step([], 0) +} + +const boardOf = (count: number) => + Schema.decodeUnknownSync(DashboardDocument)({ + id: DASHBOARD_ID, + name: "Dashboard grid bench", + timeRange: { type: "relative", value: "6h" }, + // Notes: static tiles, so nothing is fetched; the bench draws its own tile in each cell. + widgets: buildBoard(count).map((widget) => ({ + id: widget.id, + visualization: "markdown", + dataSource: { kind: "static" }, + display: { title: widget.title, markdown: { content: "" } }, + layout: widget.layout, + })), + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + schemaVersion: 3, + }) + +const LayoutWrite = Schema.Struct({ + layouts: Schema.Array(Schema.Struct({ i: Schema.String, x: Schema.Number, y: Schema.Number, w: Schema.Number, h: Schema.Number })), +}) +const decodeLayoutWrite = Schema.decodeUnknownOption(LayoutWrite) + +const BenchModel = Schema.Struct({ + board: Model, + /** `mountKey`: bumped by `measureMount`, so the board remounts. */ + mountKey: Schema.Number, + layoutCommits: Schema.Number, + lastCommitSize: Schema.Number, +}) +type BenchModel = typeof BenchModel.Type + +const BenchMessage = defineMessageUnion({ + GotBoardMessage: { message: Message }, + RequestedRemount: {}, +}) +type BenchMessage = typeof BenchMessage.Type + +type Box = typeof LayoutWrite.Type["layouts"][number] +const isMoved = (layout: Layout, box: Box) => box.x !== layout.x || box.y !== layout.y || box.w !== layout.w || box.h !== layout.h + +/** `onLayoutCommit`: the moved boxes applied to the board; an unchanged widget keeps its object. */ +const withLayouts = (model: BenchModel, layouts: ReadonlyArray): BenchModel => { + const document = model.board.document + if (document === null) return model + const byId = new Map(layouts.map((layout) => [layout.i, layout])) + const moved = document.widgets.filter((widget) => { + const box = byId.get(widget.id) + return box !== undefined && isMoved(widget.layout, box) + }) + const widgets = document.widgets.map((widget) => { + const box = byId.get(widget.id) + return box === undefined || !isMoved(widget.layout, box) + ? widget + : { ...widget, layout: { ...widget.layout, x: box.x, y: box.y, w: box.w, h: box.h } } + }) + return { + ...model, + board: { ...model.board, document: { ...document, widgets } }, + layoutCommits: model.layoutCommits + 1, + lastCommitSize: moved.length, + } +} + +/** The board's own work runs; its writes and API reads stay here. */ +const LOCAL_ONLY = new Set(["UpdateDashboardWidgetLayouts", "FetchDashboardShares", "FetchDashboardWidget"]) +const toBoard = (message: Message): BenchMessage => BenchMessage.GotBoardMessage({ message }) + +const boardStep = (model: BenchModel, message: Message) => { + const next = pageUpdate(model.board, message, CONTEXT) + const commands = next.commands ?? [] + const written = commands + .filter((command) => command.name === "UpdateDashboardWidgetLayouts") + .flatMap((command) => Option.toArray(decodeLayoutWrite(command.args))) + return { + model: written.reduce((acc, write) => withLayouts(acc, write.layouts), { ...model, board: next.model }), + commands: Command.mapMessages(commands.filter((command) => !LOCAL_ONLY.has(command.name)), toBoard), + } +} + +const update = (model: BenchModel, message: BenchMessage) => + BenchMessage.match(message, { + GotBoardMessage: ({ message: inner }) => boardStep(model, inner), + RequestedRemount: () => ({ model: { ...model, mountKey: model.mountKey + 1 }, commands: [] }), + }) + +const Flags = Schema.Struct({ count: Schema.Number }) + +const init = (flags: typeof Flags.Type) => { + const started = pageInit({ dashboardId: DASHBOARD_ID, search: { mode: "edit" } }, CONTEXT) + const empty: BenchModel = { board: started.model, mountKey: 0, layoutCommits: 0, lastCommitSize: 0 } + const loaded = boardStep(empty, Message.UpdatedDashboard({ orgId: CONTEXT.orgId, maybeDocument: Option.some(boardOf(flags.count)) })) + return { model: loaded.model, commands: [...Command.mapMessages(started.commands ?? [], toBoard), ...loaded.commands] } +} + +const BARS = Array.from({ length: 140 }, (_, index) => index) + +/** `memo(BenchTile)`: one slot per tile and mount; a widget that kept its object skips. */ +const lazyTile = createKeyedLazy() + +/** `BenchTile`: a header with the drag grip and ~140 bars in place of a chart. */ +const benchTile = (h: HtmlBuilder, widget: Widget): Html => { + // The bench's render counter (legacy `contentRenderCount`), read by the harness. + performance.mark(TILE_RENDER_MARK) + const title = widget.display.title ?? widget.id + return h.div( + [h.DataAttribute("bench-tile", widget.id), h.Class("flex h-full flex-col overflow-hidden rounded-lg border bg-card")], + [ + h.div( + [h.Class("flex items-center gap-2 px-3 py-2 text-xs")], + [ + h.button( + [ + h.Type("button"), + h.Class("widget-drag-handle cursor-grab touch-none text-muted-foreground"), + h.DataAttribute("grid-label", title), + h.AriaLabel(`Move ${title}`), + ], + ["::"], + ), + h.span([h.Class("truncate")], [title]), + ], + ), + h.div( + [h.Class("flex min-h-0 flex-1 items-end gap-px px-3 pb-2")], + BARS.map((bar) => + h.span([ + h.Class("flex-1 bg-primary/40"), + h.Style({ height: `${20 + ((bar * 37 + widget.id.length * 11) % 80)}%` }), + ]), + ), + ), + ], + ) +} + +/** The board, remounted with its key: ``. */ +const boardView = Submodel.defineView((model, inputs, h) => + model.document === null + ? h.div([], []) + : dashboardSections( + h, + { + model, + timeZone: CONTEXT.timeZone, + variables: {}, + renderTile: (widget) => lazyTile(`${inputs.mountKey}:${widget.id}`, benchTile, [h, widget]) ?? h.div([], []), + }, + model.document, + ), +) + +/** `window.__dashboardGridBench` while the bench is on the page; `measureMount` remounts the board. */ +const InstallDashboardGridBench = Mount.defineStream("InstallDashboardGridBench", { + args: { count: Schema.Number }, + messages: [BenchMessage.RequestedRemount], + execute: ({ element, count }) => + Stream.callback>((queue) => + Effect.acquireRelease( + Effect.sync(() => + installDashboardGridBench(element, count, () => Queue.offerUnsafe(queue, BenchMessage.RequestedRemount())), + ), + (uninstall) => Effect.sync(uninstall), + ).pipe(Effect.flatMap(() => Effect.never)), + ), +}) + +const view = (model: BenchModel, h: HtmlBuilder): Html => { + const count = model.board.document?.widgets.length ?? 0 + const cols = canvasGridOf(model.board)?.tier.cols ?? 12 + return h.div( + [ + h.Class("p-6"), + h.Attribute(LAYOUT_COMMITS_ATTRIBUTE, String(model.layoutCommits)), + h.Attribute(LAST_COMMIT_SIZE_ATTRIBUTE, String(model.lastCommitSize)), + h.OnMount(InstallDashboardGridBench({ count })), + ], + [ + h.p([h.Class("mb-3 text-xs text-muted-foreground")], [`${count} widgets · ${cols} columns`]), + h.keyed("div")( + `board-${model.mountKey}`, + [h.DataAttribute("bench-board", "")], + [ + h.submodel({ + slotId: `bench-board-${model.mountKey}`, + model: model.board, + view: boardView, + viewInputs: { mountKey: model.mountKey }, + toParentMessage: toBoard, + }), + ], + ), + ], + ) +} + +const subscriptions = Subscription.make()((entry) => ({ + gesturePointer: entry( + { widgetId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: (model) => ({ widgetId: model.board.gesture?.widgetId ?? null }), + dependenciesToStream: ({ widgetId }) => + widgetId === null ? Stream.empty : Stream.map(pointerEvents(widgetId), toBoard), + }, + ), +})) + +export const lab = defineLabWithFlags("Dashboard grid bench", { + Model: BenchModel, + Flags, + flags: Effect.sync(() => ({ count: Number(new URLSearchParams(window.location.search).get("n") ?? "50") || 50 })), + init, + update, + view, + subscriptions, +}) diff --git a/apps/web-foldkit/src/lab/entries/bench/infra.ts b/apps/web-foldkit/src/lab/entries/bench/infra.ts new file mode 100644 index 0000000000..0f134b571d --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/infra.ts @@ -0,0 +1,6 @@ +import { defineChartBench } from "../../bench/chart-bench" +import { view } from "../../bench/infra-bench" + +// `/lab/bench/infra`: the infra detail charts in one linked-cursor group. + +export const lab = defineChartBench("Infra charts", view) diff --git a/apps/web-foldkit/src/lab/entries/bench/logs.ts b/apps/web-foldkit/src/lab/entries/bench/logs.ts new file mode 100644 index 0000000000..4b165753b2 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/logs.ts @@ -0,0 +1,157 @@ +import { LOGS_TABLE_BENCH_ROWS } from "@maple/web/lab/bench/logs-table-bench-fixture" +import { Effect, Option, Schema, Stream } from "effect" +import { Mount, Subscription, Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" +import { logTimeFormat, toLogRowView } from "../../../ui/logs/log-row-view" +import * as LogsStream from "../../../ui/logs/logs-stream" +import * as LogsStreamView from "../../../ui/logs/logs-stream-view" +import { listFooterText, ViewerFlags, viewerFlags } from "../../../ui/logs/viewer" +import { defineLabWithFlags } from "../../define" +import { foldLabToasts, initLabToasts, labRoot } from "../../toasts" +import * as Toast from "../../../ui/toast" +import { installLogsBench } from "../../bench/logs-harness" + +/** + * `/lab/bench/logs`: the logs stream over 2,000 wide synthetic rows (apps/web's `LogsTableBench`), + * with the same `window.__logsBench` harness for `perf/logs.perf.spec.ts`. + */ +const PINNED_COLUMNS = ["deployment.environment.name", "cloud.region"] + +// MODEL + +const Model = Schema.Struct({ + stream: LogsStream.Model, + timeZone: Schema.String, + footerText: Schema.String, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +// MESSAGE + +const Message = defineMessageUnion({ + GotStreamMessage: { message: LogsStream.Message }, + CompletedBenchInstall: {}, + GotToastMessage: { message: Toast.Message }, +}) +type Message = typeof Message.Type + +// MOUNT + +const InstallLogsBench = Mount.define("InstallLogsBench", { + messages: [Message.CompletedBenchInstall], + execute: ({ element }) => + Effect.acquireRelease( + Effect.sync(() => (element instanceof HTMLElement ? installLogsBench(element) : () => undefined)), + (uninstall) => Effect.sync(uninstall), + ).pipe(Effect.as(Message.CompletedBenchInstall())), +}) + +// INIT + +const init = (flags: ViewerFlags): Update.Return => { + const time = logTimeFormat(flags.timeZone) + return { + model: { + stream: LogsStream.init( + "logs-bench", + LOGS_TABLE_BENCH_ROWS.map((log) => toLogRowView(log, PINNED_COLUMNS, time)), + ), + timeZone: flags.timeZone, + toasts: initLabToasts(), + footerText: listFooterText(flags.locale, { + shown: LOGS_TABLE_BENCH_ROWS.length, + noun: "logs", + capped: true, + hasMore: false, + }), + }, + } +} + +// UPDATE + +const unchanged: Update.Step = (model) => ({ model }) + +// The bench passes `onLogClick={() => {}}` and no live tail: nothing the stream reports goes anywhere. +const foldStream = Update.foldChild({ + update: LogsStream.update, + read: (model: Model) => Option.some(model.stream), + write: (model, stream) => modifyFields(model, { stream: () => stream }), + toParentMessage: (message: LogsStream.Message) => Message.GotStreamMessage({ message }), + foldOutMessage: LogsStream.OutMessage.match>({ + OpenedLog: () => unchanged, + ChangedInspecting: () => unchanged, + ChoseAttributeFilter: () => unchanged, + }), +}) + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotStreamMessage: ({ message: streamMessage }) => foldStream(model, streamMessage), + CompletedBenchInstall: () => ({ model }), + GotToastMessage: ({ message }) => foldToasts(model, message), + }) + +// VIEW + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +const page = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.DataAttribute("logs-bench", "true"), + h.Class("flex h-screen flex-col bg-background p-4 text-foreground"), + h.OnMount(InstallLogsBench()), + ], + [ + h.submodel({ + slotId: "logs-stream", + model: model.stream, + view: LogsStreamView.view, + toParentMessage: (message) => Message.GotStreamMessage({ message }), + viewInputs: { + wrap: false, + density: "compact", + pinnedColumns: PINNED_COLUMNS, + searchText: undefined, + timeZone: model.timeZone, + isWaiting: false, + footerText: model.footerText, + canFilter: false, + }, + }), + ], + ) + +// SUBSCRIPTION + +const subscriptions = Subscription.make()((entry) => ({ + navigationKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: (model) => ({ hasRows: model.stream.rows.length > 0 }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? Stream.map(LogsStream.navigationKeys, (message) => + Message.GotStreamMessage({ message }), + ) + : Stream.empty, + }, + ), +})) + +export const lab = defineLabWithFlags("Logs table", { + Model, + Flags: ViewerFlags, + flags: viewerFlags, + init, + update, + view, + subscriptions, +}) diff --git a/apps/web-foldkit/src/lab/entries/bench/overview.ts b/apps/web-foldkit/src/lab/entries/bench/overview.ts new file mode 100644 index 0000000000..877f349114 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/overview.ts @@ -0,0 +1,6 @@ +import { defineChartBench } from "../../bench/chart-bench" +import { view } from "../../bench/service-detail-bench" + +// `/lab/bench/overview`: apps/web mounts the same `ServiceDetailChartBench` here. + +export const lab = defineChartBench("Overview charts", view) diff --git a/apps/web-foldkit/src/lab/entries/bench/service-detail.ts b/apps/web-foldkit/src/lab/entries/bench/service-detail.ts new file mode 100644 index 0000000000..0f0a401678 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/service-detail.ts @@ -0,0 +1,6 @@ +import { defineChartBench } from "../../bench/chart-bench" +import { view } from "../../bench/service-detail-bench" + +// `/lab/bench/service-detail`: the service-detail chart grid on synthetic rows. + +export const lab = defineChartBench("Service detail charts", view) diff --git a/apps/web-foldkit/src/lab/entries/bench/service-map.ts b/apps/web-foldkit/src/lab/entries/bench/service-map.ts new file mode 100644 index 0000000000..3578ecc871 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/service-map.ts @@ -0,0 +1,244 @@ +import { + BENCH_CLOUDFLARE_SERVICES, + type BenchParams as LegacyBenchParams, + DEFAULT_BENCH_PARAMS, + DURATION_SECONDS, + generateBenchGraph, + refreshBenchMetrics, +} from "@maple/web/lab/bench/service-map-bench-fixture" +import type { DeclutterFocus } from "@maple/ui/components/service-map/service-map-declutter" +import { cn } from "@maple/ui/lib/utils" +import { Effect, Option, Queue, Schema, Stream } from "effect" +import { Command, Mount, Subscription, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { pushUrl } from "foldkit/navigation" +import { AttachBenchHarness, HarnessMessage } from "../../../service-map/map/bench-harness" +import { buildSourceGraph } from "../../../service-map/map/graph" +import * as ServiceMap from "../../../service-map/map/service-map" +import { view as serviceMapView } from "../../../service-map/map/view" +import * as Canvas from "../../../service-map/xyflow/canvas" +import { eyebrow } from "../../../ui/eyebrow" +import { defineLabWithFlags } from "../../define" +import { foldLabToasts, initLabToasts, labRoot } from "../../toasts" +import * as Toast from "../../../ui/toast" + +// `/lab/bench/service-map` (apps/web `routes/lab/bench/service-map.tsx` + `lab/bench/service-map-bench.tsx`): +// the synthetic topology at `?services=&edges=&rps=&seed=&groups=&minTraffic=&focus=`, with the +// namespace-group switch and the `window.__smBench` perf harness. + +const BenchParams = Schema.Struct({ + services: Schema.Number, + edges: Schema.Number, + rps: Schema.Literals(["low", "med", "high"]), + seed: Schema.Number, + groups: Schema.Number, + minTraffic: Schema.Number, + focus: Schema.String, +}) +type BenchParams = typeof BenchParams.Type + +/** TanStack search semantics: each value is JSON when it parses, else the raw string. */ +const readParams = Effect.sync((): BenchParams => { + const search = new URLSearchParams(location.search) + const number = (key: keyof LegacyBenchParams, fallback: number) => { + const value = Number(search.get(key)) + return search.has(key) && Number.isFinite(value) ? value : fallback + } + const rps = search.get("rps") + return { + services: number("services", DEFAULT_BENCH_PARAMS.services), + edges: number("edges", DEFAULT_BENCH_PARAMS.edges), + rps: rps === "low" || rps === "med" || rps === "high" ? rps : DEFAULT_BENCH_PARAMS.rps, + seed: number("seed", DEFAULT_BENCH_PARAMS.seed), + groups: number("groups", DEFAULT_BENCH_PARAMS.groups), + minTraffic: number("minTraffic", DEFAULT_BENCH_PARAMS.minTraffic), + focus: search.get("focus") ?? DEFAULT_BENCH_PARAMS.focus, + } +}) + +const Model = Schema.Struct({ params: BenchParams, map: ServiceMap.Model, toasts: Toast.Model }) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + GotMapMessage: { message: ServiceMap.Message }, + ClickedNamespaceGroups: { groups: Schema.Number }, + CompletedPushBenchUrl: {}, + GotHarnessMessage: { message: HarnessMessage }, + GotToastMessage: { message: Toast.Message }, +}) +type Message = typeof Message.Type + +const toMap = (message: ServiceMap.Message): Message => Message.GotMapMessage({ message }) + +const initMap = (params: BenchParams) => { + // The legacy bench renders revision 0 of its metric refresh, not the raw generated graph. + const graph = refreshBenchMetrics(generateBenchGraph(params), 0) + const source = buildSourceGraph({ + edges: graph.edges, + dbEdges: graph.dbEdges, + serviceOverviews: graph.overviews, + durationSeconds: DURATION_SECONDS, + serviceWorkloads: graph.workloads, + platforms: graph.platforms, + runtimes: graph.runtimes, + cloudflareServices: [...BENCH_CLOUDFLARE_SERVICES], + faasNames: new Map(), + planetscaleDatabases: new Map(), + planetscaleStats: [], + }) + const focus: Option.Option = params.focus + ? Option.some({ serviceId: params.focus, hops: 1, mode: "dim" }) + : Option.none() + return ServiceMap.init(source, { minTrafficPct: params.minTraffic > 0 ? params.minTraffic : 0, focus }) +} + +/** `navigate({ search: (prev) => ({ ...prev, groups }) })`. */ +const PushBenchGroups = Command.define("PushBenchGroups", { + args: { groups: Schema.Number }, + messages: [Message.CompletedPushBenchUrl], + execute: ({ groups }) => + Effect.sync(() => { + const search = new URLSearchParams(location.search) + search.set("groups", String(groups)) + return `${location.pathname}?${search}` + }).pipe(Effect.flatMap(pushUrl), Effect.as(Message.CompletedPushBenchUrl())), +}) + +const init = (params: BenchParams): Update.Return => { + const map = initMap(params) + return { + model: { params, map: map.model, toasts: initLabToasts() }, + commands: Command.mapMessages(map.commands, toMap), + } +} + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const update = (model: Model, message: Message): Update.Return => + Message.match(message, { + GotMapMessage: ({ message: mapMessage }) => { + const next = ServiceMap.update(model.map, mapMessage) + return { + model: { ...model, map: next.model }, + commands: Command.mapMessages(next.commands, toMap), + } + }, + // The legacy route keys the bench on its params, so a new grouping starts a fresh map. + ClickedNamespaceGroups: ({ groups }) => { + const fresh = init({ ...model.params, groups }) + return { + model: { ...fresh.model, toasts: model.toasts }, + commands: [...(fresh.commands ?? []), PushBenchGroups({ groups })], + } + }, + CompletedPushBenchUrl: () => ({ model }), + GotToastMessage: ({ message }) => foldToasts(model, message), + // The perf harness pins and frames the camera the way a user gesture would. + GotHarnessMessage: ({ message: harness }) => + update( + model, + toMap( + HarnessMessage.match(harness, { + RequestedCamera: ({ viewport }) => + ServiceMap.Message.GotCanvasMessage({ + message: Canvas.Message.ChangedViewport({ viewport }), + }), + RequestedFit: () => ServiceMap.Message.ClickedResort(), + }), + ), + ), + }) + +const reducedMotionQuery = () => window.matchMedia("(prefers-reduced-motion: reduce)") + +const subscriptions = Subscription.make()((entry) => ({ + /** `useMediaQuery("(prefers-reduced-motion: reduce)")`, which gates the particle canvas. */ + reducedMotion: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => + Stream.concat( + Stream.sync(() => reducedMotionQuery().matches), + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const query = reducedMotionQuery() + const listener = (event: MediaQueryListEvent) => + Queue.offerUnsafe(queue, event.matches) + query.addEventListener("change", listener) + return () => query.removeEventListener("change", listener) + }), + (release) => Effect.sync(release), + ).pipe(Effect.flatMap(() => Effect.never)), + ), + ).pipe(Stream.map((reduced) => toMap(ServiceMap.Message.ChangedReducedMotion({ reduced })))), + }, + ), +})) + +const GROUP_OPTIONS = [0, 3, 6, 10] + +/** The namespace-group switch the legacy route draws over the map. */ +const groupSwitch = (h: HtmlBuilder, groups: number): Html => + h.div( + [ + h.Class( + "absolute left-1/2 top-2 z-[60] flex -translate-x-1/2 items-center gap-1 rounded-md border border-border bg-card/90 px-2 py-1 backdrop-blur-sm", + ), + ], + [ + eyebrow(h, {}, ["Namespace groups"]), + ...GROUP_OPTIONS.map((option) => + h.button( + [ + h.Type("button"), + h.OnClick(Message.ClickedNamespaceGroups({ groups: option })), + h.Class( + cn( + "rounded px-1.5 py-0.5 font-mono text-2xs transition-colors", + option === groups + ? "bg-primary text-primary-foreground" + : "text-muted-foreground hover:text-foreground", + ), + ), + ], + [option === 0 ? "off" : String(option)], + ), + ), + ], + ) + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +const page = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("relative h-screen w-screen")], + [ + groupSwitch(h, model.params.groups), + h.div( + [ + h.Class("h-screen w-screen bg-background"), + h.DataAttribute("testid", "service-map-bench"), + h.OnMount( + Mount.mapMessage(AttachBenchHarness(), (message) => + Message.GotHarnessMessage({ message }), + ), + ), + ], + [serviceMapView(h, model.map, toMap)], + ), + ], + ) + +export const lab = defineLabWithFlags("Service map", { + Model, + Flags: BenchParams, + flags: readParams, + init, + update, + view, + subscriptions, +}) diff --git a/apps/web-foldkit/src/lab/entries/bench/tanstack.ts b/apps/web-foldkit/src/lab/entries/bench/tanstack.ts new file mode 100644 index 0000000000..aca0950bb3 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/bench/tanstack.ts @@ -0,0 +1,6 @@ +import { defineChartBench } from "../../bench/chart-bench" +import { view } from "../../bench/tanstack-bench" + +// `/lab/bench/tanstack`: the overview charts on the canvas renderer. + +export const lab = defineChartBench("TanStack charts", view) diff --git a/apps/web-foldkit/src/lab/entries/charts.ts b/apps/web-foldkit/src/lab/entries/charts.ts new file mode 100644 index 0000000000..c3cac0a33e --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/charts.ts @@ -0,0 +1,405 @@ +import { pickValueField, toBreakdownRows } from "@maple/ui/components/charts/_shared/breakdown-rows" +import { heatmapSampleData, pieSampleData } from "@maple/ui/components/charts/_shared/sample-data" +import { + STACKED_BAR_SPIKE_ROWS, + TIMESERIES_PARTIAL_ROWS, + TIMESERIES_SPIKE_ROWS, + toLatencyProductionRows, + toStackedBarProductionRows, + toThroughputProductionRows, +} from "@maple/web/lab/charts/timeseries-data" +import { Clock, Effect, Option, Schema } from "effect" +import { Command, Mount, Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" +import * as Chart from "../../charts/chart" +import { + ChartRuntime, + ChartRuntimeMessage, + isChartRuntimeReady, + LoadChartRuntime, +} from "../../charts/chart-runtime" +import * as AreaChart from "../../charts/area-chart" +import * as BarChart from "../../charts/bar-chart" +import * as HeatmapChart from "../../charts/heatmap-chart" +import * as HistogramChart from "../../charts/histogram-chart" +import * as PieChart from "../../charts/pie-chart" +import { ResolveThemeColors, ThemeColors, ThemeMessage } from "../../charts/theme-colors" +import { eyebrow } from "../../ui/eyebrow" +import { chartEmpty } from "../../ui/chart-state" +import { widgetShell } from "../../page/dashboards/kit/tile-shell" +import { inlineCode } from "../../ui/inline-code" +import { ARM_TITLES, type ChartsLabArm, SECTIONS } from "../charts/arms" +import { + histogramSpikeValues, + histogramSpikeView, + pieLegendSpikeView, + pieSpikeView, + SPIKE_TOKENS, +} from "../charts/spikes" +import { SPIKE_ARMS, SPIKE_TOKENS_ALL } from "../charts/spike-arms" +import { spikeView } from "../charts/spike-view" +import { defineLab } from "../define" +import { foldLabToasts, initLabToasts, labRoot } from "../toasts" +import * as Toast from "../../ui/toast" + +// Port of `/lab/charts` (apps/web/src/lab/charts/charts-lab.tsx): the gallery, canvas renderer. + +const PRODUCTION_CHART_CLASS = "h-full w-full aspect-auto" +const pieRows = toBreakdownRows(pieSampleData, pickValueField(pieSampleData)).map((row) => ({ + name: row.name, + value: row.value, +})) +const histogramProductionRows: ReadonlyArray> = histogramSpikeValues.map((value) => ({ + value, +})) + +type ChartRow = Readonly> + +/** The production rows narrowed to the scalar cells the timeseries charts read. */ +const scalarRows = (rows: ReadonlyArray>): ReadonlyArray => + rows.map((row) => + Object.fromEntries( + Object.entries(row).flatMap(([key, value]) => + typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? [[key, value]] : [], + ), + ), + ) + +/** The production timeseries arms: `QueryBuilderLineChart`, `QueryBuilderAreaChart`, stacked bars. */ +const PRODUCTION_TIMESERIES = { + "line-production": { kind: "line", rows: scalarRows(toLatencyProductionRows(TIMESERIES_SPIKE_ROWS)) }, + "line-incomplete-production": { kind: "line", rows: scalarRows(toLatencyProductionRows(TIMESERIES_PARTIAL_ROWS)) }, + "area-production": { kind: "area", rows: scalarRows(toThroughputProductionRows(TIMESERIES_SPIKE_ROWS)) }, + "area-incomplete-production": { kind: "area", rows: scalarRows(toThroughputProductionRows(TIMESERIES_PARTIAL_ROWS)) }, + "stacked-bar-production": { kind: "bar", rows: scalarRows(toStackedBarProductionRows(STACKED_BAR_SPIKE_ROWS)) }, +} satisfies Partial }>>> + +const productionTimeseriesOf = (arm: ChartsLabArm) => { + switch (arm) { + case "line-production": + case "line-incomplete-production": + case "area-production": + case "area-incomplete-production": + case "stacked-bar-production": + return PRODUCTION_TIMESERIES[arm] + default: + return undefined + } +} + +/** One chart instance per gallery position, section and row (an arm can repeat in a section). */ +const slotOf = (position: string, arm: ChartsLabArm): string => `${position}:${arm}` + +const PORTED_ARMS: ReadonlyArray = [ + "heatmap-production", + "line-production", + "area-production", + "stacked-bar-production", + "line-incomplete-production", + "area-incomplete-production", + "pie-production", + "pie-tanstack", + "pie-solid-tanstack", + "pie-legend-tanstack", + "pie-solid-legend-tanstack", + "histogram-production", + "histogram-tanstack", +] +const isPorted = (arm: ChartsLabArm): boolean => PORTED_ARMS.includes(arm) || SPIKE_ARMS.has(arm) + +const THEME_TOKENS: ReadonlyArray = [ + ...new Set([ + ...PieChart.pieChartTokens(pieSampleData), + ...SPIKE_TOKENS, + ...SPIKE_TOKENS_ALL, + HistogramChart.HISTOGRAM_BAR_TOKEN, + ...AreaChart.AREA_CHART_TOKENS, + ...BarChart.BAR_CHART_TOKENS, + ...HeatmapChart.HEATMAP_TOKENS, + "--border", + ]), +] + +// MODEL + +const Model = Schema.Struct({ + maybeTheme: Schema.Option(ThemeColors), + chartRuntime: ChartRuntime, + charts: Schema.Record(Schema.String, Chart.Model), + /** The capture clock the production charts judge an in-flight tail against. */ + nowMs: Schema.Number, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const init = (): Update.Return => ({ + model: { + maybeTheme: Option.none(), + chartRuntime: ChartRuntime.Loading(), + nowMs: 0, + charts: Object.fromEntries( + SECTIONS.flatMap((section, sectionIndex) => + section.rows.flatMap((row, rowIndex) => + row.filter(isPorted).map((arm) => [slotOf(`${sectionIndex}.${rowIndex}`, arm), Chart.init()]), + ), + ), + ), + toasts: initLabToasts(), + }, + commands: [ + ...Command.mapMessages([LoadChartRuntime()], (message) => Message.GotChartRuntimeMessage({ message })), + ReadClock(), + ], +}) + +// MESSAGE + +const Message = defineMessageUnion({ + ResolvedThemeColors: { colors: ThemeColors }, + GotChartRuntimeMessage: { message: ChartRuntimeMessage }, + GotChartMessage: { slot: Schema.String, message: Chart.Message }, + GotToastMessage: { message: Toast.Message }, + CompletedReadClock: { nowMs: Schema.Number }, +}) +type Message = typeof Message.Type + +const ReadClock = Command.define("ReadChartsLabClock", { + messages: [Message.CompletedReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.CompletedReadClock({ nowMs })), +}) + +// UPDATE + +const foldChart = Update.foldChildAt({ + update: Chart.update, + readAt: (model: Model, slot: string) => Option.fromNullishOr(model.charts[slot]), + writeAt: (model, slot, nextChart) => + modifyFields(model, { charts: (charts) => ({ ...charts, [slot]: nextChart }) }), + toParentMessage: (slot, message) => Message.GotChartMessage({ slot, message }), +}) + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const update = (model: Model, message: Message) => + Message.match>(message, { + ResolvedThemeColors: ({ colors }) => ({ + model: modifyFields(model, { maybeTheme: () => Option.some(colors) }), + }), + GotChartRuntimeMessage: ({ message }) => ({ + model: modifyFields(model, { + chartRuntime: () => + ChartRuntimeMessage.match(message, { + CompletedLoadChartRuntime: () => ChartRuntime.Ready(), + FailedLoadChartRuntime: ({ reason }) => ChartRuntime.Failed({ reason }), + }), + }), + }), + GotChartMessage: ({ slot, message }) => foldChart(model, slot, message), + GotToastMessage: ({ message }) => foldToasts(model, message), + CompletedReadClock: ({ nowMs }) => ({ model: modifyFields(model, { nowMs: () => nowMs }) }), + }) + +// VIEW + +const toChartMessage = (slot: string) => (message: Chart.Message) => + Message.GotChartMessage({ slot, message }) + +/** The `cards[arm].node` of a ported arm; unported arms render an empty body for now. */ +const chartNode = ( + model: Model, + h: HtmlBuilder, + theme: ThemeColors, + slot: string, + arm: ChartsLabArm, +): ReadonlyArray => { + const chart = model.charts[slot] + if (chart === undefined) return [] + const common = { slotId: slot, model: chart, toParentMessage: toChartMessage(slot) } + const chartId = slot.replace(/[:.]/g, "-") + if (arm === "pie-production") { + const viewInputs = { + chartId, + data: pieSampleData, + theme, + legend: "right", + tooltip: "visible", + className: PRODUCTION_CHART_CLASS, + } as const + return [h.submodel({ ...common, view: PieChart.view, viewInputs })] + } + if (arm === "pie-tanstack" || arm === "pie-solid-tanstack") { + const viewInputs = { + chartId, + rows: pieRows, + theme, + donut: arm === "pie-tanstack", + className: "h-full w-full", + } + return [h.submodel({ ...common, view: pieSpikeView, viewInputs })] + } + if (arm === "histogram-production") { + const viewInputs = { + chartId, + data: histogramProductionRows, + theme, + tooltip: "visible", + className: PRODUCTION_CHART_CLASS, + } as const + return [h.submodel({ ...common, view: HistogramChart.view, viewInputs })] + } + if (arm === "heatmap-production") { + const viewInputs = { chartId, data: heatmapSampleData, theme, tooltip: "visible", className: PRODUCTION_CHART_CLASS } as const + return [h.submodel({ ...common, view: HeatmapChart.view, viewInputs })] + } + const timeseries = productionTimeseriesOf(arm) + if (timeseries !== undefined) { + const base = { chartId, data: timeseries.rows, theme, nowMs: model.nowMs, showTooltip: true, className: PRODUCTION_CHART_CLASS } + return timeseries.kind === "bar" + ? [h.submodel({ ...common, view: BarChart.view, viewInputs: { ...base, stacked: true } })] + : [h.submodel({ ...common, view: AreaChart.view, viewInputs: { ...base, mark: timeseries.kind } })] + } + if (arm === "pie-legend-tanstack" || arm === "pie-solid-legend-tanstack") { + const viewInputs = { chartId, rows: pieRows, theme, donut: arm === "pie-legend-tanstack", className: "h-full w-full" } + return [h.submodel({ ...common, view: pieLegendSpikeView, viewInputs })] + } + const spike = SPIKE_ARMS.get(arm) + if (spike !== undefined) { + const legend = spike.legend + const viewInputs = { + chartId, + arm, + theme, + tokens: spike.tokens, + ariaLabel: spike.ariaLabel, + className: "h-full w-full", + ...(spike.footer === undefined ? undefined : { footer: spike.footer }), + ...(legend === undefined ? undefined : { legend: { label: legend.label, series: legend.series(theme) } }), + } + return [h.submodel({ ...common, view: spikeView, viewInputs })] + } + if (arm === "histogram-tanstack") { + const viewInputs = { chartId, values: histogramSpikeValues, theme, className: "h-full w-full" } + return [h.submodel({ ...common, view: histogramSpikeView, viewInputs })] + } + return [] +} + +const chartArm = ( + model: Model, + h: HtmlBuilder, + theme: ThemeColors, + position: string, + arm: ChartsLabArm, +): Html => + h.div( + [h.DataAttribute("chart-arm", arm), h.Class("flex flex-col rounded-xl border bg-card")], + [ + h.div( + [h.Class("border-b px-4 py-2 font-medium text-muted-foreground text-xs")], + [ARM_TITLES[arm]], + ), + h.div( + [h.Class("h-[320px] min-h-0 overflow-hidden p-2")], + chartNode(model, h, theme, slotOf(position, arm), arm), + ), + ], + ) + +const sectionRow = ( + model: Model, + h: HtmlBuilder, + theme: ThemeColors, + position: string, + arms: ReadonlyArray, +): Html => { + const [only] = arms + if (arms.length === 1 && only !== undefined) return chartArm(model, h, theme, position, only) + return h.div( + [h.Class("grid grid-cols-1 gap-4 lg:grid-cols-2")], + arms.map((arm) => chartArm(model, h, theme, position, arm)), + ) +} + +const section = (h: HtmlBuilder, title: string, children: ReadonlyArray): Html => + h.section( + [h.Class("mb-8")], + [ + eyebrow(h, { variant: "label", as: "h2", className: "mb-3" }, [title]), + h.div([h.Class("grid gap-4")], children), + ], + ) + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +const page = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.DataAttribute("testid", "charts-lab"), + h.DataAttribute("charts-lab-renderer", "tanstack-canvas"), + h.DataAttribute("charts-lab-arm", "gallery"), + h.Class("min-h-screen bg-background p-6 text-foreground"), + h.OnMount(Mount.mapMessage(ResolveThemeColors({ tokens: THEME_TOKENS }), toThemeMessage)), + ], + Option.match(readyTheme(model), { + onNone: () => [], + onSome: (theme) => gallery(model, h, theme), + }), + ) + +/** `MetricsGrid` handed no rows: each card says so over bare axes (the service-detail grid, empty). */ +const emptyMetricsGrid = (h: HtmlBuilder): Html => + h.div( + [h.DataAttribute("metrics-grid", ""), h.Class("grid grid-cols-1 md:grid-cols-2 gap-3")], + ["Latency", "Throughput", "Apdex", "Error Rate"].map((title) => + h.div( + [h.Class("h-[240px] md:h-[280px]")], + [ + widgetShell( + h, + { widgetId: title, hasGrip: false, title, isEdit: false, menu: null, isMenuOpen: false, contentClassName: undefined }, + [chartEmpty(h, { hint: "Try a wider time range" }, ["No data in this window"])], + ), + ], + ), + ), + ) + +/** The page itself, painted in one go once the theme is read and the chart library is loaded. */ +const gallery = (model: Model, h: HtmlBuilder, theme: ThemeColors): ReadonlyArray => [ + h.header( + [h.Class("mb-6")], + [ + h.h1([h.Class("font-semibold text-lg")], ["TanStack charts"]), + h.p( + [h.Class("text-muted-foreground text-sm")], + [ + "Production on the left, TanStack on the right, same rows.", + inlineCode(h, { className: "ml-2 px-1.5 py-0.5 text-xs" }, [ + "?renderer=tanstack-svg|tanstack-canvas&arm=-", + ]), + ], + ), + ], + ), + ...SECTIONS.map((entry, sectionIndex) => + section( + h, + entry.title, + entry.rows.map((arms, rowIndex) => sectionRow(model, h, theme, `${sectionIndex}.${rowIndex}`, arms)), + ), + ), + section(h, "Empty window — MetricsGrid", [h.div([h.DataAttribute("testid", "charts-lab-empty")], [emptyMetricsGrid(h)])]), +] + +const readyTheme = (model: Model): Option.Option => + Option.filter(model.maybeTheme, () => isChartRuntimeReady(model.chartRuntime) && model.nowMs > 0) + +const toThemeMessage = (message: ThemeMessage): Message => + ThemeMessage.match(message, { + ResolvedThemeColors: ({ colors }) => Message.ResolvedThemeColors({ colors }), + }) + +export const lab = defineLab("TanStack charts", { Model, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/chat.ts b/apps/web-foldkit/src/lab/entries/chat.ts new file mode 100644 index 0000000000..46bbf86acd --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/chat.ts @@ -0,0 +1,226 @@ +import type { AutoContext } from "@maple/web/components/chat/auto-contexts" +import { cn } from "@maple/ui/lib/utils" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as ConversationMessage } from "../../page/chat/conversation/message" +import { Model as ConversationModel } from "../../page/chat/conversation/model" +import * as Conversation from "../../page/chat/conversation/update" +import { themeMount, transcriptHostOf } from "../../page/chat/conversation/view" +import { LoadKnownServices } from "../../page/chat/page/command" +import { approvalCard } from "../../page/chat/kit/view-cards" +import { composer, contextChips } from "../../page/chat/kit/view-composer" +import { MountMessageScroller } from "../../page/chat/kit/scroller" +import { transcript } from "../../page/chat/kit/view-transcript" +import type { Resources } from "../../platform/resources" +import * as Toast from "../../ui/toast" +import { viewerFlags } from "../../ui/logs/viewer" +import { buildChatLabMessages } from "../chat-fixture" +import { defineLabWithFlags } from "../define" +import { foldLabToasts, initLabToasts, labRoot } from "../toasts" + +// `/lab/chat` (apps/web `lab/chat-lab.tsx`): the real transcript over a fixture thread, at the +// page, sheet and narrow widths, idle or streaming, with the composer's context tray. + +const WIDTHS = [ + { label: "Page (768px)", value: 768 }, + { label: "Sheet (620px)", value: 620 }, + { label: "Narrow (420px)", value: 420 }, +] as const + +/** The composer's tray: a long subject and a subject-less chip. */ +const LAB_CONTEXTS: ReadonlyArray = [ + { kind: "service_map", id: "service_map" }, + { kind: "service", id: "service:checkout", serviceName: "checkout-api-eu-west" }, + { kind: "trace", id: "trace:9f2c", traceId: "9f2c41b8ad0e4c1f" }, + { kind: "container", id: "container:ingest", containerName: "ingest-gateway" }, +] + +const Flags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String, locale: Schema.String }) +type Flags = typeof Flags.Type + +const Model = Schema.Struct({ + conversation: ConversationModel, + width: Schema.Number, + isLoading: Schema.Boolean, + dismissedContexts: Schema.Array(Schema.String), + knownServices: Schema.Array(Schema.String), + nowMs: Schema.Number, + timeZone: Schema.String, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + ClickedWidth: { width: Schema.Number }, + ToggledStreaming: {}, + DismissedContext: { id: Schema.String }, + ClickedLabApprove: { toolCallId: Schema.String }, + ClickedLabDeny: { toolCallId: Schema.String }, + GotConversationMessage: { message: ConversationMessage }, + CompletedLoadKnownServices: { services: Schema.Array(Schema.String) }, + CompletedMountScroller: {}, + GotToastMessage: { message: Toast.Message }, +}) +type Message = typeof Message.Type + +type Return = Readonly<{ model: Model; commands?: ReadonlyArray> }> + +const liftConversation = (model: Model, step: Conversation.Return): Return => ({ + model: { ...model, conversation: step.model }, + commands: Command.mapMessages(step.commands ?? [], (message) => Message.GotConversationMessage({ message })), +}) + +const NO_CONTEXT: Conversation.SendContext = { preamble: "" } + +const init = (flags: Flags): Return => { + const started = Conversation.withMessages(Conversation.init("lab", "org_lab:lab"), buildChatLabMessages(flags.nowMs)) + // The fixture is the history: the conversation's own history read never runs here. + const lifted = liftConversation( + { + conversation: started.model, + width: 768, + isLoading: false, + dismissedContexts: [], + knownServices: [], + nowMs: flags.nowMs, + timeZone: flags.timeZone, + toasts: initLabToasts(), + }, + { ...started, commands: (started.commands ?? []).filter((command) => command.name !== "LoadChatHistory") }, + ) + return { ...lifted, commands: [...(lifted.commands ?? []), LoadKnownServices()] } +} + +const resolve = (model: Model, toolCallId: string, outcome: "applied" | "denied"): Return => ({ + model: { + ...model, + conversation: { + ...model.conversation, + resolvedApprovals: { ...model.conversation.resolvedApprovals, [toolCallId]: outcome }, + }, + }, +}) + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const update = (model: Model, message: Message): Return => + Message.match(message, { + ClickedWidth: ({ width }) => ({ model: { ...model, width } }), + // The transcript reads `isLoading`; the composer shows the streaming submit with nothing to stop. + ToggledStreaming: () => { + const isLoading = !model.isLoading + return { + model: { ...model, isLoading, conversation: { ...model.conversation, status: isLoading ? "streaming" : "ready" } }, + } + }, + DismissedContext: ({ id }) => ({ model: { ...model, dismissedContexts: [...model.dismissedContexts, id] } }), + ClickedLabApprove: ({ toolCallId }) => resolve(model, toolCallId, "applied"), + ClickedLabDeny: ({ toolCallId }) => resolve(model, toolCallId, "denied"), + GotConversationMessage: ({ message: child }) => + // `PromptInput onSubmit={() => {}}`: the lab's composer sends nothing. + child._tag === "SubmittedComposer" + ? { model: { ...model, conversation: { ...model.conversation, draft: "" } } } + : liftConversation(model, Conversation.update(model.conversation, child, NO_CONTEXT)), + CompletedLoadKnownServices: ({ services }) => ({ model: { ...model, knownServices: services } }), + CompletedMountScroller: () => ({ model }), + GotToastMessage: ({ message: child }) => foldToasts(model, child), + }) + +type H = HtmlBuilder + +const toConversation = (message: ConversationMessage) => Message.GotConversationMessage({ message }) + +const toolbarButton = (h: H, label: string, isActive: boolean, onClick: Message): Html => + h.button( + [ + h.Class(cn("rounded-md border px-2 py-1 transition-colors hover:bg-accent", isActive && "bg-accent")), + h.Type("button"), + h.OnClick(onClick), + ], + [label], + ) + +const labView = (model: Model, h: H): Html => { + const conversation = model.conversation + const host = transcriptHostOf(h, conversation, { + toMessage: toConversation, + timeZone: model.timeZone, + textareaId: "lab-chat-composer", + placeholder: "Ask about your system…", + emptyState: () => h.p([h.Class("text-sm text-muted-foreground")], ["Nothing here yet."]), + chips: [], + scrollerMount: MountMessageScroller({ focusMessageId: null }), + knownServices: new Set(model.knownServices), + nowMs: model.nowMs, + ownCard: (part) => + part.state !== "proposed" + ? null + : approvalCard(h, { + toolName: part.toolName, + input: part.input, + resolved: conversation.resolvedApprovals[part.toolCallId], + isRawOpen: conversation.openPanels.includes(`raw:${part.toolCallId}`), + isDeciding: false, + onToggleRaw: toConversation(ConversationMessage.ToggledPanel({ id: `raw:${part.toolCallId}` })), + onApprove: Message.ClickedLabApprove({ toolCallId: part.toolCallId }), + onDeny: Message.ClickedLabDeny({ toolCallId: part.toolCallId }), + }), + }) + const contexts = LAB_CONTEXTS.filter((context) => !model.dismissedContexts.includes(context.id)) + return h.div( + [h.Class("flex h-screen flex-col bg-background"), h.OnMount(themeMount(toConversation))], + [ + h.div( + [h.Class("flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2 text-xs")], + [ + h.span([h.Class("font-semibold")], ["Chat transcript"]), + ...WIDTHS.map((option) => + toolbarButton(h, option.label, model.width === option.value, Message.ClickedWidth({ width: option.value })), + ), + toolbarButton(h, model.isLoading ? "Streaming" : "Idle", model.isLoading, Message.ToggledStreaming()), + ], + ), + h.div( + [h.Class("flex min-h-0 flex-1 justify-center")], + [ + h.div( + [h.Class("flex min-h-0 flex-col border-x"), h.Style({ width: `${model.width}px` })], + [ + transcript(h, { ...host, isLoading: model.isLoading }, conversation.messages, () => + h.p([h.Class("text-sm text-muted-foreground")], ["Nothing here yet."]), + ), + h.div( + [h.Class("shrink-0 px-4 pb-4")], + [ + composer(h, { + textareaId: "lab-chat-composer", + draft: conversation.draft, + placeholder: "Ask about your system…", + status: model.isLoading ? "streaming" : "ready", + onStop: undefined, + isDisabled: false, + onInput: (text) => toConversation(ConversationMessage.ChangedDraft({ text })), + onSubmit: toConversation(ConversationMessage.SubmittedComposer()), + chips: contextChips(h, contexts, (id) => Message.DismissedContext({ id })), + }), + ], + ), + ], + ), + ], + ), + ], + ) +} + +export const lab = defineLabWithFlags("Chat transcript", { + Model, + Flags, + flags: Effect.zipWith(viewerFlags, Clock.currentTimeMillis, (viewer, nowMs) => ({ ...viewer, nowMs })), + init, + update, + view: (model, h) => labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), labView(model, h)), +}) + diff --git a/apps/web-foldkit/src/lab/entries/cloudflare.ts b/apps/web-foldkit/src/lab/entries/cloudflare.ts new file mode 100644 index 0000000000..d9649c75e0 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/cloudflare.ts @@ -0,0 +1,19 @@ +import * as Page from "../cloudflare-lab/model" +import { flags, init, update } from "../cloudflare-lab/update" +import { breadcrumbs, toolbar, view } from "../cloudflare-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/cloudflare` (apps/web `lab/cloudflare-lab.tsx`): the list, zone and Worker pages over fixtures. + +export const lab = defineShellLab("Cloudflare", { + Model: Page.Model, + Message: Page.Message, + Flags: Page.Flags, + flags, + init, + update, + view, + before: toolbar, + currentPath: "/lab/cloudflare", + breadcrumbs, +}) diff --git a/apps/web-foldkit/src/lab/entries/code-review-repos.ts b/apps/web-foldkit/src/lab/entries/code-review-repos.ts new file mode 100644 index 0000000000..1877af0ee4 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/code-review-repos.ts @@ -0,0 +1,114 @@ +import { GithubRepoSummary, VcsRepositoryId } from "@maple/domain/http" +import { Schema } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { + initRepoList, + pinOnce, + RepoFilter, + RepoListState, + repositoryList, +} from "../../page/code-review/settings/repositories" +import { defineLab } from "../define" + +// `/lab/code-review-repos` (apps/web/src/routes/lab/code-review-repos.tsx): the repository picker +// over 246 fixture repositories. +// TODO(lab-toggle): legacy's switch calls `githubSetPrReview` for these fixture ids; here it flips +// locally (the fixture repositories exist nowhere to write to). + +// copied: the fixture from the lab route (a React module). +const NAMES = [ + "web", + "api", + "mobile-ios", + "mobile-android", + "worker-proxy", + "worker-proxy-go", + "k8s-manifests", + "ios-quickstart", + "objc-quickstart", + "checkout", + "dashboard", + "sdk-js", + "sdk-python", + "sdk-go", + "docs", + "infra", + "terraform-modules", + "billing-service", + "analytics-pipeline", + "marketing-site", +] +const decodeId = Schema.decodeSync(VcsRepositoryId) +const REPOS = Array.from({ length: 246 }, (_, i) => { + const base = NAMES[i % NAMES.length] ?? "repo" + const name = i < NAMES.length ? base : `${base}-${Math.floor(i / NAMES.length)}` + return new GithubRepoSummary({ + id: decodeId(`00000000-0000-4000-8000-${String(i).padStart(12, "0")}`), + fullName: `acme/${name}`, + htmlUrl: `https://github.com/acme/${name}`, + isPrivate: i % 7 !== 0, + status: "active", + syncStatus: "ready", + lastSyncedAt: null, + lastSyncError: null, + trackedBranch: null, + branches: [], + prReviewEnabled: i === 8 || i === 9, + }) +}) + +const Model = Schema.Struct({ list: RepoListState, toggled: Schema.Array(Schema.String) }) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + ChangedSearch: { value: Schema.String }, + PickedFilter: { filter: RepoFilter }, + ClickedShowAll: {}, + ToggledRepo: { id: Schema.String }, + ClickedConfigure: {}, +}) +type Message = typeof Message.Type + +const isEnabled = (model: Model, repo: GithubRepoSummary) => + repo.prReviewEnabled !== model.toggled.includes(repo.id) + +const update = (model: Model, message: Message): Model => + Message.match(message, { + ChangedSearch: ({ value }) => ({ ...model, list: { ...model.list, search: value } }), + PickedFilter: ({ filter }) => ({ ...model, list: { ...model.list, filter } }), + ClickedShowAll: () => ({ ...model, list: { ...model.list, search: "", filter: "all" } }), + ToggledRepo: ({ id }) => ({ + ...model, + toggled: model.toggled.includes(id) + ? model.toggled.filter((other) => other !== id) + : [...model.toggled, id], + }), + ClickedConfigure: () => model, + }) + +const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("mx-auto max-w-4xl p-8")], + [ + repositoryList(h, { + repositories: REPOS, + state: model.list, + isAdmin: true, + busy: [], + isEnabled: (repo) => isEnabled(model, repo), + onSearch: (value) => Message.ChangedSearch({ value }), + onFilter: (filter) => Message.PickedFilter({ filter }), + onShowAll: Message.ClickedShowAll(), + onToggle: (repo) => Message.ToggledRepo({ id: repo.id }), + onConfigure: () => Message.ClickedConfigure(), + }), + ], + ) + +export const lab = defineLab("Code review repositories", { + Model, + init: () => ({ model: { list: pinOnce(initRepoList(), REPOS), toggled: [] } }), + update: (model, message) => ({ model: update(model, message) }), + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/data-probe.ts b/apps/web-foldkit/src/lab/entries/data-probe.ts new file mode 100644 index 0000000000..58bd8759a0 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/data-probe.ts @@ -0,0 +1,12 @@ +import { defineLab } from "../define" +import { init, Message, Model, subscriptions, update, view } from "../data-probe" + +/** S6: warehouse facets through the execute batcher and alert rules through a liveQuery. */ +export const lab = defineLab("Data probe", { + Model, + init, + update, + view, + subscriptions, + devTools: { Message }, +}) diff --git a/apps/web-foldkit/src/lab/entries/errors.ts b/apps/web-foldkit/src/lab/entries/errors.ts new file mode 100644 index 0000000000..92f5584aeb --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/errors.ts @@ -0,0 +1,18 @@ +import * as Page from "../errors-lab/model" +import { ErrorsLabFlags, errorsLabFlags, init, update } from "../errors-lab/update" +import { view } from "../errors-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/errors` (apps/web `lab/errors-lab.tsx`): the errors hub over fixtures, inside the real shell. + +export const lab = defineShellLab("Errors", { + Model: Page.Model, + Message: Page.Message, + Flags: ErrorsLabFlags, + flags: errorsLabFlags, + init: (flags) => ({ model: init(flags) }), + update, + view, + currentPath: "/lab/errors", + breadcrumbs: [{ label: "Lab" }, { label: "Errors" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/flow.ts b/apps/web-foldkit/src/lab/entries/flow.ts new file mode 100644 index 0000000000..df9a6ed5f3 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/flow.ts @@ -0,0 +1,20 @@ +import { Effect, Schema } from "effect" +import * as TraceFlow from "../../service-map/trace-flow/trace-flow" +import { FLOW_LAB_GRAPH, pageBody } from "../flow-lab" +import { defineShellLab } from "../shell-lab" + +// `/lab/flow` (apps/web `lab/flow-lab.tsx`): the flow view lab inside the real shell. + +const updateFlow = TraceFlow.update(FLOW_LAB_GRAPH) + +export const lab = defineShellLab("Trace flow", { + Model: TraceFlow.Model, + Message: TraceFlow.Message, + Flags: Schema.Struct({}), + flags: Effect.succeed({}), + init: () => ({ model: TraceFlow.init() }), + update: (model, message) => ({ model: updateFlow(model, message) }), + view: (model, h) => pageBody(h, model, (message) => message), + currentPath: "/lab/flow", + breadcrumbs: [{ label: "Flow Lab" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/gcp-connect.ts b/apps/web-foldkit/src/lab/entries/gcp-connect.ts new file mode 100644 index 0000000000..0ff8e9722c --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/gcp-connect.ts @@ -0,0 +1,17 @@ +import * as Page from "../gcp-connect-lab/model" +import { init, update } from "../gcp-connect-lab/update" +import { view } from "../gcp-connect-lab/view" +import { defineLabWithFlags } from "../define" +import { GcpLabFlags, gcpLabFlags } from "../gcp-host" + +// `/lab/gcp-connect` (apps/web `lab/gcp-connect-lab.tsx`): the connect dialog's steps and run +// states, bare (legacy renders it outside `DashboardLayout`). + +export const lab = defineLabWithFlags("Google Cloud connect flow", { + Model: Page.Model, + Flags: GcpLabFlags, + flags: gcpLabFlags, + init, + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/gcp-connections.ts b/apps/web-foldkit/src/lab/entries/gcp-connections.ts new file mode 100644 index 0000000000..c2c4950399 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/gcp-connections.ts @@ -0,0 +1,17 @@ +import * as Page from "../gcp-connections-lab" +import { GcpLabFlags, gcpLabFlags } from "../gcp-host" +import { defineShellLab } from "../shell-lab" + +// `/lab/gcp-connections` (apps/web `lab/gcp-connections-lab.tsx`): every Google Cloud connection state. + +export const lab = defineShellLab("Google Cloud connections", { + Model: Page.Model, + Message: Page.Message, + Flags: GcpLabFlags, + flags: gcpLabFlags, + init: Page.init, + update: Page.update, + view: Page.view, + currentPath: "/lab/gcp-connections", + breadcrumbs: [{ label: "Lab" }, { label: "Google Cloud connections" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/gcp-dashboard.ts b/apps/web-foldkit/src/lab/entries/gcp-dashboard.ts new file mode 100644 index 0000000000..66c7fe65f2 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/gcp-dashboard.ts @@ -0,0 +1,22 @@ +import { Effect, Schema } from "effect" +import { Message } from "../../page/dashboards/view/message" +import { Model } from "../../page/dashboards/view/model" +import { DASHBOARD } from "../gcp-dashboard-lab/fixture" +import { init, update } from "../gcp-dashboard-lab/update" +import { view } from "../gcp-dashboard-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/gcp-dashboard` (apps/web `lab/gcp-dashboard-lab.tsx`): the Google Cloud template as a +// board inside the real shell, with no API. + +export const lab = defineShellLab("Google Cloud dashboard", { + Model, + Message, + Flags: Schema.Struct({}), + flags: Effect.succeed({}), + init, + update, + view, + currentPath: "/lab/gcp-dashboard", + breadcrumbs: [{ label: "Lab", href: "/lab" }, { label: DASHBOARD.name }], +}) diff --git a/apps/web-foldkit/src/lab/entries/gcp-infra.ts b/apps/web-foldkit/src/lab/entries/gcp-infra.ts new file mode 100644 index 0000000000..00346134c0 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/gcp-infra.ts @@ -0,0 +1,21 @@ +import { Effect, Schema } from "effect" +import { Message, Model } from "../gcp-infra-lab/model" +import { init, update } from "../gcp-infra-lab/update" +import { overlay, view } from "../gcp-infra-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/gcp-infra` (apps/web `lab/gcp-infra-lab.tsx`): the redesigned Google Cloud list, whole, +// over fixtures that reproduce the Paper artboards, inside the real shell. + +export const lab = defineShellLab("Google Cloud", { + Model, + Message, + Flags: Schema.Struct({}), + flags: Effect.succeed({}), + init: () => init(), + update, + view, + before: overlay, + currentPath: "/lab/gcp-infra", + breadcrumbs: [{ label: "Infrastructure" }, { label: "Google Cloud" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/gcp-workload.ts b/apps/web-foldkit/src/lab/entries/gcp-workload.ts new file mode 100644 index 0000000000..43e51cbdad --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/gcp-workload.ts @@ -0,0 +1,20 @@ +import { GcpWorkloadLabFlags, Message, Model } from "../gcp-workload-lab/model" +import { gcpWorkloadLabFlags, init, update } from "../gcp-workload-lab/update" +import { breadcrumbs, overlay, view } from "../gcp-workload-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/gcp-workload` (apps/web `lab/gcp-workload-lab.tsx`): the Google Cloud workload page, +// whole, over fixture series: a Cloud Run burst and a Cloud SQL instance, inside the real shell. + +export const lab = defineShellLab("Google Cloud workload", { + Model, + Message, + Flags: GcpWorkloadLabFlags, + flags: gcpWorkloadLabFlags, + init, + update, + view, + before: overlay, + currentPath: "/lab/gcp-workload", + breadcrumbs, +}) diff --git a/apps/web-foldkit/src/lab/entries/gcp.ts b/apps/web-foldkit/src/lab/entries/gcp.ts new file mode 100644 index 0000000000..c8ba293fde --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/gcp.ts @@ -0,0 +1,21 @@ +import { Effect, Schema } from "effect" +import * as Page from "../gcp-filter-lab/model" +import { init, update } from "../gcp-filter-lab/update" +import { view } from "../gcp-filter-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/gcp` (apps/web `lab/gcp-lab.tsx`): the Google Cloud log filter field in each of its states. + +const NoFlags = Schema.Struct({}) + +export const lab = defineShellLab("Google Cloud log filter", { + Model: Page.Model, + Message: Page.Message, + Flags: NoFlags, + flags: Effect.succeed({}), + init: () => ({ model: init() }), + update, + view, + currentPath: "/lab/gcp", + breadcrumbs: [{ label: "Lab" }, { label: "Google Cloud log filter" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/index.ts b/apps/web-foldkit/src/lab/entries/index.ts new file mode 100644 index 0000000000..b443000d34 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/index.ts @@ -0,0 +1,95 @@ +import { LAB_ENTRIES, type LabEntry as LegacyLabEntry } from "@maple/web/lab/registry" +import type { Html, HtmlBuilder } from "foldkit/html" +import { dashboardPage } from "../../page/kit/dashboard-page" +import { eyebrow } from "../../ui/eyebrow" +import { defineStaticShellLab } from "../static-shell-lab" + +// `/lab` (apps/web `lab/lab-index.tsx`): every lab and bench surface, read from apps/web's registry. + +const SECTIONS: ReadonlyArray<{ kind: LegacyLabEntry["kind"]; title: string; blurb: string }> = [ + { + kind: "lab", + title: "Labs", + blurb: "Component galleries over fixture data. Open one, change the component, watch it re-render.", + }, + { + kind: "bench", + title: "Benches", + blurb: "Synthetic perf harnesses. The Playwright gates in apps/web/perf drive these URLs; open them in a foreground tab when measuring by hand.", + }, +] + +const entryCard = (h: HtmlBuilder, entry: LegacyLabEntry): Html => + h.li( + [], + [ + h.a( + [ + h.Href(entry.path), + h.Class( + "flex h-full flex-col gap-1 rounded-md border bg-card px-3 py-2.5 transition-colors hover:bg-accent", + ), + ], + [ + h.span( + [h.Class("flex items-baseline justify-between gap-2")], + [ + h.span([h.Class("text-sm font-medium")], [entry.title]), + h.span([h.Class("font-mono text-2xs text-muted-foreground")], [entry.path]), + ], + ), + h.span([h.Class("text-xs text-muted-foreground")], [entry.description]), + ...(entry.session === "required" + ? [ + h.span( + [ + h.Class( + "mt-1 self-start rounded-sm border px-1.5 py-0.5 text-3xs uppercase tracking-wide text-muted-foreground", + ), + ], + ["Session required"], + ), + ] + : []), + ], + ), + ], + ) + +const page = (h: HtmlBuilder): Html => + dashboardPage(h, {}, [ + h.div( + [h.Class("flex flex-col")], + [ + h.div( + [h.Class("border-b px-4 py-3")], + [ + h.h1([h.Class("text-sm font-semibold")], ["Lab"]), + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + "Dev-only surfaces. Everything here is a 404 in production and stays out of the startup bundle.", + ], + ), + ], + ), + ...SECTIONS.map((section) => + h.section( + [h.Key(section.kind), h.Class("border-b px-4 py-4")], + [ + eyebrow(h, { variant: "label", as: "h2" }, [section.title]), + h.p([h.Class("mt-1 text-xs text-muted-foreground")], [section.blurb]), + h.ul( + [h.Class("mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3")], + LAB_ENTRIES.filter((entry) => entry.kind === section.kind).map((entry) => + entryCard(h, entry), + ), + ), + ], + ), + ), + ], + ), + ]) + +export const lab = defineStaticShellLab("Lab", { currentPath: "/lab", breadcrumbs: [{ label: "Lab" }], page }) diff --git a/apps/web-foldkit/src/lab/entries/infra.ts b/apps/web-foldkit/src/lab/entries/infra.ts new file mode 100644 index 0000000000..b4ea91889b --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/infra.ts @@ -0,0 +1,7 @@ +import { defineLabWithFlags } from "../define" +import { Flags, flags, init, Model, update } from "../infra-lab/update" +import { view } from "../infra-lab/view" + +// `/lab/infra` (apps/web `lab/infra-lab.tsx`): the infra overview, hosts and Railway lists over fixtures. + +export const lab = defineLabWithFlags("Infrastructure", { Model, Flags, flags, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/kit/buttons.ts b/apps/web-foldkit/src/lab/entries/kit/buttons.ts new file mode 100644 index 0000000000..97ebb05884 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/kit/buttons.ts @@ -0,0 +1,382 @@ +import { Option, Schema } from "effect" +import { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" +import { button } from "../../../ui/button" +import * as CopyButton from "../../../ui/copy-button" +import { badge, kbd, kbdGroup } from "../../../ui/display" +import { filterChip } from "../../../ui/filter-chip" +import { icon } from "../../../ui/icon" +import { iconButton } from "../../../ui/icon-button" +import { inlineCode } from "../../../ui/inline-code" +import { refreshButton } from "../../../ui/refresh-button" +import { statusDot } from "../../../ui/status-dot" +import * as Toggle from "../../../ui/toggle" +import * as ToggleGroup from "../../../ui/toggle-group" +import { defineLab } from "../../define" +import { kitPage, kitRow, kitSection } from "../../kit/kit-layout" + +// Port of `/lab/kit/buttons` (apps/web/src/lab/kit/buttons.tsx). + +const VARIANTS = [ + "default", + "secondary", + "outline", + "ghost", + "destructive", + "destructive-outline", + "link", +] as const +const TEXT_SIZES = ["xs", "sm", "default", "lg", "xl"] as const +const ICON_SIZES = ["icon-2xs", "icon-xs", "icon-sm", "icon", "icon-lg", "icon-xl"] as const +const BADGE_VARIANTS = [ + "default", + "secondary", + "outline", + "muted", + "meta", + "destructive", + "crit", + "warn", + "ok", + "info", + "done", + "tag", +] as const +const BADGE_SIZES = ["xs", "sm", "default", "lg"] as const +const TONES = ["crit", "warn", "ok", "info", "done", "neutral", "live"] as const + +const TOGGLES = ["wrap", "wrap-pressed", "follow", "follow-pressed", "xs", "sm", "lg", "disabled"] as const +const FILTER_CHIPS = ["errors", "warnings", "ok"] as const +const COPY_BUTTONS = ["trace-id", "ingest-key"] as const + +// MODEL + +const Model = Schema.Struct({ + toggles: Schema.Record(Schema.String, Toggle.Model), + toggleGroups: Schema.Record(Schema.String, ToggleGroup.Model), + filterChips: Schema.Record(Schema.String, Schema.Boolean), + copyButtons: Schema.Record(Schema.String, CopyButton.Model), +}) +type Model = typeof Model.Type + +const init = (): Update.Return => ({ + model: { + toggles: Object.fromEntries(TOGGLES.map((id) => [id, Toggle.init(id.endsWith("-pressed"))])), + toggleGroups: { + window: ToggleGroup.init({ label: "Window", values: ["1h"] }), + layout: ToggleGroup.init({ label: "Layout", values: ["list"] }), + signals: ToggleGroup.init({ label: "Signals", isMultiple: true, values: ["logs", "traces"] }), + }, + filterChips: { errors: true, warnings: false, ok: true }, + copyButtons: Object.fromEntries(COPY_BUTTONS.map((id) => [id, CopyButton.init()])), + }, +}) + +// MESSAGE + +const Message = defineMessageUnion({ + GotToggleMessage: { id: Schema.String, message: Toggle.Message }, + GotToggleGroupMessage: { id: Schema.String, message: ToggleGroup.Message }, + GotCopyButtonMessage: { id: Schema.String, message: CopyButton.Message }, + ClickedFilterChip: { key: Schema.String }, + ClickedRefresh: {}, +}) +type Message = typeof Message.Type + +// UPDATE + +const foldToggle = Update.foldChildAt({ + update: Toggle.update, + readAt: (model: Model, id: string) => Option.fromNullishOr(model.toggles[id]), + writeAt: (model, id, next) => modifyFields(model, { toggles: (all) => ({ ...all, [id]: next }) }), + toParentMessage: (id, message) => Message.GotToggleMessage({ id, message }), +}) + +const foldToggleGroup = Update.foldChildAt({ + update: ToggleGroup.update, + readAt: (model: Model, id: string) => Option.fromNullishOr(model.toggleGroups[id]), + writeAt: (model, id, next) => modifyFields(model, { toggleGroups: (all) => ({ ...all, [id]: next }) }), + toParentMessage: (id, message) => Message.GotToggleGroupMessage({ id, message }), +}) + +const foldCopyButton = Update.foldChildAt({ + update: CopyButton.update, + readAt: (model: Model, id: string) => Option.fromNullishOr(model.copyButtons[id]), + writeAt: (model, id, next) => modifyFields(model, { copyButtons: (all) => ({ ...all, [id]: next }) }), + toParentMessage: (id, message) => Message.GotCopyButtonMessage({ id, message }), +}) + +const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotToggleMessage: ({ id, message }) => foldToggle(model, id, message), + GotToggleGroupMessage: ({ id, message }) => foldToggleGroup(model, id, message), + GotCopyButtonMessage: ({ id, message }) => foldCopyButton(model, id, message), + ClickedFilterChip: ({ key }) => ({ + model: modifyFields(model, { filterChips: (all) => ({ ...all, [key]: !(all[key] ?? false) }) }), + }), + ClickedRefresh: () => ({ model }), + }) + +// VIEW + +const buttonVariantRows = (h: HtmlBuilder): ReadonlyArray => + VARIANTS.map((variant) => + kitRow(h, variant, [ + ...TEXT_SIZES.map((size) => button(h, { variant, size }, [size])), + ...ICON_SIZES.map((size) => + button(h, { variant, size, attributes: [h.AriaLabel(`${variant} ${size}`)] }, [ + icon(h, "PlusIcon"), + ]), + ), + ]), + ) + +const buttonStates = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + button(h, {}, ["Save changes"]), + button(h, { variant: "outline" }, ["Cancel edit"]), + button(h, { isDisabled: true }, ["Disabled"]), + button(h, { variant: "outline", isDisabled: true }, ["Disabled outline"]), + button(h, { isLoading: true }, ["Loading"]), + button(h, { variant: "outline", isLoading: true }, ["Loading outline"]), + button(h, { variant: "outline" }, [icon(h, "PlusIcon"), " With icon"]), + ]) + +const iconButtons = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + iconButton(h, { label: "Add widget", triggerId: "base-ui-_r_1_" }, [icon(h, "PlusIcon")]), + iconButton(h, { label: "Delete row", variant: "outline", triggerId: "base-ui-_r_3_" }, [ + icon(h, "TrashIcon"), + ]), + iconButton(h, { label: "Notifications", size: "icon", triggerId: "base-ui-_r_5_" }, [ + icon(h, "BellIcon"), + ]), + iconButton(h, { label: "Filters disabled", isDisabled: true, triggerId: "base-ui-_r_7_" }, [ + icon(h, "FilterIcon"), + ]), + ]) + +const toggleOf = ( + model: Model, + h: HtmlBuilder, + id: (typeof TOGGLES)[number], + options: Omit, "isPressed" | "onClick">, + label: string, +): Html => + Toggle.toggle( + h, + { + ...options, + isPressed: model.toggles[id]?.isPressed ?? false, + onClick: Message.GotToggleMessage({ id, message: Toggle.Message.ClickedToggle() }), + }, + [label], + ) + +const toggles = (model: Model, h: HtmlBuilder): Html => + kitRow(h, undefined, [ + toggleOf(model, h, "wrap", { attributes: [h.AriaLabel("Wrap lines")] }, "Wrap"), + toggleOf(model, h, "wrap-pressed", { attributes: [h.AriaLabel("Wrap lines pressed")] }, "Wrap"), + toggleOf( + model, + h, + "follow", + { variant: "outline", attributes: [h.AriaLabel("Follow tail")] }, + "Follow", + ), + toggleOf( + model, + h, + "follow-pressed", + { variant: "outline", attributes: [h.AriaLabel("Follow tail pressed")] }, + "Follow", + ), + toggleOf(model, h, "xs", { size: "xs" }, "xs"), + toggleOf(model, h, "sm", { size: "sm" }, "sm"), + toggleOf(model, h, "lg", { size: "lg" }, "lg"), + toggleOf(model, h, "disabled", { isDisabled: true }, "Disabled"), + ]) + +const toggleGroupOf = ( + model: Model, + h: HtmlBuilder, + id: string, + config: Omit, "model" | "toParentMessage">, +): ReadonlyArray => { + const group = model.toggleGroups[id] + if (group === undefined) return [] + return [ + ToggleGroup.view(h, { + ...config, + model: group, + toParentMessage: (message) => Message.GotToggleGroupMessage({ id, message }), + }), + ] +} + +const items = (...labels: ReadonlyArray) => + labels.map(([value, label]) => ({ value, label })) + +const toggleGroups = (model: Model, h: HtmlBuilder): ReadonlyArray => [ + kitRow( + h, + "outline (track)", + toggleGroupOf(model, h, "window", { + variant: "outline", + items: items(["15m", "15m"], ["1h", "1h"], ["24h", "24h"], ["7d", "7d"]), + }), + ), + kitRow( + h, + "default", + toggleGroupOf(model, h, "layout", { items: items(["list", "List"], ["grid", "Grid"]) }), + ), + kitRow( + h, + "standalone, sm", + toggleGroupOf(model, h, "signals", { + variant: "outline", + isConnected: false, + size: "sm", + items: items(["logs", "Logs"], ["traces", "Traces"], ["metrics", "Metrics"]), + }), + ), +] + +const copyButtonOf = ( + model: Model, + h: HtmlBuilder, + id: (typeof COPY_BUTTONS)[number], + config: Omit, "model" | "toParentMessage">, +): ReadonlyArray => { + const copy = model.copyButtons[id] + if (copy === undefined) return [] + return [ + CopyButton.view(h, { + ...config, + model: copy, + toParentMessage: (message) => Message.GotCopyButtonMessage({ id, message }), + }), + ] +} + +const copyAndRefresh = (model: Model, h: HtmlBuilder): Html => + kitRow(h, undefined, [ + ...copyButtonOf(model, h, "trace-id", { + value: "4bf92f3577b34da6a3ce929d0e0e4736", + label: "trace id", + }), + ...copyButtonOf(model, h, "ingest-key", { + value: "maple_ingest_key", + label: "ingest key", + idleLabel: "Copy", + variant: "outline", + }), + refreshButton(h, { onRefresh: Message.ClickedRefresh() }), + refreshButton(h, { onRefresh: Message.ClickedRefresh(), isPending: true, label: "Refreshing" }), + refreshButton(h, { + onRefresh: Message.ClickedRefresh(), + isIconOnly: true, + label: "Reload data", + triggerId: "base-ui-_r_q_", + }), + ]) + +const filterChips = (model: Model, h: HtmlBuilder): Html => { + const isPressed = (key: (typeof FILTER_CHIPS)[number]) => model.filterChips[key] ?? false + const onClick = (key: (typeof FILTER_CHIPS)[number]) => Message.ClickedFilterChip({ key }) + return kitRow(h, undefined, [ + filterChip( + h, + { + isPressed: isPressed("errors"), + onClick: onClick("errors"), + tone: "crit", + hasDot: true, + count: "12", + }, + ["Errors"], + ), + filterChip( + h, + { + isPressed: isPressed("warnings"), + onClick: onClick("warnings"), + tone: "warn", + hasDot: true, + count: "4", + }, + ["Warnings"], + ), + filterChip(h, { isPressed: isPressed("ok"), onClick: onClick("ok"), tone: "ok", size: "xs" }, [ + "Healthy", + ]), + ]) +} + +const badges = (h: HtmlBuilder): ReadonlyArray => [ + kitRow( + h, + "variants", + BADGE_VARIANTS.map((variant) => badge(h, { variant }, [variant])), + ), + kitRow(h, "sizes, pill, mono", [ + ...BADGE_SIZES.map((size) => badge(h, { size, variant: "secondary" }, [size])), + badge(h, { pill: true, variant: "muted" }, ["pill"]), + badge(h, { mono: true, variant: "outline" }, ["12,480"]), + ]), +] + +const statusDots = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + ...TONES.map((tone) => + h.span([h.Class("flex items-center gap-1.5 text-xs")], [statusDot(h, { tone }), ` ${tone}`]), + ), + statusDot(h, { tone: "crit", size: "sm" }), + statusDot(h, { tone: "crit", size: "lg" }), + ]) + +const kbdAndInlineCode = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + kbd(h, undefined, ["K"]), + kbdGroup(h, undefined, [ + kbd(h, undefined, ["Cmd"]), + kbd(h, undefined, ["Shift"]), + kbd(h, undefined, ["P"]), + ]), + h.span( + [h.Class("text-sm")], + [ + "Set ", + inlineCode(h, {}, ["service.name"]), + " or run ", + inlineCode(h, { variant: "plain" }, ["maple login"]), + ".", + ], + ), + ]) + +const view = (model: Model, h: HtmlBuilder): Html => + kitPage( + h, + { + title: "Kit: buttons", + description: "Buttons, toggles, chips, badges and inline marks from @maple/ui.", + }, + [ + kitSection(h, "Button variants x sizes", buttonVariantRows(h)), + kitSection(h, "Button states", [buttonStates(h)]), + kitSection(h, "Icon button", [iconButtons(h)]), + kitSection(h, "Toggle", [toggles(model, h)]), + kitSection(h, "Toggle group", toggleGroups(model, h)), + kitSection(h, "Copy and refresh buttons", [copyAndRefresh(model, h)]), + kitSection(h, "Filter chip", [filterChips(model, h)]), + kitSection(h, "Badge", badges(h)), + kitSection(h, "Status dot", [statusDots(h)]), + kitSection(h, "Kbd and inline code", [kbdAndInlineCode(h)]), + ], + ) + +export const lab = defineLab("Kit: buttons", { Model, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/kit/content.ts b/apps/web-foldkit/src/lab/entries/kit/content.ts new file mode 100644 index 0000000000..89c0935a32 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/kit/content.ts @@ -0,0 +1,90 @@ +import { Schema } from "effect" +import { Command, type Update } from "foldkit" +import * as CalendarDates from "foldkit/calendar" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as Calendar from "../../../ui/calendar" +import * as CalendarView from "../../../ui/calendar-view" +import * as CopyButton from "../../../ui/copy-button" +import { defineLab } from "../../define" +import { contentSections } from "../../kit/content-sections" +import { kitPage } from "../../kit/kit-layout" + +// Port of `/lab/kit/content` (apps/web/src/lab/kit/content.tsx): text, identity, conversation +// and layout primitives. The calendar and the trace id copy button are live Submodels. + +// MODEL + +const Model = Schema.Struct({ calendar: Calendar.Model, traceIdCopy: CopyButton.Model }) +type Model = typeof Model.Type + +// `MONTH`, `SELECTED` and `TODAY` of the React page (fixed dates, not the clock). +const init = (): Update.Return => ({ + model: { + calendar: Calendar.init({ + month: CalendarDates.make(2026, 10, 1), + selected: CalendarDates.make(2026, 10, 14), + today: CalendarDates.make(2026, 10, 17), + }), + traceIdCopy: CopyButton.init(), + }, +}) + +// MESSAGE + +const Message = defineMessageUnion({ + GotCalendarMessage: { message: Calendar.Message }, + GotCopyMessage: { message: CopyButton.Message }, +}) +type Message = typeof Message.Type + +// UPDATE + +const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotCalendarMessage: ({ message }) => { + const next = Calendar.update(model.calendar, message) + return { + model: { ...model, calendar: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotCalendarMessage({ message: child }), + ), + } + }, + GotCopyMessage: ({ message }) => { + const next = CopyButton.update(model.traceIdCopy, message) + return { + model: { ...model, traceIdCopy: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotCopyMessage({ message: child }), + ), + } + }, + }) + +// VIEW + +const view = (model: Model, h: HtmlBuilder): Html => + kitPage( + h, + { + title: "Kit: content", + description: "Text, identity, conversation and layout primitives from @maple/ui.", + }, + [ + ...contentSections(h, { + copyStatus: model.traceIdCopy.status, + onCopy: (value) => + Message.GotCopyMessage({ message: CopyButton.Message.ClickedCopy({ text: value }) }), + calendar: h.submodel({ + slotId: "kit-content-calendar", + model: model.calendar, + toParentMessage: (message: Calendar.Message) => Message.GotCalendarMessage({ message }), + view: CalendarView.view, + viewInputs: { className: "w-fit rounded-md border" }, + }), + }), + ], + ) + +export const lab = defineLab("Kit: content", { Model, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/kit/display.ts b/apps/web-foldkit/src/lab/entries/kit/display.ts new file mode 100644 index 0000000000..3874702e0b --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/kit/display.ts @@ -0,0 +1,147 @@ +import { Schema } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as CopyButton from "../../../ui/copy-button" +import * as Tabs from "../../../ui/tabs" +import * as TabsView from "../../../ui/tabs-view" +import { defineLab } from "../../define" +import { separator } from "../../../ui/display" +import { alertSection, cardAndPanelSection, emptySection, itemSection } from "../../kit/display-sections" +import { + keyValueSection, + meterSection, + skeletonSection, + statSection, + tableSection, +} from "../../kit/display-sections-more" +import { kitPage, kitSection } from "../../kit/kit-layout" + +// Port of `/lab/kit/display` (apps/web/src/lab/kit/display.tsx): surfaces, data display and +// loading states. Both tab strips and the trace id copy button are live Submodels. + +const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" + +// MODEL + +const Model = Schema.Struct({ + defaultTabs: Tabs.Model, + underlineTabs: Tabs.Model, + traceIdCopy: CopyButton.Model, +}) +type Model = typeof Model.Type + +// Base UI ids in legacy render order: the first Tabs takes `_r_0_`..`_r_3_`, the second `_r_4_`..`_r_7_`. +const init = (): Update.Return => ({ + model: { + defaultTabs: Tabs.init({ idBase: 0, values: ["overview", "traces", "logs"], selected: "overview" }), + underlineTabs: Tabs.init({ idBase: 4, values: ["inbox", "rules", "routing"], selected: "rules" }), + traceIdCopy: CopyButton.init(), + }, +}) + +// MESSAGE + +const Message = defineMessageUnion({ + GotDefaultTabsMessage: { message: Tabs.Message }, + GotUnderlineTabsMessage: { message: Tabs.Message }, + GotCopyMessage: { message: CopyButton.Message }, +}) +type Message = typeof Message.Type + +// UPDATE + +const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotDefaultTabsMessage: ({ message }) => { + const next = Tabs.update(model.defaultTabs, message) + return { + model: { ...model, defaultTabs: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotDefaultTabsMessage({ message: child }), + ), + } + }, + GotUnderlineTabsMessage: ({ message }) => { + const next = Tabs.update(model.underlineTabs, message) + return { + model: { ...model, underlineTabs: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotUnderlineTabsMessage({ message: child }), + ), + } + }, + GotCopyMessage: ({ message }) => { + const next = CopyButton.update(model.traceIdCopy, message) + return { + model: { ...model, traceIdCopy: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotCopyMessage({ message: child }), + ), + } + }, + }) + +// VIEW + +const defaultTabs = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "kit-display-tabs-default", + model: model.defaultTabs, + toParentMessage: (message: Tabs.Message) => Message.GotDefaultTabsMessage({ message }), + view: TabsView.view, + viewInputs: { + labels: ["Overview", "Traces", "Logs"], + panelClassName: "pt-3 text-sm", + panel: (value: string) => (value === "overview" ? ["Default tabs panel."] : undefined), + }, + }) + +const underlineTabs = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "kit-display-tabs-underline", + model: model.underlineTabs, + toParentMessage: (message: Tabs.Message) => Message.GotUnderlineTabsMessage({ message }), + view: TabsView.view, + viewInputs: { + variant: "underline", + labels: ["Inbox", "Rules", "Routing"], + panelClassName: "pt-3 text-sm", + panel: (value: string) => (value === "rules" ? ["Underline tabs panel."] : undefined), + }, + }) + +const tabsSection = (model: Model, h: HtmlBuilder): Html => + kitSection(h, "Tabs and separator", [ + defaultTabs(model, h), + separator(h, "horizontal", undefined), + underlineTabs(model, h), + h.div( + [h.Class("flex h-6 items-center gap-3 text-sm")], + ["Left ", separator(h, "vertical", undefined), " Right"], + ), + ]) + +const view = (model: Model, h: HtmlBuilder): Html => + kitPage( + h, + { title: "Kit: display", description: "Surfaces, data display and loading states from @maple/ui." }, + [ + cardAndPanelSection(h), + alertSection(h), + emptySection(h), + itemSection(h), + keyValueSection( + h, + model.traceIdCopy.status, + Message.GotCopyMessage({ message: CopyButton.Message.ClickedCopy({ text: TRACE_ID }) }), + ), + meterSection(h), + statSection(h), + skeletonSection(h), + tableSection(h), + tabsSection(model, h), + ], + ) + +export const lab = defineLab("Kit: display", { Model, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/kit/forms.ts b/apps/web-foldkit/src/lab/entries/kit/forms.ts new file mode 100644 index 0000000000..372011e6a5 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/kit/forms.ts @@ -0,0 +1,482 @@ +import { Option, Schema } from "effect" +import { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { modifyFields } from "foldkit/struct" +import * as Checkbox from "../../../ui/checkbox" +import * as Combobox from "../../../ui/combobox" +import * as Field from "../../../ui/field" +import { icon } from "../../../ui/icon" +import { input } from "../../../ui/input" +import { + inputGroup, + inputGroupAddon, + inputGroupButton, + inputGroupInput, + inputGroupText, +} from "../../../ui/input-group" +import { label } from "../../../ui/label" +import { nativeOptGroup, nativeOption, nativeSelect } from "../../../ui/native-select" +import * as OtpField from "../../../ui/otp-field" +import * as RadioGroup from "../../../ui/radio-group" +import * as Select from "../../../ui/select" +import * as Switch from "../../../ui/switch" +import { textarea } from "../../../ui/textarea" +import { defineLab } from "../../define" +import { kitPage, kitRow, kitSection } from "../../kit/kit-layout" +import { + initKitAutocomplete, + initKitCombobox, + initKitComboboxChips, + initKitSelect, + kitAutocomplete, + kitCombobox, + kitComboboxChips, + kitSelect, +} from "../../kit/pickers" + +// Port of `/lab/kit/forms` (apps/web/src/lab/kit/forms.tsx, pickers.tsx). Ids are the `useId`s +// Base UI gives each control in apps/web, so the two DOMs diff clean. + +const INPUT_SIZES = ["sm", "default", "lg"] as const +const CHECKBOXES = [ + { key: "unchecked", id: "base-ui-_r_j_", inputId: "base-ui-_r_k_", text: "Unchecked" }, + { key: "checked", id: "base-ui-_r_m_", inputId: "base-ui-_r_n_", text: "Checked" }, + { key: "indeterminate", id: "base-ui-_r_p_", inputId: "base-ui-_r_q_", text: "Indeterminate" }, + { key: "disabled", id: "base-ui-_r_s_", inputId: "base-ui-_r_t_", text: "Disabled" }, +] as const +const SWITCHES = [ + { key: "off", id: "base-ui-_r_1c_", inputId: "base-ui-_r_1d_", text: "Off" }, + { key: "on", id: "base-ui-_r_1f_", inputId: "base-ui-_r_1g_", text: "On" }, + { key: "disabled", id: "base-ui-_r_1i_", inputId: "base-ui-_r_1j_", text: "Disabled" }, +] as const +const RADIOS: ReadonlyArray = [ + { value: "p50", id: "base-ui-_r_10_", inputId: "base-ui-_r_11_" }, + { value: "p95", id: "base-ui-_r_13_", inputId: "base-ui-_r_14_" }, + { value: "p99", id: "base-ui-_r_16_", inputId: "base-ui-_r_17_" }, + { value: "max", id: "base-ui-_r_19_", inputId: "base-ui-_r_1a_", isDisabled: true }, +] +const DASHBOARD_FIELD: Field.FieldIds = { + control: "base-ui-_r_b_", + label: "base-ui-_r_c_", + error: "base-ui-_r_d_", + description: "base-ui-_r_e_", +} +const WEBHOOK_FIELD: Field.FieldIds = { + control: "base-ui-_r_f_", + label: "base-ui-_r_g_", + error: "base-ui-_r_i_", +} + +// MODEL + +const Model = Schema.Struct({ + fields: Schema.Record(Schema.String, Field.Model), + checkboxes: Schema.Record(Schema.String, Checkbox.Model), + switches: Schema.Record(Schema.String, Switch.Model), + radioGroup: RadioGroup.Model, + select: Select.Model, + combobox: Combobox.Model, + comboboxChips: Combobox.Model, + autocomplete: Combobox.Model, + otpField: OtpField.Model, +}) +type Model = typeof Model.Type + +const init = (): Update.Return => ({ + model: { + fields: { dashboard: Field.init("Checkout latency"), webhook: Field.init("hooks.example") }, + checkboxes: Object.fromEntries(CHECKBOXES.map(({ key }) => [key, Checkbox.init(key === "checked")])), + switches: Object.fromEntries(SWITCHES.map(({ key }) => [key, Switch.init(key === "on")])), + radioGroup: RadioGroup.init("p95"), + select: initKitSelect("kit-forms-select"), + combobox: initKitCombobox("kit-forms-combobox"), + comboboxChips: initKitComboboxChips("kit-forms-combobox-chips"), + autocomplete: initKitAutocomplete("kit-forms-autocomplete"), + otpField: OtpField.init({ id: "base-ui-_r_22_", length: 6, value: "482" }), + }, +}) + +// MESSAGE + +const Message = defineMessageUnion({ + GotFieldMessage: { key: Schema.String, message: Field.Message }, + GotCheckboxMessage: { key: Schema.String, message: Checkbox.Message }, + GotSwitchMessage: { key: Schema.String, message: Switch.Message }, + GotRadioGroupMessage: { message: RadioGroup.Message }, + GotSelectMessage: { message: Select.Message }, + GotComboboxMessage: { message: Combobox.Message }, + GotComboboxChipsMessage: { message: Combobox.Message }, + GotAutocompleteMessage: { message: Combobox.Message }, + GotOtpFieldMessage: { message: OtpField.Message }, + ClickedClearSearch: {}, +}) +type Message = typeof Message.Type + +// UPDATE + +const foldField = Update.foldChildAt({ + update: Field.update, + readAt: (model: Model, key: string) => Option.fromNullishOr(model.fields[key]), + writeAt: (model, key, next) => modifyFields(model, { fields: (all) => ({ ...all, [key]: next }) }), + toParentMessage: (key, message) => Message.GotFieldMessage({ key, message }), +}) + +const foldCheckbox = Update.foldChildAt({ + update: Checkbox.update, + readAt: (model: Model, key: string) => Option.fromNullishOr(model.checkboxes[key]), + writeAt: (model, key, next) => modifyFields(model, { checkboxes: (all) => ({ ...all, [key]: next }) }), + toParentMessage: (key, message) => Message.GotCheckboxMessage({ key, message }), +}) + +const foldSwitch = Update.foldChildAt({ + update: Switch.update, + readAt: (model: Model, key: string) => Option.fromNullishOr(model.switches[key]), + writeAt: (model, key, next) => modifyFields(model, { switches: (all) => ({ ...all, [key]: next }) }), + toParentMessage: (key, message) => Message.GotSwitchMessage({ key, message }), +}) + +/** The page shows the pickers closed: a picked value needs no reaction. */ +const comboboxFold = ( + field: "combobox" | "comboboxChips" | "autocomplete", + toParentMessage: (message: Combobox.Message) => Message, +) => + Update.foldChild({ + update: Combobox.update, + read: (model: Model) => Option.some(model[field]), + write: (model: Model, next: Combobox.Model): Model => ({ ...model, [field]: next }), + toParentMessage, + foldOutMessage: (_outMessage: Combobox.OutMessage) => (model: Model) => ({ model }), + }) + +const foldCombobox = comboboxFold("combobox", (message) => Message.GotComboboxMessage({ message })) +const foldComboboxChips = comboboxFold("comboboxChips", (message) => + Message.GotComboboxChipsMessage({ message }), +) +const foldAutocomplete = comboboxFold("autocomplete", (message) => + Message.GotAutocompleteMessage({ message }), +) + +const foldRadioGroup = Update.foldChild({ + update: RadioGroup.update, + read: (model: Model) => Option.some(model.radioGroup), + write: (model, next) => modifyFields(model, { radioGroup: () => next }), + toParentMessage: (message: RadioGroup.Message) => Message.GotRadioGroupMessage({ message }), +}) + +const foldSelect = Update.foldChild({ + update: Select.update, + read: (model: Model) => Option.some(model.select), + write: (model, next) => modifyFields(model, { select: () => next }), + toParentMessage: (message: Select.Message) => Message.GotSelectMessage({ message }), + foldOutMessage: (_outMessage: Select.OutMessage) => (model: Model) => ({ model }), +}) + +const foldOtpField = Update.foldChild({ + update: OtpField.update, + read: (model: Model) => Option.some(model.otpField), + write: (model, next) => modifyFields(model, { otpField: () => next }), + toParentMessage: (message: OtpField.Message) => Message.GotOtpFieldMessage({ message }), +}) + +const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotFieldMessage: ({ key, message }) => foldField(model, key, message), + GotCheckboxMessage: ({ key, message }) => foldCheckbox(model, key, message), + GotSwitchMessage: ({ key, message }) => foldSwitch(model, key, message), + GotRadioGroupMessage: ({ message }) => foldRadioGroup(model, message), + GotSelectMessage: ({ message }) => foldSelect(model, message), + GotComboboxMessage: ({ message }) => foldCombobox(model, message), + GotComboboxChipsMessage: ({ message }) => foldComboboxChips(model, message), + GotAutocompleteMessage: ({ message }) => foldAutocomplete(model, message), + GotOtpFieldMessage: ({ message }) => foldOtpField(model, message), + // The legacy clear button has no handler either. + ClickedClearSearch: () => ({ model }), + }) + +// VIEW + +const inputs = (h: HtmlBuilder): ReadonlyArray => [ + kitRow( + h, + "sizes", + INPUT_SIZES.map((size, index) => + input(h, { + id: `base-ui-_r_${index}_`, + size, + className: "w-48", + attributes: [h.AriaLabel(`Input ${size}`), h.Placeholder(`Size ${size}`)], + }), + ), + ), + kitRow(h, "states", [ + input(h, { + id: "base-ui-_r_3_", + className: "w-48", + attributes: [h.AriaLabel("Service name"), h.Value("checkout-svc")], + }), + input(h, { + id: "base-ui-_r_4_", + className: "w-48", + attributes: [h.AriaInvalid(true), h.AriaLabel("Invalid input"), h.Value("not a url")], + }), + input(h, { + id: "base-ui-_r_5_", + className: "w-48", + isDisabled: true, + attributes: [h.AriaLabel("Disabled input"), h.Value("read only")], + }), + ]), +] + +const textareas = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + textarea(h, { + id: "base-ui-_r_6_", + className: "w-72", + attributes: [h.AriaLabel("Notes"), h.Placeholder("Describe the incident")], + }), + textarea(h, { + id: "base-ui-_r_7_", + className: "w-72", + defaultValue: "Too short", + attributes: [h.AriaInvalid(true), h.AriaLabel("Invalid notes")], + }), + textarea(h, { + id: "base-ui-_r_8_", + className: "w-72", + isDisabled: true, + defaultValue: "Locked", + attributes: [h.AriaLabel("Disabled notes")], + }), + ]) + +const inputGroups = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + inputGroup(h, { className: "w-72" }, [ + inputGroupAddon(h, {}, [icon(h, "MagnifierIcon")]), + inputGroupInput(h, { + id: "base-ui-_r_9_", + attributes: [h.AriaLabel("Search spans"), h.Placeholder("Search spans")], + }), + inputGroupAddon(h, { align: "inline-end" }, [ + inputGroupButton( + h, + { + size: "icon-xs", + attributes: [h.AriaLabel("Clear search"), h.OnClick(Message.ClickedClearSearch())], + }, + [icon(h, "XmarkIcon")], + ), + ]), + ]), + inputGroup(h, { className: "w-72" }, [ + inputGroupAddon(h, {}, [inputGroupText(h, {}, ["https://"])]), + inputGroupInput(h, { + id: "base-ui-_r_a_", + attributes: [h.AriaLabel("Endpoint"), h.Value("ingest.maple.dev")], + }), + inputGroupAddon(h, { align: "inline-end" }, [inputGroupText(h, {}, ["/v1/traces"])]), + ]), + ]) + +const fieldOf = ( + model: Model, + h: HtmlBuilder, + key: string, + ids: Field.FieldIds, + isInvalid: boolean, + labelText: string, + trailing: (state: Field.FieldState) => Html, +): ReadonlyArray => { + const fieldModel = model.fields[key] + if (fieldModel === undefined) return [] + const state = Field.stateOf(fieldModel, isInvalid) + const toParentMessage = (message: Field.Message) => Message.GotFieldMessage({ key, message }) + return [ + Field.field(h, { state }, [ + Field.fieldLabel(h, { ids, state }, labelText), + Field.input(h, { + ids, + state, + attributes: Field.controlAttributes(h, fieldModel, toParentMessage), + }), + trailing(state), + ]), + ] +} + +const fields = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("grid max-w-3xl grid-cols-2 gap-6")], + [ + ...fieldOf(model, h, "dashboard", DASHBOARD_FIELD, false, "Dashboard name", (state) => + Field.fieldDescription( + h, + { ids: DASHBOARD_FIELD, state }, + "Shown in the sidebar and in alert notifications.", + ), + ), + ...fieldOf(model, h, "webhook", WEBHOOK_FIELD, true, "Webhook URL", (state) => + Field.fieldError(h, { ids: WEBHOOK_FIELD, state }, "Enter a full URL starting with https://"), + ), + ], + ) + +const CHOICE_LABEL = "flex items-center gap-2" + +const choiceLabel = ( + h: HtmlBuilder, + inputId: string, + control: ReadonlyArray, + text: string, +): Html => + label(h, { className: CHOICE_LABEL, attributes: [h.Id(`${inputId}-label`)] }, [...control, ` ${text}`]) + +const checkboxes = (model: Model, h: HtmlBuilder): Html => + kitRow( + h, + "checkbox", + CHECKBOXES.flatMap(({ key, id, inputId, text }) => { + const checkbox = model.checkboxes[key] + if (checkbox === undefined) return [] + const control = Checkbox.view(h, { + model: checkbox, + toParentMessage: (message) => Message.GotCheckboxMessage({ key, message }), + id, + inputId, + isIndeterminate: key === "indeterminate", + isDisabled: key === "disabled", + }) + return [choiceLabel(h, inputId, control, text)] + }), + ) + +const radios = (model: Model, h: HtmlBuilder): Html => { + const config: RadioGroup.RadioGroupViewConfig = { + model: model.radioGroup, + toParentMessage: (message) => Message.GotRadioGroupMessage({ message }), + items: RADIOS, + } + return kitRow(h, "radio group", [ + RadioGroup.radioGroup( + h, + { label: "Percentile", className: "flex flex-row gap-4" }, + RADIOS.map((item) => + choiceLabel(h, item.inputId, RadioGroup.radio(h, config, item.value), item.value), + ), + ), + ]) +} + +const switches = (model: Model, h: HtmlBuilder): Html => + kitRow( + h, + "switch", + SWITCHES.flatMap(({ key, id, inputId, text }) => { + const switchModel = model.switches[key] + if (switchModel === undefined) return [] + const control = Switch.view(h, { + model: switchModel, + toParentMessage: (message) => Message.GotSwitchMessage({ key, message }), + id, + inputId, + isDisabled: key === "disabled", + }) + return [choiceLabel(h, inputId, control, text)] + }), + ) + +const nativeSelects = (h: HtmlBuilder): Html => + kitRow(h, undefined, [ + nativeSelect(h, { className: "w-48", defaultValue: "us", attributes: [h.AriaLabel("Region")] }, [ + nativeOptGroup("Regions", [ + { value: "us", label: "United States" }, + { value: "eu", label: "Europe" }, + ]), + ]), + nativeSelect( + h, + { className: "w-48", size: "sm", defaultValue: "eu", attributes: [h.AriaLabel("Small region")] }, + [nativeOption("us", "United States"), nativeOption("eu", "Europe")], + ), + nativeSelect( + h, + { className: "w-48", isDisabled: true, attributes: [h.AriaLabel("Disabled region")] }, + [nativeOption("us", "United States")], + ), + ]) + +/** Select, combobox and autocomplete side by side (each nests the next for its siblings), then chips. */ +const pickers = (model: Model, h: HtmlBuilder): ReadonlyArray => [ + kitSelect( + h, + model.select, + (message) => Message.GotSelectMessage({ message }), + (select) => + kitCombobox( + h, + model.combobox, + (message) => Message.GotComboboxMessage({ message }), + (combobox) => + kitAutocomplete( + h, + model.autocomplete, + (message) => Message.GotAutocompleteMessage({ message }), + (autocomplete) => kitRow(h, undefined, [...select, ...combobox, ...autocomplete]), + ), + ), + ), + kitComboboxChips( + h, + model.comboboxChips, + (message) => Message.GotComboboxChipsMessage({ message }), + (chips) => kitRow(h, undefined, chips), + ), +] + +const otpField = (model: Model, h: HtmlBuilder): Html => { + const config: OtpField.OtpFieldViewConfig = { + model: model.otpField, + toParentMessage: (message) => Message.GotOtpFieldMessage({ message }), + label: "Verification code", + } + const slot = (index: number) => OtpField.otpFieldInput(h, config, index) + return kitRow(h, undefined, [ + ...OtpField.otpField(h, config, [ + slot(0), + slot(1), + slot(2), + OtpField.otpFieldSeparator(h), + slot(3), + slot(4), + slot(5), + ]), + label(h, {}, ["Standalone label"]), + ]) +} + +const view = (model: Model, h: HtmlBuilder): Html => + kitPage( + h, + { + title: "Kit: forms", + description: "Inputs, fields and choice controls from @maple/ui, every picker closed.", + }, + [ + kitSection(h, "Input", inputs(h)), + kitSection(h, "Textarea", [textareas(h)]), + kitSection(h, "Input group", [inputGroups(h)]), + kitSection(h, "Field", [fields(model, h)]), + kitSection(h, "Checkbox, radio group, switch", [ + checkboxes(model, h), + radios(model, h), + switches(model, h), + ]), + kitSection(h, "Native select", [nativeSelects(h)]), + kitSection(h, "Select, combobox, autocomplete (closed)", pickers(model, h)), + kitSection(h, "OTP field and label", [otpField(model, h)]), + ], + ) + +export const lab = defineLab("Kit: forms", { Model, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/kit/navigation.ts b/apps/web-foldkit/src/lab/entries/kit/navigation.ts new file mode 100644 index 0000000000..db5cd9faa6 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/kit/navigation.ts @@ -0,0 +1,219 @@ +import { Option, Schema } from "effect" +import { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as NavigationMenu from "../../../ui/navigation-menu" +import * as Resizable from "../../../ui/resizable" +import * as Tooltip from "../../../ui/tooltip" +import { defineLab } from "../../define" +import { kitPage, kitSection } from "../../kit/kit-layout" +import { sidebarFrame } from "../../kit/navigation-sidebar" + +// Port of `/lab/kit/navigation` (apps/web/src/lab/kit/navigation.tsx): navigation menu, an +// expanded and a collapsed sidebar, and nested resizable panel groups. + +// MODEL + +const Model = Schema.Struct({ + navigationMenu: NavigationMenu.Model, + railTooltip: Tooltip.Model, + outerPanels: Resizable.Model, + innerPanels: Resizable.Model, +}) +type Model = typeof Model.Type + +const init = (): Update.Return => ({ + model: { + navigationMenu: NavigationMenu.init("kit-navigation-menu", { value: "product" }), + railTooltip: Tooltip.init("kit-rail-tooltip", { side: "right", align: "center" }), + outerPanels: Resizable.init("kit-resizable-outer", "horizontal", [40, 60]), + innerPanels: Resizable.init("kit-resizable-inner", "vertical", [50, 50]), + }, +}) + +// MESSAGE + +const Message = defineMessageUnion({ + GotNavigationMenuMessage: { message: NavigationMenu.Message }, + GotRailTooltipMessage: { message: Tooltip.Message }, + GotOuterPanelsMessage: { message: Resizable.Message }, + GotInnerPanelsMessage: { message: Resizable.Message }, +}) +type Message = typeof Message.Type + +// UPDATE + +const fold = ( + field: Field, + update: (model: Model[Field], message: ChildMessage) => Update.Return, + toParentMessage: (message: ChildMessage) => Message, +) => + Update.foldChild({ + update, + read: (model: Model) => Option.some(model[field]), + write: (model: Model, next: Model[Field]): Model => ({ ...model, [field]: next }), + toParentMessage, + }) + +const foldNavigationMenu = fold("navigationMenu", NavigationMenu.update, (message) => + Message.GotNavigationMenuMessage({ message }), +) +const foldRailTooltip = fold("railTooltip", Tooltip.update, (message) => + Message.GotRailTooltipMessage({ message }), +) +const foldOuterPanels = fold("outerPanels", Resizable.update, (message) => + Message.GotOuterPanelsMessage({ message }), +) +const foldInnerPanels = fold("innerPanels", Resizable.update, (message) => + Message.GotInnerPanelsMessage({ message }), +) + +const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotNavigationMenuMessage: ({ message: child }) => foldNavigationMenu(model, child), + GotRailTooltipMessage: ({ message: child }) => foldRailTooltip(model, child), + GotOuterPanelsMessage: ({ message: child }) => foldOuterPanels(model, child), + GotInnerPanelsMessage: ({ message: child }) => foldInnerPanels(model, child), + }) + +// VIEW + +type H = HtmlBuilder + +const PRODUCT = ["Traces", "Logs", "Metrics", "Session replay"] + +const navigationMenuItems = (h: H): ReadonlyArray => [ + { + _tag: "Trigger", + value: "product", + label: "Product", + content: [ + h.ul( + [h.Class("grid w-96 grid-cols-2 gap-1 p-2")], + PRODUCT.map((title) => + h.li( + [], + [ + NavigationMenu.navigationMenuLink( + h, + `#${title.toLowerCase().replace(" ", "-")}`, + [title], + ), + ], + ), + ), + ), + ], + }, + { + _tag: "Trigger", + value: "docs", + label: "Docs", + content: [h.div([h.Class("w-64 p-3 text-sm")], ["Guides and API reference."])], + }, + { _tag: "Link", label: "Pricing", href: "#pricing" }, +] + +const railTriggerId = (title: string): string => `kit-rail-${title.toLowerCase()}` +const RAIL_TITLES = ["Traces", "Logs", "Metrics", "Hosts", "Alerts"] + +const sidebars = (h: H, model: Model): Html => + h.div( + [h.Class("flex gap-6")], + [ + sidebarFrame(h, { + name: "Expanded sidebar", + state: "expanded", + tooltipTrigger: () => [], + tooltipPortal: null, + }), + // `hidden={state !== "collapsed"}`: only the collapsed rail shows its tooltips. + h.submodel({ + slotId: "rail-tooltip", + model: model.railTooltip, + view: Tooltip.view, + viewInputs: { + toView: ({ trigger, portal }) => + sidebarFrame(h, { + name: "Collapsed sidebar", + state: "collapsed", + tooltipTrigger: (title) => trigger(railTriggerId(title)), + tooltipPortal: portal, + }), + triggerIds: RAIL_TITLES.map(railTriggerId), + toContent: (triggerId) => + RAIL_TITLES.filter((title) => railTriggerId(title) === triggerId), + }, + toParentMessage: (message) => Message.GotRailTooltipMessage({ message }), + }), + ], + ) + +const panelText = (h: H, text: string): ReadonlyArray => [h.div([h.Class("p-3 text-sm")], [text])] + +const resizable = (h: H, model: Model): Html => + h.div( + [h.Class("h-40 overflow-hidden rounded-md border")], + [ + h.submodel({ + slotId: "resizable-outer", + model: model.outerPanels, + view: Resizable.view, + viewInputs: { + withHandle: [true], + toPanels: () => [ + panelText(h, "Waterfall"), + [ + h.submodel({ + slotId: "resizable-inner", + model: model.innerPanels, + view: Resizable.view, + viewInputs: { + withHandle: [false], + toPanels: () => [panelText(h, "Span details"), panelText(h, "Events")], + }, + toParentMessage: (message) => Message.GotInnerPanelsMessage({ message }), + }), + ], + ], + }, + toParentMessage: (message) => Message.GotOuterPanelsMessage({ message }), + }), + ], + ) + +const view = (model: Model, h: H): Html => + h.div( + [h.Id("app")], + [ + kitPage( + h, + { + title: "Kit: navigation", + description: "Sidebar, navigation menu and resizable panels from @maple/ui.", + }, + [ + kitSection(h, "Navigation menu", [ + h.div( + [h.Class("min-h-40")], + [ + h.submodel({ + slotId: "navigation-menu", + model: model.navigationMenu, + view: NavigationMenu.view, + viewInputs: { items: navigationMenuItems(h) }, + toParentMessage: (message) => + Message.GotNavigationMenuMessage({ message }), + }), + ], + ), + ]), + kitSection(h, "Sidebar (expanded and collapsed)", [sidebars(h, model)]), + kitSection(h, "Resizable", [resizable(h, model)]), + ], + ), + ], + ) + +/** `/lab/kit/navigation`: the Product menu open on mount (`defaultValue`). */ +export const lab = defineLab("Kit: navigation", { Model, init, update, view, devTools: { Message } }) diff --git a/apps/web-foldkit/src/lab/entries/kit/overlays.ts b/apps/web-foldkit/src/lab/entries/kit/overlays.ts new file mode 100644 index 0000000000..a3c94fba6c --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/kit/overlays.ts @@ -0,0 +1,15 @@ +import { defineLabWithFlags } from "../../define" +import { Flags, Message, Model } from "../../kit/overlays/model" +import { flags, init, update } from "../../kit/overlays/update" +import { view } from "../../kit/overlays/view" + +/** `/lab/kit/overlays`: one @maple/ui overlay per `?item=`, rendered open on mount. */ +export const lab = defineLabWithFlags("Kit: overlays", { + Model, + Flags, + flags, + init, + update, + view, + devTools: { Message }, +}) diff --git a/apps/web-foldkit/src/lab/entries/loaders.ts b/apps/web-foldkit/src/lab/entries/loaders.ts new file mode 100644 index 0000000000..0fad47c51b --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/loaders.ts @@ -0,0 +1,93 @@ +import { Schema } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { dotLoader } from "../../page/chat/kit/dot-loader" +import { statusMarker } from "../../page/chat/kit/view-transcript" +import { eyebrow } from "../../ui/eyebrow" +import { defineLab } from "../define" + +// `/lab/loaders` (apps/web `lab/loaders-lab.tsx`): the chat's loader on the lines it sits on. + +const Model = Schema.Struct({}) +type Model = typeof Model.Type + +const heading = (h: HtmlBuilder, text: string): Html => + eyebrow(h, { variant: "label", as: "h2", className: "mb-2" }, [text]) + +const view = (_model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("mx-auto flex max-w-3xl flex-col gap-10 p-8")], + [ + h.header( + [h.Class("flex flex-col gap-1")], + [ + h.h1([h.Class("font-semibold text-lg")], ["Chat loader"]), + h.p( + [h.Class("text-muted-foreground text-sm")], + [ + "One dot-matrix animation, one 14px box, everywhere the chat reports work in flight.", + ], + ), + ], + ), + h.section( + [h.Class("flex flex-col gap-1")], + [ + heading(h, "The tool row — running, then settled"), + ...["Searching Traces", "Reading spans", "Grouping by service"].map((line) => + h.div( + [h.Key(line), h.Class("flex items-center gap-2 py-0.5 text-xs")], + [ + h.span( + [h.Class("flex size-5 shrink-0 items-center justify-center")], + [dotLoader(h)], + ), + h.span( + [h.Class("min-w-0 flex-1 truncate font-medium text-foreground")], + [line], + ), + ], + ), + ), + ], + ), + h.section( + [h.Class("flex flex-col gap-1")], + [ + heading(h, "The sidebar tab"), + h.div( + [h.Class("flex items-center gap-2 py-0.5 text-sm")], + [ + dotLoader(h, { color: "var(--primary)", label: "Working" }), + h.span([h.Class("min-w-0 flex-1 truncate")], ["Investigating checkout latency"]), + ], + ), + ], + ), + h.section([h.Class("flex flex-col gap-1")], [heading(h, "The thinking row"), statusMarker(h)]), + h.section( + [h.Class("flex flex-col gap-2")], + [ + heading(h, "In running prose"), + h.p( + [h.Class("text-sm leading-relaxed")], + [ + "The checkout path is still degraded ", + dotLoader(h), + " and the paywall worker is the one dragging it down ", + dotLoader(h), + " so I am pulling the last hour of spans ", + dotLoader(h), + "before saying anything firmer.", + ], + ), + ], + ), + ], + ) + +export const lab = defineLab("Chat loaders", { + Model, + init: () => ({ model: {} }), + update: (model: Model, _message: never) => ({ model }), + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/logs.ts b/apps/web-foldkit/src/lab/entries/logs.ts new file mode 100644 index 0000000000..a4ecb49795 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/logs.ts @@ -0,0 +1,66 @@ +import { Duration, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import * as LogsStream from "../../ui/logs/logs-stream" +import { ViewerFlags, viewerFlags } from "../../ui/logs/viewer" +import * as Shell from "../../shell" +import { LabShellFlags, labShellFlags, labShellSubscriptions } from "../app-shell" +import { defineLabWithFlags } from "../define" +import type { Resources } from "../../platform/resources" +import { Message, Model } from "../logs-lab/model" +import { init, update } from "../logs-lab/update" +import { view } from "../logs-lab/view" + +/** + * `/lab/logs`: the logs stream over apps/web's production-like fixture, with the lab's toggles for + * wrap, density, search and pinned columns, and a live tail that reveals held-back rows on reload. + */ +const toShell = (message: Shell.Message) => Message.GotShellMessage({ message }) + +const own = Subscription.make()((entry) => ({ + navigationKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: (model) => ({ hasRows: model.stream.rows.length > 0 }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? Stream.map(LogsStream.navigationKeys, (message) => + Message.GotStreamMessage({ message }), + ) + : Stream.empty, + }, + ), + /** `useIntervalRefresh`, held while the reader inspects the stream. */ + autoRefresh: entry( + { intervalSeconds: Schema.Number }, + { + modelToDependencies: (model) => ({ + intervalSeconds: model.isInspecting ? 0 : model.refreshInterval, + }), + dependenciesToStream: ({ intervalSeconds }) => + intervalSeconds > 0 + ? Stream.tick(Duration.seconds(intervalSeconds)).pipe( + Stream.drop(1), + Stream.map(() => Message.TickedAutoRefresh()), + ) + : Stream.empty, + }, + ), +})) + +const subscriptions = Subscription.aggregate()( + labShellSubscriptions(toShell), + own, +) + +export const lab = defineLabWithFlags("Logs stream", { + Model, + Flags: Schema.Struct({ ...ViewerFlags.fields, ...LabShellFlags.fields }), + flags: Effect.map(Effect.all([viewerFlags, labShellFlags]), ([viewer, shell]) => ({ + ...viewer, + ...shell, + })), + init, + update, + view, + subscriptions, +}) diff --git a/apps/web-foldkit/src/lab/entries/nodes.ts b/apps/web-foldkit/src/lab/entries/nodes.ts new file mode 100644 index 0000000000..60826141a4 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/nodes.ts @@ -0,0 +1,16 @@ +import { Flags, flags, init, Message, Model, update, view } from "../nodes-lab" +import { defineShellLab } from "../shell-lab" + +// `/lab/nodes`: every provenance-canvas node and edge state, inside the real shell. + +export const lab = defineShellLab("Provenance nodes", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/nodes", + breadcrumbs: [{ label: "Node Lab" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/onboarding.ts b/apps/web-foldkit/src/lab/entries/onboarding.ts new file mode 100644 index 0000000000..f5c4f83e71 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/onboarding.ts @@ -0,0 +1,256 @@ +import { CatalogPlan, CatalogPlanItem, CatalogPlanItemPrice } from "@maple/domain/http" +import { Effect, Schema } from "effect" +import { Command, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as PricingCardsView from "../../page/onboarding/pricing-cards-view" +import * as QuickStartTeamView from "../../page/onboarding/quick-start-team-view" +import * as QuickStartView from "../../page/onboarding/quick-start-view" +import { button } from "../../ui/button" +import { icon } from "../../ui/icon" +import { defineLabWithFlags } from "../define" +import { defineMessageUnion } from "foldkit/message" +import * as PricingCards from "../../page/onboarding/pricing-cards" +import * as QuickStart from "../../page/onboarding/quick-start" +import { + completeStep, + DEFAULT_QUICK_START_STATE, + pickedRole, + QuickStartState, + toggledIntent, +} from "../../page/onboarding/quick-start-state" +import { ready, Team, TeamMessage, updateTeam } from "../../page/onboarding/quick-start-team" + +// `/lab/onboarding` (apps/web `lab/onboarding/onboarding-lab.tsx`): click through onboarding with a +// sample catalog; rename, invites and checkout are simulated, and Restart starts over. + +const meteredItem = (featureId: string, included: number, amount: number, billingUnits: number, secondaryText: string) => + new CatalogPlanItem({ + featureId, + included, + price: new CatalogPlanItemPrice({ amount, billingUnits, interval: "month" }), + display: { secondaryText }, + }) + +/** The lab's illustrative catalog, mirroring `apps/api/autumn.config.ts`. */ +export const PLANS = [ + new CatalogPlan({ + id: "startup", + name: "Startup", + price: { amount: 39, interval: "month" }, + items: [ + meteredItem("logs", 100, 0.3, 1, "then $0.3 per Logs"), + meteredItem("traces", 100, 0.3, 1, "then $0.3 per Traces"), + meteredItem("metrics", 100, 0.3, 1, "then $0.3 per Metrics"), + meteredItem("browser_sessions", 5000, 0.002, 1, "then $0.002 per browser session"), + meteredItem("product_events", 1_000_000, 0.005, 1000, "then $0.005 per 1,000 product events"), + ], + customerEligibility: { trialAvailable: true }, + }), +] + +export const Model = Schema.Struct({ + /** 1 to 4 are the wizard's steps; 5 is "Preview complete". */ + step: Schema.Number, + /** Bumped by Restart, so every step remounts. */ + run: Schema.Number, + state: QuickStartState, + workspaceName: Schema.String, + invitations: Schema.Array(Schema.String), + team: Team, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotStepMessage: { message: QuickStart.Message }, + ClickedRestart: {}, + ClickedBackToPlans: {}, + SimulatedTeamOperation: { message: TeamMessage }, +}) +export type Message = typeof Message.Type + +export const init = (initialStep: number): Model => ({ + step: initialStep, + run: 0, + state: DEFAULT_QUICK_START_STATE, + workspaceName: "Your workspace", + invitations: [], + team: ready("Your workspace", [], true, null), +}) + +type Return = { readonly model: Model; readonly commands?: ReadonlyArray> } + +const STEP_OF = { role: 1, intent: 2, team: 3, plan: 4 } as const + +/** `goTo(step)`: the team step remounts with the name and invitations the preview kept. */ +const goTo = (model: Model, step: number): Model => ({ + ...model, + step, + team: step === 3 ? ready(model.workspaceName, model.invitations, true, null) : model.team, +}) + +const restart = (model: Model): Model => + goTo({ ...init(1), run: model.run + 1 }, 1) + +/** The team step's own logic, with the Clerk calls swapped for the simulated ones. */ +const teamStep = (model: Model, message: TeamMessage): Return => { + const next = updateTeam(model.team, message) + const kept = + message._tag === "SucceededTeamOperation" + ? message.operation === "rename" + ? { workspaceName: message.value } + : { invitations: [...model.invitations, message.value] } + : {} + const operation = next.commands.length === 0 ? null : next.team.pending + return { + model: { ...model, ...kept, team: next.team }, + commands: + operation === null + ? [] + : [ + SimulateTeamOperation({ + operation, + value: operation === "rename" ? model.team.name.trim() : model.team.email.trim(), + }), + ], + } +} + +const stepMessage = (model: Model, message: QuickStart.Message): Return => { + const answers = model.state.qualifyAnswers + const withAnswers = (qualifyAnswers: typeof answers): Return => ({ model: { ...model, state: { ...model.state, qualifyAnswers } } }) + switch (message._tag) { + case "PickedRole": + return withAnswers(pickedRole(answers, message.role, message.role === "other" ? answers.roleDetail : "")) + case "ChangedRoleDetail": + return withAnswers(pickedRole(answers, "other", message.value)) + case "ToggledIntent": + return withAnswers(toggledIntent(answers, message.intent)) + case "ClickedContinue": { + const isBlocked = + (message.step === "role" && (answers.role === null || QuickStart.roleNeedsDetail(model.state))) || + (message.step === "intent" && answers.intents.length === 0) + return isBlocked ? { model } : { model: goTo({ ...model, state: completeStep(model.state, message.step) }, STEP_OF[message.step] + 1) } + } + case "ClickedBack": + return { model: goTo(model, STEP_OF[message.to]) } + case "GotTeamMessage": + return teamStep(model, message.message) + // The sample catalog's checkout and the Enterprise contact both end the preview. + case "GotPricingMessage": + return message.message._tag === "ClickedPlan" || message.message._tag === "ClickedEnterpriseContact" + ? { model: goTo(model, 5) } + : { model } + default: + return { model } + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotStepMessage: ({ message: child }) => stepMessage(model, child), + ClickedRestart: () => ({ model: restart(model) }), + ClickedBackToPlans: () => ({ model: goTo(model, 4) }), + SimulatedTeamOperation: ({ message: child }) => teamStep(model, child), + }) + +// VIEW + +/** The wizard's steps 1 to 4, drawn by the screen's own step views. */ +const stepView = Submodel.defineView((model, h) => { + switch (model.step) { + case 1: + return QuickStartView.roleStep(h, model.state) + case 2: + return QuickStartView.intentStep(h, model.state) + case 3: + return QuickStartTeamView.teamStep(h, model.team) + default: + return QuickStartTeamView.planStep( + h, + false, + h.submodel({ + slotId: "lab-onboarding-plans", + model: null, + view: plansView, + toParentMessage: (message) => QuickStart.Message.GotPricingMessage({ message }), + }), + ) + } +}) + +const plansView = Submodel.defineView((_, h) => PricingCardsView.planCards(h, PLANS, null)) + +const complete = (h: HtmlBuilder): Html => + h.div( + [h.Class("flex flex-1 flex-col items-center justify-center gap-6 px-6 py-16 text-center")], + [ + icon(h, "MapleMark", { size: 56, className: "text-primary" }), + h.h1([h.Class("text-2xl font-semibold")], ["Preview complete"]), + h.p( + [h.Class("max-w-md text-sm text-muted-foreground")], + [ + "In the real flow, plan selection opens checkout. This preview ends here without creating data or changing your plan.", + ], + ), + button(h, { attributes: [h.OnClick(Message.ClickedRestart())] }, ["Try it again"]), + button(h, { variant: "ghost", attributes: [h.OnClick(Message.ClickedBackToPlans())] }, ["Back to plans"]), + ], + ) + +export const view = (model: Model, h: HtmlBuilder): Html => + QuickStartView.onboardingLayout( + h, + Math.min(model.step, 4), + 4, + (label) => + QuickStartView.accountClusterOf(h, [ + label, + h.a([h.Href("/lab"), h.Class("text-xs underline underline-offset-4")], ["Exit preview"]), + ]), + [ + h.div( + [h.Class("flex flex-wrap items-center justify-center gap-3 border-y px-4 py-2 text-xs text-muted-foreground")], + [ + h.span([], ["Onboarding preview · sample pricing · invites and checkout simulated"]), + button(h, { variant: "ghost", size: "sm", attributes: [h.OnClick(Message.ClickedRestart())] }, ["Restart"]), + ], + ), + QuickStartView.motionStep( + h, + `${model.run}-${model.step}`, + model.step === 5 + ? complete(h) + : h.submodel({ + slotId: "lab-onboarding-step", + model, + view: stepView, + toParentMessage: (message) => Message.GotStepMessage({ message }), + }), + ), + ], + ) + +/** `?step=N` opens the preview on a later step. */ +const initialStep = (search: string): number => { + const step = Number(new URLSearchParams(search).get("step")) + return Number.isInteger(step) && step >= 1 && step <= 5 ? step : 1 +} + +export const lab = defineLabWithFlags("Onboarding", { + Model, + Flags: Schema.Struct({ step: Schema.Number }), + flags: Effect.sync(() => ({ step: initialStep(window.location.search) })), + init: (flags) => ({ model: init(flags.step) }), + update, + view, +}) + +/** The lab's `actions`: a rename or invite succeeds at once and only changes the preview. */ +export const SimulateTeamOperation = Command.define("SimulateOnboardingTeamOperation", { + args: { operation: Schema.Literals(["rename", "invite"]), value: Schema.String }, + messages: [Message.SimulatedTeamOperation], + execute: ({ operation, value }) => + Effect.succeed( + Message.SimulatedTeamOperation({ message: TeamMessage.SucceededTeamOperation({ operation, value }) }), + ), +}) diff --git a/apps/web-foldkit/src/lab/entries/query-builder.ts b/apps/web-foldkit/src/lab/entries/query-builder.ts new file mode 100644 index 0000000000..7a8e014287 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/query-builder.ts @@ -0,0 +1,21 @@ +import { flags } from "../query-builder-lab/flags" +import { Flags, Message, Model } from "../query-builder-lab/model" +import { init, update } from "../query-builder-lab/update" +import { view } from "../query-builder-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/query-builder` (apps/web `routes/lab/query-builder.tsx`): the query builder MVP over the +// session's org, inside the real shell. +// TODO(lab-query-builder): the where clause autocomplete listbox and `sessionTimeRangeSearchMiddleware`. + +export const lab = defineShellLab("Query builder", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/query-builder", + breadcrumbs: [{ label: "Overview", href: "/" }, { label: "Query Builder Lab" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/replays.ts b/apps/web-foldkit/src/lab/entries/replays.ts new file mode 100644 index 0000000000..b844f17419 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/replays.ts @@ -0,0 +1,214 @@ +import { SessionTag } from "@maple/domain/query-engine" +import { cn } from "@maple/ui/lib/utils" +import { + pageContentStyles, + pageLayoutStyles, + pageScrollAreaStyles, + pageStickyAreaStyles, +} from "@maple/ui/styles/ui/page-layout" +import { Clock, Effect, Schema, Stream } from "effect" +import { Mount } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { replaysToolbar } from "../../page/replays/kit/replays-toolbar" +import { sessionListItems, type SessionRow } from "../../page/replays/kit/session-row" +import { sessionsList } from "../../page/replays/kit/sessions-list" +import { emptyWindow, VirtualWindow, virtualizeSessions } from "../../page/replays/kit/virtualize" +import { viewerFlags } from "../../ui/logs/viewer" +import { defineLabWithFlags } from "../define" +import { foldLabToasts, initLabToasts, labRoot } from "../toasts" +import * as Toast from "../../ui/toast" +import { labSessions } from "../replays/fixture" + +// `/lab/replays` (apps/web/src/lab/replays-list-lab.tsx): the `/replays` list over a day of mostly +// bots, bounces and idle tabs, without a warehouse behind it. + +const WIDTHS = [ + { label: "Full", value: null }, + { label: "1000px", value: 1000 }, + { label: "700px", value: 700 }, + { label: "380px", value: 380 }, +] as const + +const Model = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + width: Schema.NullOr(Schema.Number), + isErrorsOnly: Schema.Boolean, + isEngagedOnly: Schema.Boolean, + tag: Schema.NullOr(SessionTag), + group: Schema.NullOr(Schema.String), + expanded: Schema.Array(Schema.String), + /** `ToolbarSearch`'s own input state; the lab's `onSearch` does nothing with it. */ + query: Schema.String, + virtualWindow: VirtualWindow, + toasts: Toast.Model, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + ClickedWidth: { width: Schema.NullOr(Schema.Number) }, + ClickedClear: {}, + ToggledErrorsOnly: {}, + ToggledEngagedOnly: {}, + ClickedTag: { tag: SessionTag }, + ClickedGroup: { group: Schema.String }, + ToggledRun: { key: Schema.String }, + ClickedSession: { sessionId: Schema.String }, + ChangedSearch: { value: Schema.String }, + GotToastMessage: { message: Toast.Message }, + ChangedVirtualWindow: { window: VirtualWindow }, +}) +type Message = typeof Message.Type + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) + +const update = (model: Model, message: Message) => + message._tag === "GotToastMessage" + ? foldToasts(model, message.message) + : { model: updateState(model, message), commands: [] } + +const updateState = (model: Model, message: Message): Model => + Message.match(message, { + ClickedWidth: ({ width }) => ({ ...model, width }), + ClickedClear: () => ({ ...model, tag: null, group: null }), + ToggledErrorsOnly: () => ({ ...model, isErrorsOnly: !model.isErrorsOnly }), + ToggledEngagedOnly: () => ({ ...model, isEngagedOnly: !model.isEngagedOnly }), + ClickedTag: ({ tag }) => ({ ...model, tag }), + ClickedGroup: ({ group }) => ({ ...model, group }), + ToggledRun: ({ key }) => ({ + ...model, + expanded: model.expanded.includes(key) + ? model.expanded.filter((other) => other !== key) + : [...model.expanded, key], + }), + // TODO(lab-navigation): legacy navigates to `/replays/$sessionId`; the lab has no router. + ClickedSession: () => model, + ChangedSearch: ({ value }) => ({ ...model, query: value }), + GotToastMessage: () => model, + ChangedVirtualWindow: ({ window }) => ({ ...model, virtualWindow: window }), + }) + +const visibleRows = (model: Model, rows: ReadonlyArray) => + rows.filter( + (row) => + (!model.isErrorsOnly || row.errorCount > 0) && + (!model.isEngagedOnly || row.tags.includes("engaged")) && + (model.tag === null || row.tags.includes(model.tag)) && + (model.group === null || row.groupName === model.group), + ) + +const widthButton = (h: HtmlBuilder, model: Model, option: (typeof WIDTHS)[number]): Html => + h.button( + [ + h.Type("button"), + h.OnClick(Message.ClickedWidth({ width: option.value })), + h.Class( + model.width === option.value + ? "rounded border border-border bg-accent px-2 py-1 text-xs" + : "rounded border border-border px-2 py-1 text-xs text-muted-foreground", + ), + ], + [option.label], + ) + +const clearLabel = (model: Model) => [model.tag, model.group].filter((value) => value !== null).join(" + ") + +const page = (model: Model, h: HtmlBuilder): Html => { + const rows = labSessions(model.nowMs) + const visible = visibleRows(model, rows) + return h.div([h.Class("flex h-svh flex-col gap-4 p-6")], [ + h.div([h.Class("flex items-center gap-2")], [ + ...(model.tag !== null || model.group !== null + ? [ + h.button( + [ + h.Type("button"), + h.OnClick(Message.ClickedClear()), + h.Class("rounded border border-border px-2 py-1 text-xs"), + ], + ["Clear ", clearLabel(model)], + ), + ] + : []), + ...WIDTHS.map((option) => widthButton(h, model, option)), + ]), + h.div( + [ + h.Class("flex min-h-0 flex-1 flex-col"), + ...(model.width === null ? [] : [h.Style({ width: `${model.width}px` })]), + ], + [ + h.div([h.DataAttribute("slot", "page-layout"), h.Class(pageLayoutStyles)], [ + h.main([h.Id("main-content"), h.DataAttribute("slot", "page-content"), h.Class(pageContentStyles)], [ + h.div([h.DataAttribute("slot", "page-sticky-area"), h.Class(cn(pageStickyAreaStyles, "pb-3"))], [ + replaysToolbar(h, { + query: model.query, + onInput: (value) => Message.ChangedSearch({ value }), + onClear: Message.ChangedSearch({ value: "" }), + errorSessions: rows.filter((row) => row.errorCount > 0).length, + isErrorsOnly: model.isErrorsOnly, + onToggleErrorsOnly: Message.ToggledErrorsOnly(), + engagedSessions: rows.filter((row) => row.tags.includes("engaged")).length, + isEngagedOnly: model.isEngagedOnly, + onToggleEngagedOnly: Message.ToggledEngagedOnly(), + }), + ]), + h.div([h.DataAttribute("slot", "page-scroll-area"), h.Class(cn(pageScrollAreaStyles, "pt-0"))], [ + sessionsList(h, { + items: sessionListItems(visible, { + collapse: !model.isEngagedOnly && model.tag === null, + expanded: model.expanded, + nowMs: model.nowMs, + }), + nowMs: model.nowMs, + timeZone: model.timeZone, + durationP95: 1_200_000, + onOpen: (session) => Message.ClickedSession({ sessionId: session.sessionId }), + onToggleRun: (key) => Message.ToggledRun({ key }), + onFilterTag: (tag) => Message.ClickedTag({ tag }), + onFilterGroup: (group) => Message.ClickedGroup({ group }), + }, { window: model.virtualWindow, mount: h.OnMount(VirtualizeList()) }), + ]), + ]), + ]), + ], + ), + ]) +} + +const VirtualizeList = Mount.defineStream("VirtualizeLabReplaysList", { + messages: [Message.ChangedVirtualWindow], + execute: ({ element }) => + element instanceof HTMLElement + ? virtualizeSessions(element, (window) => Message.ChangedVirtualWindow({ window })) + : Stream.never, +}) + +const view = (model: Model, h: HtmlBuilder): Html => + labRoot(h, model.toasts, (message) => Message.GotToastMessage({ message }), page(model, h)) + +const Flags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) + +export const lab = defineLabWithFlags("Session replays list", { + Model, + Flags, + flags: Effect.all({ nowMs: Clock.currentTimeMillis, timeZone: Effect.map(viewerFlags, (flags) => flags.timeZone) }), + init: ({ nowMs, timeZone }) => ({ + model: { + nowMs, + timeZone, + width: null, + isErrorsOnly: false, + isEngagedOnly: false, + tag: null, + group: null, + expanded: [], + query: "", + virtualWindow: emptyWindow, + toasts: initLabToasts(), + }, + }), + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/entries/service-map-3d.ts b/apps/web-foldkit/src/lab/entries/service-map-3d.ts new file mode 100644 index 0000000000..c41c7ee07b --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/service-map-3d.ts @@ -0,0 +1,17 @@ +import { Flags, flags, init, Message, Model, update } from "../service-map-3d-lab/update" +import { view } from "../service-map-3d-lab/view" +import { defineShellLab } from "../shell-lab" + +// `/lab/service-map-3d` (apps/web `lab/service-map-3d/index.tsx`) inside the real shell. + +export const lab = defineShellLab("Service map 3D", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/service-map-3d", + breadcrumbs: [{ label: "Lab", href: "/lab" }, { label: "Service map 3D" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/time-range.ts b/apps/web-foldkit/src/lab/entries/time-range.ts new file mode 100644 index 0000000000..bdaa57210e --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/time-range.ts @@ -0,0 +1,17 @@ +import * as Page from "../time-range-lab" +import { defineShellLab } from "../shell-lab" + +// `/lab/time-range` (apps/web `lab/time-range-lab.tsx`): the time range picker over plain state. +// TODO(picker): the "D" hotkey, the timezone list and the custom tab are the picker's own gaps. + +export const lab = defineShellLab("Time range picker", { + Model: Page.Model, + Message: Page.Message, + Flags: Page.Flags, + flags: Page.flags, + init: Page.init, + update: Page.update, + view: Page.view, + currentPath: "/lab/time-range", + breadcrumbs: [{ label: "Lab" }, { label: "Time range picker" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/timeline.ts b/apps/web-foldkit/src/lab/entries/timeline.ts new file mode 100644 index 0000000000..b145224c28 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/timeline.ts @@ -0,0 +1,8 @@ +import { defineLab } from "../define" +import { Model } from "../timeline-lab/model" +import { init, update } from "../timeline-lab/update" +import { view } from "../timeline-lab/view" + +// `/lab/timeline` (apps/web `lab/timeline-lab.tsx`): the trace view tabs over a synthetic trace. + +export const lab = defineLab("Trace timeline", { Model, init, update, view }) diff --git a/apps/web-foldkit/src/lab/entries/verdict.ts b/apps/web-foldkit/src/lab/entries/verdict.ts new file mode 100644 index 0000000000..f5186bbb69 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/verdict.ts @@ -0,0 +1,46 @@ +import { VERDICT_LAB_CASES } from "@maple/web/lab/verdict-fixture" +import { Clock, Effect, Schema } from "effect" +import { dashboardPage } from "../../page/kit/dashboard-page" +import { verdictCard } from "../../page/investigations/detail/view-verdict" +import { defineShellLab } from "../shell-lab" + +// `/lab/verdict` (apps/web `lab/verdict-lab.tsx`): the verdict card and run-progress feed in every +// state an investigation presents them in, inside the real shell. + +const Model = Schema.Struct({ nowMs: Schema.Number }) + +export const lab = defineShellLab("Verdict", { + Model, + Message: Schema.Never, + Flags: Model, + /** `useTickingNow`'s first reading. */ + flags: Effect.map(Clock.currentTimeMillis, (nowMs) => ({ nowMs })), + init: (flags) => ({ model: flags }), + update: (model) => ({ model }), + view: (model, h) => + dashboardPage(h, {}, [ + h.div( + [h.Class("flex flex-col gap-10 py-2")], + VERDICT_LAB_CASES.map((entry) => + h.section( + [h.Key(entry.key), h.Class("flex flex-col gap-3")], + [ + h.header( + [h.Class("flex flex-col gap-1")], + [ + h.h2([h.Class("text-sm font-semibold text-foreground")], [entry.title]), + h.p( + [h.Class("max-w-3xl text-xs leading-5 text-muted-foreground")], + [entry.note], + ), + ], + ), + verdictCard(h, entry.investigation, model.nowMs), + ], + ), + ), + ), + ]), + currentPath: "/lab/verdict", + breadcrumbs: [{ label: "Verdict Lab" }], +}) diff --git a/apps/web-foldkit/src/lab/entries/widgets.ts b/apps/web-foldkit/src/lab/entries/widgets.ts new file mode 100644 index 0000000000..50208803c4 --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/widgets.ts @@ -0,0 +1,54 @@ +import { Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import * as Shell from "../../shell" +import { liveQueryStream } from "../../data/live-query" +import { dashboardsOf } from "../../page/dashboards/kit/store" +import type { Resources } from "../../platform/resources" +import { ViewerFlags, viewerFlags } from "../../ui/logs/viewer" +import { LabShellFlags, labShellFlags } from "../app-shell" +import { defineLabWithFlags } from "../define" +import { Message, Model } from "../widget-lab/model" +import { init, update } from "../widget-lab/update" +import { view } from "../widget-lab/view" + +/** `/lab/widgets`: every dashboard widget renderer over its bundled sample data, in the app shell. */ +const toShell = (message: Shell.Message) => Message.GotShellMessage({ message }) + +const subscriptions = Subscription.make()((entry) => ({ + shellViewport: entry( + {}, + { modelToDependencies: () => ({}), dependenciesToStream: () => Stream.map(Shell.viewportChanges, toShell) }, + ), + shellSidebarShortcut: entry( + {}, + { modelToDependencies: () => ({}), dependenciesToStream: () => Stream.map(Shell.sidebarShortcut, toShell) }, + ), + // The widget modules start the org's dashboards sync in apps/web; the rows are not read here. + dashboards: entry( + { orgId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: (model) => ({ orgId: model.orgId }), + dependenciesToStream: ({ orgId }) => + orgId === null + ? Stream.empty + : Stream.unwrap( + Effect.map(dashboardsOf(orgId), (collection) => + liveQueryStream( + (q) => q.from({ d: collection }), + () => Message.UpdatedDashboards({ orgId }), + ), + ), + ), + }, + ), +})) + +export const lab = defineLabWithFlags("Widgets", { + Model, + Flags: Schema.Struct({ ...ViewerFlags.fields, ...LabShellFlags.fields }), + flags: Effect.map(Effect.all([viewerFlags, labShellFlags]), ([viewer, shell]) => ({ ...viewer, ...shell })), + init, + update, + view, + subscriptions, +}) diff --git a/apps/web-foldkit/src/lab/entries/worst-case.ts b/apps/web-foldkit/src/lab/entries/worst-case.ts new file mode 100644 index 0000000000..ae4d4a1c1f --- /dev/null +++ b/apps/web-foldkit/src/lab/entries/worst-case.ts @@ -0,0 +1,16 @@ +import { defineLabWithFlags } from "../define" +import { Flags, Model } from "../worst-case-lab/model" +import { init, update, worstCaseFlags } from "../worst-case-lab/update" +import { view } from "../worst-case-lab/view" + +// `/lab/worst-case` (apps/web `lab/worst-case-lab.tsx`): product components over worst-case data, +// a bare page as legacy's route renders it. + +export const lab = defineLabWithFlags("Worst case", { + Model, + Flags, + flags: worstCaseFlags, + init, + update, + view, +}) diff --git a/apps/web-foldkit/src/lab/errors-lab/hub-step.ts b/apps/web-foldkit/src/lab/errors-lab/hub-step.ts new file mode 100644 index 0000000000..76db45e921 --- /dev/null +++ b/apps/web-foldkit/src/lab/errors-lab/hub-step.ts @@ -0,0 +1,83 @@ +import { buildErrorsLabFixture } from "@maple/web/lab/errors-fixture" +import type { ErrorSignal } from "@maple/web/lib/models/error-signal" +import { OrgId } from "@maple/domain" +import { Option, Schema } from "effect" +import type { Command } from "foldkit" +import { fromString as urlFromString } from "foldkit/url" +import type { PageContext } from "../../page/contract" +import { severityOf, sortOf, sortSignals, viewCovers, viewOf } from "../../page/errors/hub/derive" +import type { Message as HubMessage } from "../../page/errors/hub/message" +import type { Model as HubModel } from "../../page/errors/hub/model" +import * as HubUpdate from "../../page/errors/hub/update" +import type { Resources } from "../../platform/resources" +import { urlToAppRoute } from "../../route" +import type { LabState } from "./model" + +/** The hub runs against a fixture org; its window stays unresolved, so it never fetches the list. */ +export const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab_errors"), + pinnedNamespace: null, + timeZone: "UTC", +} + +export const initHub = (nowMs: number): HubModel => ({ + ...HubUpdate.init({ search: {} }, LAB_CONTEXT).model, + nowMs, +}) + +/** The fixture through the lab state, then the view, severity and search filters, sorted. */ +export const labSignals = (hub: HubModel, state: LabState): ReadonlyArray => { + if (state === "empty") return [] + const view = viewOf(hub.search) + const severity = severityOf(hub.search) + const term = (hub.search.q ?? "").toLowerCase() + const signals = buildErrorsLabFixture(hub.nowMs).signals.filter( + (signal) => + viewCovers(view, signal.issue.workflowState) && + (severity === "all" || + (severity === "unset" ? signal.severity === null : signal.severity === severity)) && + // Mirrors the server's ILIKE over type, message, label and service. + (term === "" || + [ + signal.issue.exceptionType, + signal.issue.exceptionMessage, + signal.issue.errorLabel, + signal.issue.serviceName, + ].some((field) => field.toLowerCase().includes(term))), + ) + return sortSignals(signals, sortOf(hub.search)) +} + +export type HubStep = Readonly<{ + hub: HubModel + commands: ReadonlyArray> +}> + +/** A navigation the hub asked for, applied to its own `search` the way a route change would. */ +const followNavigation = (hub: HubModel, url: string): HubModel => + Option.match(urlFromString(new URL(url, "http://localhost").href), { + onNone: () => hub, + onSome: (parsed) => { + const route = urlToAppRoute(parsed) + return route._tag === "Errors" ? HubUpdate.routeChanged(hub, route, LAB_CONTEXT).model : hub + }, + }) + +/** The hub's own update, its URL writes kept in the lab; the header checkbox reads the lab's rows. */ +export const hubStep = (hub: HubModel, message: HubMessage, signals: ReadonlyArray): HubStep => { + if (message._tag === "ToggledAllRows") { + const ids = signals.map((signal) => signal.id) + const isAll = ids.length > 0 && ids.every((id) => hub.selectedIds.includes(id)) + const selectedIds = isAll ? [] : [...new Set([...hub.selectedIds, ...ids])] + return { hub: { ...hub, selectedIds, selectionAnchor: null }, commands: [] } + } + const next = HubUpdate.update(hub, message, LAB_CONTEXT) + const out = next.outMessage + return { + hub: + out !== undefined && out._tag === "RequestedNavigation" + ? followNavigation(next.model, out.url) + : next.model, + commands: next.commands ?? [], + } +} diff --git a/apps/web-foldkit/src/lab/errors-lab/model.ts b/apps/web-foldkit/src/lab/errors-lab/model.ts new file mode 100644 index 0000000000..ff1432c973 --- /dev/null +++ b/apps/web-foldkit/src/lab/errors-lab/model.ts @@ -0,0 +1,35 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { Message as HubMessage } from "../../page/errors/hub/message" +import { Model as HubModel } from "../../page/errors/hub/model" + +// `/lab/errors` (apps/web `lab/errors-lab.tsx`): the errors hub over a fixture holding every row +// state at once, with the lab's State and Width toggles. Shell-free; the entry wraps it. + +export const LAB_STATES = ["ready", "loading", "failed", "empty"] as const +export const LabState = Schema.Literals(LAB_STATES) +export type LabState = typeof LabState.Type + +/** Widths worth checking: the row's lanes are container queries. */ +export const LAB_WIDTHS = [ + { label: "Full", value: null }, + { label: "1100px", value: 1100 }, + { label: "820px", value: 820 }, + { label: "600px", value: 600 }, +] as const + +export const Model = Schema.Struct({ + timeZone: Schema.String, + /** The hub's toolbar and row state; its `search` holds the lab's view, sort, severity and query. */ + hub: HubModel, + state: LabState, + width: Schema.NullOr(Schema.Number), +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage }, + ClickedState: { state: LabState }, + ClickedWidth: { width: Schema.NullOr(Schema.Number) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/errors-lab/update.ts b/apps/web-foldkit/src/lab/errors-lab/update.ts new file mode 100644 index 0000000000..9cc615403b --- /dev/null +++ b/apps/web-foldkit/src/lab/errors-lab/update.ts @@ -0,0 +1,40 @@ +import { Clock, Effect, Schema } from "effect" +import { Command, type Update } from "foldkit" +import type { Resources } from "../../platform/resources" +import { hubStep, initHub, labSignals } from "./hub-step" +import { Message, type Model } from "./model" + +export type Return = Update.Return + +export const ErrorsLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export type ErrorsLabFlags = typeof ErrorsLabFlags.Type + +/** The boot clock (legacy's one `Date.now()` per mount) and the browser's zone. */ +export const errorsLabFlags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), +}) + +export const init = (flags: ErrorsLabFlags): Model => ({ + timeZone: flags.timeZone, + hub: initHub(flags.nowMs), + state: "ready", + width: null, +}) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: inner }) => { + // The retry card's button: legacy's `onRetry` puts the lab back to ready. + if (inner._tag === "ClickedRetry") return { model: { ...model, state: "ready" } } + const next = hubStep(model.hub, inner, labSignals(model.hub, model.state)) + return { + model: { ...model, hub: next.hub }, + commands: Command.mapMessages(next.commands, (child) => + Message.GotHubMessage({ message: child }), + ), + } + }, + ClickedState: ({ state }) => ({ model: { ...model, state } }), + ClickedWidth: ({ width }) => ({ model: { ...model, width } }), + }) diff --git a/apps/web-foldkit/src/lab/errors-lab/view.ts b/apps/web-foldkit/src/lab/errors-lab/view.ts new file mode 100644 index 0000000000..ce45399de1 --- /dev/null +++ b/apps/web-foldkit/src/lab/errors-lab/view.ts @@ -0,0 +1,154 @@ +import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { buildErrorsLabFixture } from "@maple/web/lab/errors-fixture" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { dashboardPage } from "../../page/kit/dashboard-page" +import { listBody } from "../../page/errors/hub/view-list" +import { toolbar } from "../../page/errors/hub/view-toolbar" +import type { Message as HubMessage } from "../../page/errors/hub/message" +import type { Model as HubModel } from "../../page/errors/hub/model" +import { eyebrow } from "../../ui/eyebrow" +import { isMounted } from "../../ui/overlay/transition" +import { labSignals } from "./hub-step" +import { LAB_STATES, LAB_WIDTHS, type LabState, Message, type Model } from "./model" + +// apps/web `lab/errors-lab.tsx`: `LabControls`, `LabStatStrip` and `ErrorsHubView` over the fixture. +// Shell-free (the entry wraps it), so scene tests can render it. + +type Option = Readonly<{ value: T; label: string }> + +/** `LabToggleGroup`. */ +const toggleGroup = ( + h: HtmlBuilder, + label: string, + options: ReadonlyArray>, + active: T, + onChange: (value: T) => M, +): Html => + h.div( + [h.Class("flex items-center gap-1.5")], + [ + eyebrow(h, {}, [label]), + h.div( + [h.Class("flex items-center gap-0.5 rounded-md border border-border/60 p-0.5")], + options.map((option) => + h.button( + [ + h.Type("button"), + h.OnClick(onChange(option.value)), + h.Class( + cn( + "h-6 rounded px-2 text-xs transition-colors", + option.value === active + ? "bg-muted text-foreground" + : "text-muted-foreground hover:text-foreground", + ), + ), + ], + [option.label], + ), + ), + ), + ], + ) + +/** The stat strip's success branch over fixture numbers. */ +const labStatStrip = (h: HtmlBuilder, summary: { totalErrors: number; errorRate: number }): Html => + h.div( + [h.Class("flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-2 text-xs")], + [ + ...[ + { value: formatNumber(summary.totalErrors), label: "error events" }, + { value: formatErrorRate(summary.errorRate), label: "of all spans" }, + ].map((stat) => + h.span( + [h.Class("flex items-baseline gap-1.5")], + [ + h.span([h.Class("font-medium tabular-nums text-foreground")], [stat.value]), + h.span([h.Class("text-muted-foreground")], [stat.label]), + ], + ), + ), + h.span([h.Class("text-muted-foreground/60")], ["in the last 24 hours"]), + ], + ) + +type HubInputs = Readonly<{ state: LabState; timeZone: string }> + +/** `ErrorsHubView`: the stat strip and toolbar over the skeleton, retry card, rows or empty state. */ +const hubView = Submodel.defineView((hub, inputs, h) => { + const fixture = buildErrorsLabFixture(hub.nowMs) + const signals = labSignals(hub, inputs.state) + const status = inputs.state === "ready" || inputs.state === "empty" ? "ready" : inputs.state + const isSelecting = hub.selectedIds.length > 0 + // Split where the JSX interpolates, so the text runs match. + const count = + status === "ready" ? [String(signals.length), " ", signals.length === 1 ? "issue" : "issues"] : null + return h.div( + [], + [ + labStatStrip(h, fixture.summary), + toolbar(h, hub, count), + ...listBody(h, hub, { + status, + signals, + isNarrowed: false, + rowState: (signal) => ({ + isSelected: hub.selectedIds.includes(signal.id), + isSelecting, + isFocused: hub.focusedId === signal.id, + timeZone: inputs.timeZone, + nowMs: hub.nowMs, + sparkWindow: fixture.sparkWindow, + picker: hub.picker !== null && hub.picker.issueId === signal.id ? hub.picker : null, + rowMenu: + hub.rowMenuTarget === signal.id && isMounted(hub.rowMenu.isOpen, hub.rowMenu.animation) + ? hub.rowMenu + : null, + }), + }), + ], + ) +}) + +/** `DashboardLayout.Body` of the lab: the controls in the header, the hub in a width-capped column. */ +export const view = Submodel.defineView((model, h) => + dashboardPage( + h, + { + actions: h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + toggleGroup( + h, + "State", + LAB_STATES.map((value) => ({ value, label: value })), + model.state, + (state) => Message.ClickedState({ state }), + ), + toggleGroup(h, "Width", LAB_WIDTHS, model.width, (width) => + Message.ClickedWidth({ width }), + ), + ], + ), + }, + [ + h.div( + [ + h.Class("@container/page mx-auto w-full min-w-0"), + ...(model.width === null ? [] : [h.Style({ maxWidth: `${model.width}px` })]), + ], + [ + h.submodel({ + slotId: "errors-lab-hub", + model: model.hub, + view: hubView, + viewInputs: { state: model.state, timeZone: model.timeZone }, + toParentMessage: (message) => Message.GotHubMessage({ message }), + }), + ], + ), + ], + ), +) diff --git a/apps/web-foldkit/src/lab/flow-lab.ts b/apps/web-foldkit/src/lab/flow-lab.ts new file mode 100644 index 0000000000..5711fde0ed --- /dev/null +++ b/apps/web-foldkit/src/lab/flow-lab.ts @@ -0,0 +1,50 @@ +import { FLOW_LAB_TRACE } from "@maple/web/lab/flow-fixture" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { dashboardPage } from "../page/kit/dashboard-page" +import { findSpanById } from "../service-map/trace-flow/flow-graph" +import * as TraceFlow from "../service-map/trace-flow/trace-flow" +import { view as traceFlowView } from "../service-map/trace-flow/view" + +// apps/web `lab/flow-lab.tsx` under the shell: the synthetic trace in the flow view, with the +// selected span's name in the header. Shell-free so scene tests can render it. + +export const FLOW_LAB_GRAPH = TraceFlow.buildFlowGraph({ + rootSpans: FLOW_LAB_TRACE.rootSpans, + totalDurationMs: FLOW_LAB_TRACE.totalDurationMs, + services: FLOW_LAB_TRACE.services, +}) + +const selectedName = (model: TraceFlow.Model): string => + Option.match(model.selectedSpanId, { + onNone: () => "none", + onSome: (spanId) => findSpanById([...FLOW_LAB_TRACE.rootSpans], spanId)?.spanName ?? "none", + }) + +export const pageBody = ( + h: HtmlBuilder, + flow: TraceFlow.Model, + toMessage: (message: TraceFlow.Message) => M, +): Html => + dashboardPage(h, {}, [ + h.div( + [h.Class("flex h-full flex-col")], + [ + h.div( + [h.Class("border-b px-4 py-3")], + [ + h.h1([h.Class("text-sm font-semibold")], ["Flow Lab"]), + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + "Synthetic trace exercising every Flow view card variant. Selected:", + " ", + h.span([h.Class("font-mono")], [selectedName(flow)]), + ], + ), + ], + ), + h.div([h.Class("min-h-0 flex-1")], [traceFlowView(h, FLOW_LAB_GRAPH, flow, toMessage)]), + ], + ), + ]) diff --git a/apps/web-foldkit/src/lab/gcp-connect-lab/fixture.ts b/apps/web-foldkit/src/lab/gcp-connect-lab/fixture.ts new file mode 100644 index 0000000000..f98f5a4551 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connect-lab/fixture.ts @@ -0,0 +1,284 @@ +import type { V2GcpConnector, V2GcpSetupScripts } from "@maple/domain/http/v2" +import { AsyncData } from "foldkit" +import type { Keyed } from "../../data/keyed" +import { type LoadFailure, toLoadFailure } from "../../data/load-failure" +import { GCP_SETUP_FIXTURES } from "@maple/web/lab/gcp-setup-fixtures" +import type { ApplyOpened, LogFilterDraft, SetupMethod } from "../../page/integrations/gcp-configure-schema" +import { agoOf, connectorOf } from "../gcp-host" + +// apps/web `lab/gcp-connect-lab.tsx`'s fixtures: minutes old against the lab's clock. + +export const STATES = { + new: { + label: "New connection", + note: "Interactive. Continue on the Data step calls the API and fails here.", + }, + configure: { label: "Configure", note: "An existing connection's Data step." }, + waiting: { label: "Run · waiting", note: "Just created, nothing reported yet." }, + reporting: { label: "Run · reporting", note: "Log forwarding reported, metrics still running." }, + overdue: { label: "Run · overdue", note: "Six minutes without a report." }, + removing: { label: "Run · org, removing logs", note: "An organization turning log forwarding off." }, + failed: { label: "Run · script failed", note: "The script couldn't be loaded." }, + connected: { label: "Connected", note: "First run confirmed, data not in yet." }, + applied: { label: "Applied", note: "A reconfigured organization, receiving." }, + method: { label: "Method", note: "The Method step, on the script." }, + "method-code": { label: "Method · code", note: "Infrastructure as code picked, Terraform." }, + "manual-project": { label: "Manual · project", note: "A project with logs and metrics, nothing confirmed." }, + "manual-org": { label: "Manual · org logs", note: "An organization forwarding logs only." }, + "manual-failed": { + label: "Manual · metrics failing", + note: "Logs arrive; past the grant's grace, the poller's read is still denied.", + }, + "manual-removal": { label: "Manual · removal", note: "Log forwarding switched off: what to delete." }, + terraform: { label: "Terraform", note: "Click Copy prompt for coding agent for the prompt." }, + "terraform-removal": { label: "Terraform · removal", note: "The regenerated file without log forwarding." }, + alchemy: { label: "Alchemy", note: "The same project as an Alchemy stack." }, + "alchemy-org": { + label: "Alchemy · org metrics", + note: "An organization's read roles, granted by the file's own ScopeIamMember resource.", + }, + "method-confirmed": { + label: "Manual · confirmed", + note: "Both confirmed: the steps stay in view for a change or a repair.", + }, +} as const + +export const LAB_STATES = [ + "new", + "configure", + "waiting", + "reporting", + "overdue", + "removing", + "failed", + "connected", + "applied", + "method", + "method-code", + "manual-project", + "manual-org", + "manual-failed", + "manual-removal", + "terraform", + "terraform-removal", + "alchemy", + "alchemy-org", + "method-confirmed", +] as const satisfies ReadonlyArray +export type LabState = (typeof LAB_STATES)[number] +export type MethodState = "method" | "method-code" +export type MethodApplyState = Exclude +export type ScriptState = "waiting" | "reporting" | "overdue" | "removing" | "failed" | "connected" | "applied" + +export const SCRIPT = `#!/usr/bin/env bash +# Maple setup for Google Cloud project acme-prod +# Log forwarding: on +# Metrics and resources: on +# Safe to run again: it sets up what is on and removes what is off. +# Keep it private: PUSH_ENDPOINT contains this connection's secret. +set -euo pipefail +# gcloud would log every argument, the secret too, and the access token to ~/.config/gcloud/logs. +export CLOUDSDK_CORE_DISABLE_PROMPTS=1 CLOUDSDK_CORE_DISABLE_FILE_LOGGING=1 + +# ---- Which logs are forwarded ---- +LOG_FILTER_MODE='set' +LOG_FILTER='severity>=DEFAULT' +PROJECT='acme-prod' +PUSH_ENDPOINT='https://ingest.maple.dev/v1/logpush/gcp/gcpc_YofPTrK9782DWwcnXhpcCw?secret=lab-fixture' +SINK_CREATED=0 +ACCOUNT_CREATED=0 + +gcloud services enable pubsub.googleapis.com logging.googleapis.com monitoring.googleapis.com \\ + cloudasset.googleapis.com --project="$PROJECT" +gcloud pubsub topics create maple-logs-7f3k --project="$PROJECT" +gcloud logging sinks create maple-7f3k "pubsub.googleapis.com/projects/$PROJECT/topics/maple-logs-7f3k" \\ + --log-filter="$LOG_FILTER" --project="$PROJECT" +gcloud iam service-accounts create maple-7f3k --display-name="Maple (read-only)" --project="$PROJECT" +` + +/** `GcpApplyView`'s props as the run step's state: `logFilter` is the draft that reads as it. */ +export type ApplyFixture = Readonly<{ + connector: V2GcpConnector + created: boolean + filter: LogFilterDraft + scripts: Keyed + opened: ApplyOpened +}> + +export type Fixtures = Readonly<{ + configured: V2GcpConnector + apply: Readonly> + method: Readonly>> + methodApply: Readonly> +}> + +/** `GcpMethodApplyView`'s props as state: the run step's, with the method. */ +export type MethodApplyFixture = ApplyFixture & Readonly<{ method: SetupMethod }> + +const PLATFORM_LOGS: LogFilterDraft = { keep: null, runtimes: null } +const CURRENT_FILTER: LogFilterDraft = { keep: null, runtimes: null } +/** Once the sink exists, "Platform logs" is a replaced filter. */ +const REPLACED_PLATFORM_LOGS: LogFilterDraft = { keep: false, runtimes: null } + +type SetupFields = (typeof GCP_SETUP_FIXTURES)[keyof typeof GCP_SETUP_FIXTURES] + +const script = (text: string, setup: SetupFields = GCP_SETUP_FIXTURES.project): Keyed => ({ + key: null, + data: AsyncData.Success({ + data: { object: "gcp_connector.setup_scripts", setup_script: text, cleanup_script: "", ...setup }, + }), +}) + +/** "Couldn't load the script.": an unexpected failure, as the run step words it. */ +const FAILED_SCRIPT: Keyed = { + key: null, + data: AsyncData.Failure({ error: toLoadFailure("lab fixture: the script read failed") }), +} + +type Over = Readonly> + +/** `METHOD_APPLY`: "Platform logs" over an existing sink is a replaced filter. */ +const methodApplyFixtures = ( + connector: (over?: Over) => V2GcpConnector, + organization: Over, + ago: (minutes: number) => string, + opened: ApplyOpened, +): Fixtures["methodApply"] => { + const removingLogs = connector({ + ...organization, + logs_enabled: false, + applied_logs_enabled: true, + applied_metrics_enabled: true, + setup_reported_at: ago(60 * 24), + last_metrics_received_at: ago(3), + discovered_project_count: 14, + }) + const fixture = ( + shown: V2GcpConnector, + method: SetupMethod, + setup: SetupFields, + created = true, + ): MethodApplyFixture => ({ + connector: shown, + created, + filter: shown.applied_logs_enabled === true ? REPLACED_PLATFORM_LOGS : PLATFORM_LOGS, + scripts: script("", setup), + opened, + method, + }) + const failing = + "Google denied Maple's read of project acme-prod: the read-only roles are missing. Grant them with the setup script, the console steps or your Terraform. A new grant can take a few minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED)" + return { + "manual-project": fixture(connector(), "manual", GCP_SETUP_FIXTURES.project), + "manual-org": fixture( + connector({ ...organization, created_at: ago(2), metrics_enabled: false }), + "manual", + GCP_SETUP_FIXTURES.organization, + ), + "manual-failed": fixture( + connector({ created_at: ago(20), applied_logs_enabled: true, last_log_received_at: ago(0.2), last_metrics_error: failing }), + "manual", + GCP_SETUP_FIXTURES.project, + ), + "manual-removal": fixture(removingLogs, "manual", GCP_SETUP_FIXTURES.organizationMetrics, false), + terraform: fixture(connector(), "terraform", GCP_SETUP_FIXTURES.project), + "terraform-removal": fixture(removingLogs, "terraform", GCP_SETUP_FIXTURES.organizationMetrics, false), + alchemy: fixture(connector(), "alchemy", GCP_SETUP_FIXTURES.project), + "alchemy-org": fixture( + connector({ ...organization, created_at: ago(1), logs_enabled: false, applied_logs_enabled: false }), + "alchemy", + GCP_SETUP_FIXTURES.organizationMetrics, + ), + "method-confirmed": fixture( + connector({ + applied_logs_enabled: true, + applied_metrics_enabled: true, + setup_reported_at: ago(0.5), + last_log_received_at: ago(0.3), + }), + "manual", + GCP_SETUP_FIXTURES.project, + ), + } +} + +export const fixturesAt = (nowMs: number): Fixtures => { + const ago = agoOf(nowMs) + const base = { + id: "gcpc_YofPTrK9782DWwcnXhpcCw", + object: "gcp_connector", + scope_type: "project", + scope_id: "acme-prod", + project_id: "acme-prod", + logs_enabled: true, + metrics_enabled: true, + created_at: ago(1), + last_log_received_at: null, + last_log_error: null, + applied_logs_enabled: null, + applied_metrics_enabled: null, + setup_reported_at: null, + last_metrics_received_at: null, + last_metrics_error: null, + discovered_project_count: 0, + last_resources_error: null, + } + const connector = (over: Readonly> = {}) => connectorOf(base, over) + const organization = { + scope_type: "organization", + scope_id: "123456789012", + project_id: "acme-observability", + created_at: ago(60 * 24 * 7), + } + const receiving = { + ...organization, + applied_logs_enabled: true, + applied_metrics_enabled: true, + last_log_received_at: ago(1), + last_metrics_received_at: ago(3), + discovered_project_count: 14, + } + const firstRun: ApplyOpened = { at: nowMs - 30_000, rerun: false, seen: null } + const created = (over: Readonly>, filter: LogFilterDraft): ApplyFixture => ({ + connector: connector(over), + created: true, + filter, + scripts: script(SCRIPT), + opened: firstRun, + }) + return { + configured: connector({ ...receiving, setup_reported_at: ago(60 * 24) }), + apply: { + waiting: created({}, PLATFORM_LOGS), + reporting: created( + { applied_logs_enabled: true, setup_reported_at: ago(0.3) }, + REPLACED_PLATFORM_LOGS, + ), + overdue: created({ created_at: ago(6) }, { keep: null, runtimes: ["gke"] }), + removing: { + connector: connector({ ...receiving, logs_enabled: false, setup_reported_at: ago(60 * 24) }), + created: false, + filter: CURRENT_FILTER, + scripts: script(SCRIPT), + opened: { at: nowMs - 30_000, rerun: false, seen: ago(60 * 24) }, + }, + failed: { ...created({}, PLATFORM_LOGS), scripts: FAILED_SCRIPT }, + connected: created( + { applied_logs_enabled: true, applied_metrics_enabled: true, setup_reported_at: ago(3) }, + REPLACED_PLATFORM_LOGS, + ), + applied: { + connector: connector({ ...receiving, setup_reported_at: ago(4) }), + created: false, + filter: CURRENT_FILTER, + scripts: script(SCRIPT), + opened: { at: nowMs - 8 * 60_000, rerun: true, seen: ago(60 * 24) }, + }, + }, + method: { + method: { connector: connector(), method: "script" }, + "method-code": { connector: connector(), method: "terraform" }, + }, + methodApply: methodApplyFixtures(connector, organization, ago, firstRun), + } +} diff --git a/apps/web-foldkit/src/lab/gcp-connect-lab/model.ts b/apps/web-foldkit/src/lab/gcp-connect-lab/model.ts new file mode 100644 index 0000000000..88f122908a --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connect-lab/model.ts @@ -0,0 +1,33 @@ +import { V2GcpSetupScripts } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { KeyedSchema } from "../../data/keyed" +import { LoadFailure } from "../../data/load-failure" +import { Message as IntegrationsMessage } from "../../page/integrations/message" +import { Model as IntegrationsModel } from "../../page/integrations/model" +import * as ToggleGroup from "../../ui/toggle-group" +import { LAB_STATES } from "./fixture" + +// `/lab/gcp-connect` (apps/web `lab/gcp-connect-lab.tsx`): every step and run state of the Google +// Cloud connect dialog over fixture connectors. The dialog is the integrations page's own. + +export const LabState = Schema.Literals(LAB_STATES) + +export const Model = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + state: LabState, + stateGroup: ToggleGroup.Model, + /** The page's Model; the dialog is open while its `gcpCard.configure` is set. */ + hub: IntegrationsModel, + /** A run state's script read, held as legacy's `script` prop; null on the interactive states. */ + pinnedScripts: Schema.NullOr(KeyedSchema(V2GcpSetupScripts, LoadFailure)), +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotStateGroupMessage: { message: ToggleGroup.Message }, + ClickedReopen: {}, + GotHubMessage: { message: IntegrationsMessage }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/gcp-connect-lab/update.test.ts b/apps/web-foldkit/src/lab/gcp-connect-lab/update.test.ts new file mode 100644 index 0000000000..1cfa20a825 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connect-lab/update.test.ts @@ -0,0 +1,62 @@ +import { AsyncData } from "foldkit" +import { describe, expect, test } from "vitest" +import { Message as IntegrationsMessage } from "../../page/integrations/message" +import * as ToggleGroup from "../../ui/toggle-group" +import { LAB_STATES } from "./fixture" +import { Message } from "./model" +import { init, update } from "./update" + +const NOW = Date.UTC(2026, 9, 18, 12) +const opened = () => init({ nowMs: NOW, timeZone: "UTC" }).model + +const pick = (model: ReturnType, state: (typeof LAB_STATES)[number]) => + update( + model, + Message.GotStateGroupMessage({ + message: ToggleGroup.Message.ClickedItem({ value: state, index: LAB_STATES.indexOf(state) }), + }), + ).model + +describe("gcp connect lab", () => { + test("opens on a new connection's first step, with the dialog open", () => { + const model = opened() + expect(model.state).toBe("new") + expect(model.hub.gcpCard.configure?.target._tag).toBe("New") + expect(model.hub.gcpCard.configure?.dialog.isOpen).toBe(true) + }) + + test("a run state opens the run step on its fixture script", () => { + const model = pick(opened(), "failed") + const configure = model.hub.gcpCard.configure + expect(model.stateGroup.values).toEqual(["failed"]) + expect(configure?.opened).not.toBeNull() + expect( + configure !== null && configure !== undefined && AsyncData.isFailure(configure.scripts.data), + ).toBe(true) + }) + + test("pressing the current state again keeps it pressed", () => { + const model = pick(opened(), "new") + expect([model.state, model.stateGroup.values]).toEqual(["new", ["new"]]) + }) + + test("Close unmounts the wizard and Reopen brings it back from its first step", () => { + const waiting = pick(opened(), "waiting") + const closed = update( + waiting, + Message.GotHubMessage({ message: IntegrationsMessage.ClosedGcpConfigure() }), + ).model + expect(closed.hub.gcpCard.configure).toBeNull() + const reopened = update(closed, Message.ClickedReopen()).model + expect(reopened.hub.gcpCard.configure?.opened?.rerun).toBe(false) + }) + + test("a run fixture's Back and Try again do nothing, as legacy's no-ops", () => { + const failed = pick(opened(), "failed") + const retried = update( + failed, + Message.GotHubMessage({ message: IntegrationsMessage.ClickedGcpConfigureScriptRetry() }), + ) + expect(retried.model).toBe(failed) + }) +}) diff --git a/apps/web-foldkit/src/lab/gcp-connect-lab/update.ts b/apps/web-foldkit/src/lab/gcp-connect-lab/update.ts new file mode 100644 index 0000000000..81ff049cfb --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connect-lab/update.ts @@ -0,0 +1,175 @@ +import type { V2GcpConnector } from "@maple/domain/http/v2" +import { Command, type Update } from "foldkit" +import { initConfigure } from "../../page/integrations/gcp-configure" +import type { ConfigureModel } from "../../page/integrations/gcp-configure-schema" +import { Message as IntegrationsMessage } from "../../page/integrations/message" +import type { Resources } from "../../platform/resources" +import * as Dialog from "../../ui/dialog" +import * as ToggleGroup from "../../ui/toggle-group" +import { type GcpLabFlags, hubOf, hubStep } from "../gcp-host" +import { fixturesAt, LAB_STATES, type LabState, type MethodApplyState } from "./fixture" +import { Message, type Model } from "./model" + +export type Return = Update.Return + +const toHub = (message: IntegrationsMessage): Message => Message.GotHubMessage({ message }) + +type Staged = Readonly<{ + connectors: ReadonlyArray + configure: ConfigureModel + pinnedScripts: Model["pinnedScripts"] +}> + +/** The wizard each state mounts: a new connection, a connection's Data step, or a run fixture. */ +const staged = (nowMs: number, state: LabState): Staged => { + const fixtures = fixturesAt(nowMs) + switch (state) { + case "new": + return { + connectors: [], + configure: initConfigure({ _tag: "New" }, [], true), + pinnedScripts: null, + } + case "configure": { + const connector = fixtures.configured + const target = { _tag: "Connection", id: connector.id, stage: "choose" } as const + return { + connectors: [connector], + configure: initConfigure(target, [connector], true), + pinnedScripts: null, + } + } + case "method": + case "method-code": { + const { connector, method } = fixtures.method[state] + const target = { _tag: "Connection", id: connector.id, stage: "apply" } as const + const base = initConfigure(target, [connector], true) + return { + connectors: [connector], + configure: { + ...base, + newTitle: "Connect Google Cloud", + created: connector, + connectionStage: "method", + method, + flavourGroup: { ...base.flavourGroup, values: [method === "alchemy" ? "alchemy" : "terraform"] }, + }, + pinnedScripts: null, + } + } + default: { + const fixture = isMethodApplyState(state) ? fixtures.methodApply[state] : fixtures.apply[state] + const method = isMethodApplyState(state) ? fixtures.methodApply[state].method : "script" + const connector = fixture.connector + const target = { _tag: "Connection", id: connector.id, stage: "apply" } as const + return { + connectors: [connector], + configure: { + ...initConfigure(target, [connector], true), + newTitle: "Connect Google Cloud", + created: fixture.created ? connector : null, + filter: fixture.filter, + opened: fixture.opened, + scripts: fixture.scripts, + method, + sinkExisted: connector.applied_logs_enabled === true, + }, + pinnedScripts: fixture.scripts, + } + } + } +} + +const METHOD_APPLY_STATES: ReadonlyArray = [ + "manual-project", + "manual-org", + "manual-failed", + "manual-removal", + "terraform", + "terraform-removal", + "alchemy", + "alchemy-org", + "method-confirmed", +] +const isMethodApplyState = (state: LabState): state is MethodApplyState => METHOD_APPLY_STATES.includes(state) + +/** `show(next)`: the state's dialog, remounted from its first step and open. */ +const show = (model: Model, state: LabState): Return => { + const { connectors, configure, pinnedScripts } = staged(model.nowMs, state) + const shown = Dialog.open(configure.dialog) + const hub = hubOf(model.nowMs, connectors) + return { + model: { + ...model, + state, + stateGroup: { ...model.stateGroup, values: [state] }, + pinnedScripts, + hub: { ...hub, gcpCard: { ...hub.gcpCard, configure: { ...configure, dialog: shown.model } } }, + }, + commands: Command.mapMessages(shown.commands ?? [], (message) => + toHub(IntegrationsMessage.GotGcpConfigureDialogMessage({ message })), + ), + } +} + +export const init = (flags: GcpLabFlags): Return => + show( + { + nowMs: flags.nowMs, + timeZone: flags.timeZone, + state: "new", + stateGroup: ToggleGroup.init({ label: "", values: ["new"] }), + hub: hubOf(flags.nowMs, []), + pinnedScripts: null, + }, + "new", + ) + +/** A run fixture's Back, "Try again" and "I've removed them" are legacy's `noop`s. */ +const isFixtureNoop = (message: IntegrationsMessage): boolean => + message._tag === "ClickedGcpRunEdit" || + message._tag === "ClickedGcpConfigureScriptRetry" || + message._tag === "ClickedGcpConfirmRemoval" + +const isMethodState = (state: LabState): boolean => state === "method" || state === "method-code" + +const hubMessage = (model: Model, message: IntegrationsMessage): Return => { + const pinned = model.pinnedScripts + if (pinned !== null && isFixtureNoop(message)) return { model } + // `MethodLab`: Back is a `noop`, Continue closes. + if (isMethodState(model.state) && message._tag === "ClickedGcpRunEdit") return { model } + if (isMethodState(model.state) && message._tag === "ClickedGcpMethodContinue") + return hubMessage(model, IntegrationsMessage.ClosedGcpConfigure()) + const next = hubStep(model.hub, message) + const configure = next.hub.gcpCard.configure + // A run fixture keeps its script, whatever the step would read: legacy passes it as a prop. + const hub = + pinned === null || configure === null + ? next.hub + : { ...next.hub, gcpCard: { ...next.hub.gcpCard, configure: { ...configure, scripts: pinned } } } + const commands = + pinned === null + ? next.commands + : next.commands.filter((command) => command.name !== "LoadGcpConfigureScripts") + return { model: { ...model, hub }, commands: Command.mapMessages(commands, toHub) } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + // Legacy's group is multiple: pressing the current state again changes nothing. + GotStateGroupMessage: ({ message: inner }) => { + const step = ToggleGroup.update(model.stateGroup, inner) + const commands = Command.mapMessages(step.commands ?? [], (child) => + Message.GotStateGroupMessage({ message: child }), + ) + const next = LAB_STATES.find( + (state) => state !== model.state && step.model.values.includes(state), + ) + if (next === undefined) + return { model: { ...model, stateGroup: { ...step.model, values: [model.state] } }, commands } + const shown = show({ ...model, stateGroup: step.model }, next) + return { ...shown, commands: [...commands, ...(shown.commands ?? [])] } + }, + ClickedReopen: () => show(model, model.state), + GotHubMessage: ({ message: inner }) => hubMessage(model, inner), + }) diff --git a/apps/web-foldkit/src/lab/gcp-connect-lab/view.ts b/apps/web-foldkit/src/lab/gcp-connect-lab/view.ts new file mode 100644 index 0000000000..3775274e09 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connect-lab/view.ts @@ -0,0 +1,70 @@ +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message as IntegrationsMessage } from "../../page/integrations/message" +import type { Model as IntegrationsModel } from "../../page/integrations/model" +import { gcpConfigure } from "../../page/integrations/view-gcp-configure" +import { button } from "../../ui/button" +import * as ToggleGroup from "../../ui/toggle-group" +import { LAB_STATES, STATES } from "./fixture" +import { Message, type Model } from "./model" + +type H = HtmlBuilder + +/** `GcpConfigure` / `GcpConfigureFrame`: the page's dialog, open while the wizard is. */ +const dialogView = Submodel.defineView< + IntegrationsModel, + IntegrationsMessage, + Readonly<{ timeZone: string }> +>((hub, inputs, h) => h.div([h.Class("contents")], gcpConfigure(h, hub, inputs.timeZone))) + +export const view = (model: Model, h: H): Html => { + const isOpen = model.hub.gcpCard.configure !== null + return h.div( + [h.Class("flex flex-col gap-4 p-6")], + [ + h.div( + [h.Class("flex flex-col gap-1")], + [ + h.h1([h.Class("text-lg font-medium")], ["Google Cloud connect flow"]), + h.p([h.Class("text-sm text-muted-foreground")], [STATES[model.state].note]), + ], + ), + h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + ToggleGroup.view(h, { + model: model.stateGroup, + toParentMessage: (message) => Message.GotStateGroupMessage({ message }), + items: LAB_STATES.map((state) => ({ value: state, label: STATES[state].label })), + variant: "outline", + isConnected: false, + }), + ...(isOpen + ? [] + : [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedReopen())], + }, + ["Reopen"], + ), + ]), + ], + ), + ...(isOpen + ? [ + h.submodel({ + slotId: "gcp-connect-lab-dialog", + model: model.hub, + view: dialogView, + viewInputs: { timeZone: model.timeZone }, + toParentMessage: (message) => Message.GotHubMessage({ message }), + }), + ] + : []), + ], + ) +} diff --git a/apps/web-foldkit/src/lab/gcp-connections-lab.test.ts b/apps/web-foldkit/src/lab/gcp-connections-lab.test.ts new file mode 100644 index 0000000000..c86bcca09f --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connections-lab.test.ts @@ -0,0 +1,38 @@ +import { gcpLogState } from "@maple/web/components/integrations/gcp-connector-state" +import { describe, expect, test } from "vitest" +import { Message as IntegrationsMessage } from "../page/integrations/message" +import { init, Message, update } from "./gcp-connections-lab" + +const NOW = Date.UTC(2026, 9, 18, 12) +const opened = () => init({ nowMs: NOW, timeZone: "UTC" }).model + +describe("gcp connections lab", () => { + test("each card gets its own connection id, so its menu and dialogs stay its own", () => { + const ids = opened().connectors.map((connector) => connector.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + test("the scenarios read as the states they are named for, against the lab clock", () => { + const model = opened() + const states = model.connectors.map((connector) => gcpLogState(connector, NOW).kind) + expect(states[0]).toBe("receiving") + expect(states[2]).not.toBe(states[0]) + }) + + test("Run the script again opens nothing, as legacy's no-op onConfigure", () => { + const model = opened() + const connector = model.connectors[4] + expect(connector?.last_log_error).not.toBeNull() + if (connector === undefined) return + const next = update( + model, + Message.GotHubMessage({ + message: IntegrationsMessage.ClickedGcpConfigure({ + target: { _tag: "Connection", id: connector.id, stage: "apply" }, + }), + }), + ) + expect(next.model.hub.gcpCard.configure).toBeNull() + expect(next.commands ?? []).toEqual([]) + }) +}) diff --git a/apps/web-foldkit/src/lab/gcp-connections-lab.ts b/apps/web-foldkit/src/lab/gcp-connections-lab.ts new file mode 100644 index 0000000000..4a2066757d --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-connections-lab.ts @@ -0,0 +1,165 @@ +import { V2GcpConnector } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { Command, Submodel, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as IntegrationsMessage } from "../page/integrations/message" +import { Model as IntegrationsModel } from "../page/integrations/model" +import { gcpConnection } from "../page/integrations/view-gcp-connection" +import { gcpDialogs } from "../page/integrations/view-gcp-dialogs" +import { dashboardPage } from "../page/kit/dashboard-page" +import type { Resources } from "../platform/resources" +import { agoOf, connectorOf, type GcpLabFlags, hubOf, hubStep, withIndexId } from "./gcp-host" + +// `/lab/gcp-connections` (apps/web `lab/gcp-connections-lab.tsx`): each state of a Google Cloud +// connection over fixture data, as an admin sees it. + +/** A project connection whose script ran an hour ago and that delivers both. */ +const baseConnector = (ago: (minutes: number) => string) => ({ + id: "gcpc_YofPTrK9782DWwcnXhpcCw", + object: "gcp_connector", + scope_type: "project", + scope_id: "maple-development-project", + project_id: "maple-development-project", + logs_enabled: true, + metrics_enabled: true, + created_at: ago(90), + last_log_received_at: ago(1), + last_log_error: null, + applied_logs_enabled: true, + applied_metrics_enabled: true, + setup_reported_at: ago(60), + last_metrics_received_at: ago(4), + last_metrics_error: null, + discovered_project_count: 1, + last_resources_error: null, +}) + +export const scenariosAt = (nowMs: number): ReadonlyArray<{ title: string; connector: V2GcpConnector }> => { + const ago = agoOf(nowMs) + const base = baseConnector(ago) + const scenarios: ReadonlyArray<{ title: string; over: Readonly> }> = [ + { title: "Healthy project", over: {} }, + { + title: "Healthy folder", + over: { + scope_type: "folder", + scope_id: "482910374615", + project_id: "maple-shared-logging", + discovered_project_count: 13, + last_log_received_at: ago(0.2), + last_metrics_received_at: ago(2), + }, + }, + { + title: "Waiting for the first data", + over: { setup_reported_at: ago(3), last_log_received_at: null, last_metrics_received_at: null }, + }, + { + title: "Overdue", + over: { setup_reported_at: ago(24), last_log_received_at: null, last_metrics_received_at: null }, + }, + { + title: "Log forwarding failing", + over: { + last_log_received_at: ago(120), + last_log_error: + "The log sink can't publish to Maple's topic. Run the setup script again: it restores the permission. Google answered: PERMISSION_DENIED: pubsub.topics.publish.", + last_metrics_received_at: ago(2), + }, + }, + { + title: "Setup pending", + over: { + applied_logs_enabled: null, + applied_metrics_enabled: null, + setup_reported_at: null, + last_log_received_at: null, + last_metrics_received_at: null, + }, + }, + ] + return scenarios.map(({ title, over }, index) => ({ + title, + connector: withIndexId(connectorOf(base, over), index), + })) +} + +export const Model = Schema.Struct({ + timeZone: Schema.String, + titles: Schema.Array(Schema.String), + /** The cards' connectors, fixed as legacy's props whatever the page's status read becomes. */ + connectors: Schema.Array(V2GcpConnector), + hub: IntegrationsModel, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotHubMessage: { message: IntegrationsMessage } }) +export type Message = typeof Message.Type + +export type Return = Update.Return + +export const init = (flags: GcpLabFlags): Return => { + const scenarios = scenariosAt(flags.nowMs) + const connectors = scenarios.map((scenario) => scenario.connector) + return { + model: { + timeZone: flags.timeZone, + titles: scenarios.map((scenario) => scenario.title), + connectors, + hub: hubOf(flags.nowMs, connectors), + }, + } +} + +/** Legacy's `onConfigure` is a no-op: Configure and the run-script buttons open nothing. */ +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: inner }) => { + const next = hubStep(model.hub, inner) + if (model.hub.gcpCard.configure === null && next.hub.gcpCard.configure !== null) + return { + model: { + ...model, + hub: { ...next.hub, gcpCard: { ...next.hub.gcpCard, configure: null } }, + }, + } + return { + model: { ...model, hub: next.hub }, + commands: Command.mapMessages(next.commands, (child) => + Message.GotHubMessage({ message: child }), + ), + } + }, + }) + +const connectionsView = Submodel.defineView< + IntegrationsModel, + IntegrationsMessage, + Readonly<{ timeZone: string; titles: ReadonlyArray; connectors: ReadonlyArray }> +>((hub, inputs, h) => + dashboardPage(h, {}, [ + h.div( + [h.Class("mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 font-display")], + inputs.connectors.map((connector, index) => + h.section( + [h.Key(connector.id), h.Class("flex flex-col gap-2")], + [ + h.h2([h.Class("text-xs text-muted-foreground")], [inputs.titles[index] ?? ""]), + gcpConnection(h, hub, connector, inputs.timeZone), + ], + ), + ), + ), + ...gcpDialogs(h, hub, inputs.timeZone), + ]), +) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "gcp-connections-lab", + model: model.hub, + view: connectionsView, + viewInputs: { timeZone: model.timeZone, titles: model.titles, connectors: model.connectors }, + toParentMessage: (message) => Message.GotHubMessage({ message }), + }) diff --git a/apps/web-foldkit/src/lab/gcp-dashboard-lab/fixture.ts b/apps/web-foldkit/src/lab/gcp-dashboard-lab/fixture.ts new file mode 100644 index 0000000000..dac70d53f8 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-dashboard-lab/fixture.ts @@ -0,0 +1,95 @@ +import { DashboardDocument } from "@maple/domain/http" +import type { VariableValues } from "@maple/query-engine" +import fixture from "@maple/web/lab/gcp-dashboard-fixture.json" +import { Schema } from "effect" + +// copied: the series fixture of apps/web `lab/gcp-dashboard-lab.tsx` (WORKLOADS, GROUPS, OPTIONS, +// `fixtureRows`), over the template document as the dashboards view reads it. + +export const DASHBOARD_ID = "gcp-lab" + +export const DASHBOARD = Schema.decodeUnknownSync(DashboardDocument)({ + ...fixture, + id: DASHBOARD_ID, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + schemaVersion: 3, +}) + +type Widget = DashboardDocument["widgets"][number] +type Resource = Readonly<{ name: string; project: string; region?: string }> + +const run = (name: string, project: string, region: string): Resource => ({ name, project, region }) +const global = (name: string, project: string): Resource => ({ name, project }) + +const WORKLOADS = new Map>([ + ["cloud-run", [run("checkout", "acme-prod", "us-central1"), run("storefront", "acme-prod", "europe-west1"), run("checkout", "acme-staging", "us-central1")]], + ["cloud-functions", [run("resize-image", "acme-prod", "us-central1"), run("send-receipt", "acme-prod", "europe-west1")]], + ["gke", [run("orders-api", "acme-prod", "us-central1"), run("worker", "acme-prod", "us-central1"), run("metrics-server", "acme-staging", "europe-west1")]], + ["compute-engine", [run("bastion", "acme-prod", "us-central1"), run("ci-runner", "acme-staging", "europe-west1")]], +]) + +const GROUPS = new Map>([ + ["resource.k8s.cluster.name", [run("prod-us", "acme-prod", "us-central1"), run("staging-eu", "acme-staging", "europe-west1")]], + ["resource.gcp.resource.labels.database_id", [run("acme-prod:orders-db", "acme-prod", "us-central1"), run("acme-prod:orders-db-replica", "acme-prod", "europe-west1")]], + ["resource.gcp.resource.labels.subscription_id", [global("orders-fulfilment", "acme-prod"), global("receipts", "acme-prod"), global("orders-fulfilment", "acme-staging")]], + ["resource.gcp.resource.labels.topic_id", [global("orders", "acme-prod"), global("orders", "acme-staging")]], + ["resource.gcp.resource.labels.url_map_name", [global("storefront-lb", "acme-prod"), global("api-lb", "acme-prod")]], + ["attr.gcp.metric.labels.response_code_class", [global("2xx", "acme-prod"), global("4xx", "acme-prod"), global("5xx", "acme-prod")]], +]) + +const ALL_RESOURCES = [...WORKLOADS.values(), ...GROUPS.values()].flat() +const unique = (values: ReadonlyArray): ReadonlyArray => + [...new Set(values.filter((value): value is string => value !== undefined))].toSorted() + +/** What the metric-sourced variables would list against this data. */ +export const OPTIONS = new Map>([ + ["project", unique(ALL_RESOURCES.map((resource) => resource.project))], + ["region", unique(ALL_RESOURCES.map((resource) => resource.region))], + ["workload", unique([...WORKLOADS.values()].flat().map((resource) => resource.name))], +]) + +const ATTRIBUTE = new Map string | undefined>([ + ["project", (resource) => resource.project], + ["region", (resource) => resource.region], + ["workload", (resource) => resource.name], +]) + +const RANGES = new Map([ + ["percent", [0.15, 0.85]], + ["bytes", [2e7, 6e8]], + ["duration_s", [0.05, 2]], +]) + +const BUCKETS = Array.from({ length: 48 }, (_, i) => new Date(Date.UTC(2026, 9, 14, 0, i * 30)).toISOString()) + +const hash = (text: string): number => + (Array.from(text).reduce((h, char) => Math.imul(h ^ char.charCodeAt(0), 16777619), 2166136261) >>> 0) / + 4294967296 + +/** `fixtureRows`: a resource passes a variable only when the widget's where clause filters on it. */ +export const fixtureRows = (widget: Widget, values: VariableValues): ReadonlyArray> => { + if (widget.dataSource.kind !== "query") return [] + const query = widget.dataSource.queries[0] + const groupBy = query?.groupBy?.[0] ?? "" + const resources = (groupBy === "service.name" ? WORKLOADS.get(widget.tabId ?? "") : GROUPS.get(groupBy)) ?? [] + const filters = [...ATTRIBUTE.keys()].filter((name) => query?.whereClause?.includes(`$${name}`)) + const kept = resources.filter((resource) => + filters.every((name) => { + const selected = values[name] + return selected === undefined || selected.isAll || ATTRIBUTE.get(name)?.(resource) === selected.value + }), + ) + const series = unique(kept.map((resource) => resource.name)) + const [low, high] = RANGES.get(String(widget.display.unit)) ?? [5, 1800] + return BUCKETS.map((bucket, i) => ({ + bucket, + ...Object.fromEntries( + series.map((name) => { + const seed = hash(`${widget.id}/${name}`) + const wave = 0.5 + 0.35 * Math.sin(i / 5 + seed * 6) + 0.15 * (hash(`${seed}/${i}`) - 0.5) + return [name, low + (high - low) * Math.min(1, Math.max(0, wave * (0.4 + seed)))] + }), + ), + })) +} diff --git a/apps/web-foldkit/src/lab/gcp-dashboard-lab/update.test.ts b/apps/web-foldkit/src/lab/gcp-dashboard-lab/update.test.ts new file mode 100644 index 0000000000..419aa91575 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-dashboard-lab/update.test.ts @@ -0,0 +1,47 @@ +import { variableValuesFromSearch } from "@maple/web/lib/dashboard-controls/search-params" +import { resolveSectionView } from "@maple/web/lib/dashboards/section-view-state" +import { describe, expect, test } from "vitest" +import { Message } from "../../page/dashboards/view/message" +import { optionsByNameOf, variablesOf } from "../../page/dashboards/view/plan" +import { fixtureRows } from "./fixture" +import { CONTEXT, init, update } from "./update" + +const API = /^(FetchDashboard|SaveDashboard|UpdateDashboard)/ +const NOW = Date.UTC(2026, 9, 15) + +const opened = () => { + const started = init() + const clocked = update(started.model, Message.CompletedReadClock({ nowMs: NOW })) + return { model: clocked.model, commands: [...started.commands, ...clocked.commands] } +} + +describe("gcp dashboard lab", () => { + test("boots the template board without asking the API anything", () => { + const { model, commands } = opened() + expect(model.document?.name).toBe("Google Cloud") + expect(commands.map((command) => command.name).filter((name) => API.test(name))).toEqual([]) + }) + + test("the metric variables list the fixture's options, not loading", () => { + const options = optionsByNameOf(opened().model, CONTEXT) + expect(options.project).toEqual({ options: ["acme-prod", "acme-staging"], loading: false }) + expect(options.region).toEqual({ options: ["europe-west1", "us-central1"], loading: false }) + }) + + test("a tab switch stays in the lab's own search", () => { + const next = update(opened().model, Message.ClickedSectionTab({ sectionId: "gcp-services", tabId: "gke" })) + const document = next.model.document + const view = resolveSectionView(document?.sections ?? [], document?.widgets ?? [], next.model.search) + expect(view.activeTabs.get("gcp-services")).toBe("gke") + expect(next.commands.map((command) => command.name).filter((name) => API.test(name))).toEqual([]) + }) + + test("a picked project narrows the series of widgets that filter on it", () => { + const drafted = update(opened().model, Message.ChangedVariableDraft({ name: "project", value: "acme-staging" })) + const picked = update(drafted.model, Message.CommittedVariableDraft({ name: "project" })).model + expect(variableValuesFromSearch(picked.search).project).toBe("acme-staging") + const widget = picked.document?.widgets.find((candidate) => candidate.id === "run-requests") + const rows = widget === undefined ? [] : fixtureRows(widget, variablesOf(picked, CONTEXT).values) + expect(Object.keys(rows[0] ?? {}).toSorted()).toEqual(["bucket", "checkout"]) + }) +}) diff --git a/apps/web-foldkit/src/lab/gcp-dashboard-lab/update.ts b/apps/web-foldkit/src/lab/gcp-dashboard-lab/update.ts new file mode 100644 index 0000000000..457acd561e --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-dashboard-lab/update.ts @@ -0,0 +1,73 @@ +import { OrgId } from "@maple/domain" +import { DashboardVariableSchema } from "@maple/domain/http" +import { Option, Result, Schema } from "effect" +import { fromString as urlFromString } from "foldkit/url" +import type { PageContext, PageOutMessage, PageReturn } from "../../page/contract" +import { Message } from "../../page/dashboards/view/message" +import type { Model } from "../../page/dashboards/view/model" +import { init as pageInit, routeChanged, update as pageUpdate } from "../../page/dashboards/view/update" +import { urlToAppRoute } from "../../route" +import { DASHBOARD, DASHBOARD_ID, OPTIONS } from "./fixture" + +// The dashboards view over the template with no API: option lists answer from the fixture, a +// variables save rewrites the board here, and search changes (tabs, values) stay in the Model. + +export const CONTEXT: PageContext = { orgId: OrgId.make("lab"), pinnedNamespace: null, timeZone: "UTC" } + +type Commands = NonNullable["commands"]> +type Step = Readonly<{ model: Model; commands: Commands }> + +const OptionsArgs = Schema.Struct({ name: Schema.String, key: Schema.String }) +const SaveArgs = Schema.Struct({ variables: Schema.Array(DashboardVariableSchema) }) +const decodeOptionsArgs = Schema.decodeUnknownOption(OptionsArgs) +const decodeSaveArgs = Schema.decodeUnknownOption(SaveArgs) + +/** The API reads and writes the board would make; the lab answers or drops them. */ +const DROPPED = new Set(["FetchDashboardWidget", "FetchDashboardShares"]) + +const then = (acc: Step, next: Step): Step => ({ model: next.model, commands: [...acc.commands, ...next.commands] }) + +const answer = (model: Model, commands: Commands): Step => + commands.reduce((acc, command) => { + if (command.name === "FetchDashboardVariableOptions") + return Option.match(decodeOptionsArgs(command.args), { + onNone: () => acc, + onSome: ({ name, key }) => + then(acc, step(acc.model, Message.CompletedFetchVariableOptions({ name, key, result: Result.succeed(OPTIONS.get(name) ?? []) }))), + }) + if (command.name === "SaveDashboardVariables") + return Option.match(Option.all([decodeSaveArgs(command.args), Option.fromNullishOr(acc.model.document)]), { + onNone: () => acc, + onSome: ([{ variables }, document]) => + then(acc, step(acc.model, Message.UpdatedDashboard({ orgId: CONTEXT.orgId, maybeDocument: Option.some({ ...document, variables }) }))), + }) + return DROPPED.has(command.name) ? acc : { ...acc, commands: [...acc.commands, command] } + }, { model, commands: [] }) + +/** A navigation the page asks for re-enters its own route, as legacy's local `viewSearch` does. */ +const navigated = (model: Model, outMessage: PageOutMessage | undefined): PageReturn => { + if (outMessage?._tag !== "RequestedNavigation") return { model } + return Option.match(urlFromString(new URL(outMessage.url, "http://localhost").href), { + onNone: () => ({ model }), + onSome: (parsed) => { + const route = urlToAppRoute(parsed) + return route._tag === "Dashboard" + ? routeChanged(model, { dashboardId: DASHBOARD_ID, search: route.search }, CONTEXT) + : { model } + }, + }) +} + +const step = (model: Model, message: Message): Step => { + const next = pageUpdate(model, message, CONTEXT) + const routed = navigated(next.model, next.outMessage) + return answer(routed.model, [...(next.commands ?? []), ...(routed.commands ?? [])]) +} + +export const update = (model: Model, message: Message): Step => step(model, message) + +export const init = (): Step => { + const started = pageInit({ dashboardId: DASHBOARD_ID, search: {} }, CONTEXT) + const loaded = step(started.model, Message.UpdatedDashboard({ orgId: CONTEXT.orgId, maybeDocument: Option.some(DASHBOARD) })) + return { model: loaded.model, commands: [...(started.commands ?? []), ...loaded.commands] } +} diff --git a/apps/web-foldkit/src/lab/gcp-dashboard-lab/view.ts b/apps/web-foldkit/src/lab/gcp-dashboard-lab/view.ts new file mode 100644 index 0000000000..f8c26f8e8e --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-dashboard-lab/view.ts @@ -0,0 +1,75 @@ +import type { DashboardDocument } from "@maple/domain/http" +import type { VariableValues } from "@maple/query-engine" +import type { Html, HtmlBuilder } from "foldkit/html" +import { ResolveThemeColors } from "../../charts/theme-colors" +import { WIDGET_CHART_TOKENS } from "../../page/dashboards/kit/chart-tokens" +import { widgetTile } from "../../page/dashboards/kit/tiles" +import * as VariablesManagerView from "../../page/dashboards/kit/variables-manager/view" +import { Message } from "../../page/dashboards/view/message" +import type { Model } from "../../page/dashboards/view/model" +import { variablesOf } from "../../page/dashboards/view/plan" +import { dashboardSections } from "../../page/dashboards/view/view-grid" +import { variableSelects } from "../../page/dashboards/view/view-variables" +import { tileContext } from "../../page/dashboards/view/view-widgets" +import { dashboardPage } from "../../page/kit/dashboard-page" +import { fixtureRows } from "./fixture" +import { CONTEXT } from "./update" + +// `GcpDashboardLab`: `DashboardPage fill` with the variable chips as its actions, the board as +// `ReadOnlyDashboardView`, and the variables dialog. + +type H = HtmlBuilder +type Widget = DashboardDocument["widgets"][number] + +/** `LabWidget`: the widget's visualization over the fixture series, no actions. */ +const labTile = (h: H, model: Model, widget: Widget, values: VariableValues): Html => + widgetTile( + h, + { + ...tileContext(h, model, widget, CONTEXT.timeZone, values), + state: { _tag: "Ready", data: { _tag: "Rows", rows: fixtureRows(widget, values) } }, + menu: null, + isMenuOpen: false, + configure: null, + }, + widget, + ) + +/** `ReadOnlyDashboardView`: the scroller around the sectioned board. */ +const board = (h: H, model: Model, document: DashboardDocument): Html => { + const values = variablesOf(model, CONTEXT).values + return h.div( + [h.Class("flex h-full min-h-0 w-full flex-col")], + [ + h.div( + [h.Class("min-h-0 flex-1 overflow-y-auto")], + [ + dashboardSections( + h, + { model, timeZone: CONTEXT.timeZone, variables: values, renderTile: (widget) => labTile(h, model, widget, values) }, + document, + ), + ], + ), + ], + ) +} + +export const view = (model: Model, h: H): Html => + h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: WIDGET_CHART_TOKENS }))], + [ + dashboardPage( + h, + { actions: variableSelects(h, model, CONTEXT, true)[0], fill: true }, + model.document === null ? [] : [board(h, model, model.document)], + ), + h.submodel({ + slotId: "dashboard-view-variables-manager", + model: model.variablesManager, + view: VariablesManagerView.view, + viewInputs: { slotId: "dashboard-variables-dialog" }, + toParentMessage: (message) => Message.GotVariablesManagerMessage({ message }), + }), + ], + ) diff --git a/apps/web-foldkit/src/lab/gcp-filter-lab/model.ts b/apps/web-foldkit/src/lab/gcp-filter-lab/model.ts new file mode 100644 index 0000000000..f11061da4f --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-filter-lab/model.ts @@ -0,0 +1,55 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LogFilterDraft } from "../../page/integrations/gcp-configure-schema" +import { Message as IntegrationsMessage } from "../../page/integrations/message" +import * as Select from "../../ui/select" + +// `/lab/gcp` (apps/web `lab/gcp-lab.tsx`): the Google Cloud configuration's Log filter over plain +// state, in each place it shows up. Each card prints what the setup script would be asked for. + +export type Scenario = Readonly<{ + title: string + description: string + sinkExists: boolean + initial: LogFilterDraft +}> + +export const SCENARIOS: ReadonlyArray = [ + { + title: "New connection", + description: "No sink yet: platform logs only, until OpenTelemetry is ruled out.", + sinkExists: false, + initial: { keep: null, runtimes: null }, + }, + { + title: "New connection, OpenTelemetry ruled out", + description: "After “Can't forward via OpenTelemetry”, with Cloud Run ticked.", + sinkExists: false, + initial: { keep: null, runtimes: ["cloud_run"] }, + }, + { + title: "Existing sink, keep", + description: "Configure on a connection whose sink exists: its filter is left alone.", + sinkExists: true, + initial: { keep: null, runtimes: null }, + }, + { + title: "Existing sink, replace", + description: "Configure with Replace filter chosen.", + sinkExists: true, + initial: { keep: false, runtimes: null }, + }, +] + +/** One card's `useState`: the draft, and its Keep/Replace select. */ +export const ScenarioState = Schema.Struct({ filter: LogFilterDraft, filterMode: Select.Model }) +export type ScenarioState = typeof ScenarioState.Type + +export const Model = Schema.Struct({ scenarios: Schema.Array(ScenarioState) }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + /** The card's field speaks the integrations page's Messages (`GcpLogFilterField`'s `onFilter`). */ + GotScenarioMessage: { index: Schema.Number, message: IntegrationsMessage }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/gcp-filter-lab/update.test.ts b/apps/web-foldkit/src/lab/gcp-filter-lab/update.test.ts new file mode 100644 index 0000000000..8bb9bc765a --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-filter-lab/update.test.ts @@ -0,0 +1,40 @@ +import { gcpLogFilterChoice } from "@maple/web/components/integrations/gcp-connector-state" +import { describe, expect, test } from "vitest" +import { Message as IntegrationsMessage } from "../../page/integrations/message" +import { Message, SCENARIOS } from "./model" +import { init, update } from "./update" + +const applicationLogsOf = (model: ReturnType, index: number) => { + const state = model.scenarios[index] + const scenario = SCENARIOS[index] + return state === undefined || scenario === undefined + ? null + : gcpLogFilterChoice(state.filter, scenario.sinkExists).applicationLogs +} + +const send = (model: ReturnType, index: number, message: IntegrationsMessage) => + update(model, Message.GotScenarioMessage({ index, message })).model + +describe("gcp log filter lab", () => { + test("each card starts on its scenario's draft", () => { + const model = init() + expect([0, 1, 2, 3].map((index) => applicationLogsOf(model, index))).toEqual([ + [], + ["cloud_run"], + undefined, + [], + ]) + }) + + test("ruling OpenTelemetry out and ticking a runtime changes only that card", () => { + const ruledOut = send(init(), 0, IntegrationsMessage.ClickedGcpForwardOutput()) + const ticked = send(ruledOut, 0, IntegrationsMessage.ToggledGcpRuntime({ runtime: "app_engine" })) + expect(applicationLogsOf(ticked, 0)).toEqual(["app_engine"]) + expect(applicationLogsOf(ticked, 1)).toEqual(["cloud_run"]) + }) + + test("Use OpenTelemetry instead closes the runtime ticks again", () => { + const back = send(init(), 1, IntegrationsMessage.ClickedGcpUseOpenTelemetry()) + expect(back.scenarios[1]?.filter.runtimes).toBeNull() + }) +}) diff --git a/apps/web-foldkit/src/lab/gcp-filter-lab/update.ts b/apps/web-foldkit/src/lab/gcp-filter-lab/update.ts new file mode 100644 index 0000000000..21d927c0fb --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-filter-lab/update.ts @@ -0,0 +1,65 @@ +import { Command, type Update } from "foldkit" +import { Message as IntegrationsMessage } from "../../page/integrations/message" +import { runtimeToggled } from "../../page/integrations/update-gcp-configure" +import type { Resources } from "../../platform/resources" +import * as Select from "../../ui/select" +import { Message, type Model, SCENARIOS, type ScenarioState } from "./model" + +export type Return = Update.Return + +type Step = Readonly<{ + state: ScenarioState + commands: ReadonlyArray> +}> + +export const init = (): Model => ({ + scenarios: SCENARIOS.map((scenario, index) => ({ + filter: scenario.initial, + filterMode: Select.init(`gcp-lab-filter-mode-${index}`, { + value: scenario.initial.keep === false ? "set" : "keep", + selectedIndex: scenario.initial.keep === false ? 1 : 0, + }), + })), +}) + +/** `onFilter`: the field's controls edit the card's draft; anything else is not the field's. */ +const step = (state: ScenarioState, message: IntegrationsMessage): Step => { + const withFilter = (patch: Partial): Step => ({ + state: { ...state, filter: { ...state.filter, ...patch } }, + commands: [], + }) + switch (message._tag) { + case "ClickedGcpForwardOutput": + return withFilter({ runtimes: [] }) + case "ClickedGcpUseOpenTelemetry": + return withFilter({ runtimes: null }) + case "ToggledGcpRuntime": + return withFilter(runtimeToggled(state.filter, message.runtime)) + case "GotGcpFilterModeMessage": { + const next = Select.update(state.filterMode, message.message) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + IntegrationsMessage.GotGcpFilterModeMessage({ message: inner }), + ) + const picked = next.outMessage?._tag === "ChangedValue" ? next.outMessage.value : null + const filter = picked === null ? state.filter : { ...state.filter, keep: picked === "keep" } + return { state: { ...state, filterMode: next.model, filter }, commands } + } + default: + return { state, commands: [] } + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotScenarioMessage: ({ index, message: inner }) => { + const state = model.scenarios[index] + if (state === undefined) return { model } + const next = step(state, inner) + return { + model: { scenarios: model.scenarios.map((entry, at) => (at === index ? next.state : entry)) }, + commands: Command.mapMessages(next.commands, (child) => + Message.GotScenarioMessage({ index, message: child }), + ), + } + }, + }) diff --git a/apps/web-foldkit/src/lab/gcp-filter-lab/view.ts b/apps/web-foldkit/src/lab/gcp-filter-lab/view.ts new file mode 100644 index 0000000000..d41819ed97 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-filter-lab/view.ts @@ -0,0 +1,72 @@ +import { gcpLogFilterChoice } from "@maple/web/components/integrations/gcp-connector-state" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message as IntegrationsMessage } from "../../page/integrations/message" +import { filterField } from "../../page/integrations/view-gcp-configure-filter" +import { dashboardPage } from "../../page/kit/dashboard-page" +import { Message, type Model, type Scenario, SCENARIOS, type ScenarioState } from "./model" + +type H = HtmlBuilder + +/** The card around `GcpLogFilterField`, labelled, with ids of its own. */ +const cardView = Submodel.defineView< + ScenarioState, + IntegrationsMessage, + Readonly<{ index: number; sinkExists: boolean }> +>((state, inputs, h) => + h.div( + [h.Class("rounded-xl border bg-popover p-6")], + [ + filterField(h, state, { + sinkExists: inputs.sinkExists, + logRouter: inputs.sinkExists ? "https://console.cloud.google.com/logs/router" : undefined, + labelId: `gcp-lab-filter-${inputs.index}-label`, + forwardId: `gcp-lab-filter-${inputs.index}-forward`, + }), + ], + ), +) + +const scenarioView = (h: H, scenario: Scenario, state: ScenarioState, index: number): Html => { + const { applicationLogs } = gcpLogFilterChoice(state.filter, scenario.sinkExists) + return h.section( + [h.Key(scenario.title), h.Class("flex flex-col gap-3")], + [ + h.div( + [], + [ + h.h2([h.Class("text-sm font-medium")], [scenario.title]), + h.p([h.Class("text-xs text-muted-foreground")], [scenario.description]), + ], + ), + h.submodel({ + slotId: `gcp-lab-filter-${index}`, + model: state, + view: cardView, + viewInputs: { index, sinkExists: scenario.sinkExists }, + toParentMessage: (message) => Message.GotScenarioMessage({ index, message }), + }), + h.p( + [h.Class("font-mono text-xs text-muted-foreground")], + [ + "application_logs:", + " ", + applicationLogs === undefined + ? "omitted (keep the filter)" + : JSON.stringify(applicationLogs), + ], + ), + ], + ) +} + +export const view = (model: Model, h: H): Html => + dashboardPage(h, {}, [ + h.div( + [h.Class("mx-auto flex w-full max-w-2xl flex-col gap-8 p-4")], + SCENARIOS.flatMap((scenario, index) => { + const state = model.scenarios[index] + return state === undefined ? [] : [scenarioView(h, scenario, state, index)] + }), + ), + ]) diff --git a/apps/web-foldkit/src/lab/gcp-host.ts b/apps/web-foldkit/src/lab/gcp-host.ts new file mode 100644 index 0000000000..43353f73ec --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-host.ts @@ -0,0 +1,86 @@ +import { OrgId } from "@maple/domain" +import { V2GcpConnector } from "@maple/domain/http/v2" +import { GcpConnectorId } from "@maple/domain/primitives" +import { Clock, Effect, Schema } from "effect" +import { AsyncData, type Command } from "foldkit" +import type { PageContext } from "../page/contract" +import type { Message as IntegrationsMessage } from "../page/integrations/message" +import type { Model as IntegrationsModel } from "../page/integrations/model" +import * as Integrations from "../page/integrations/update" +import type { Resources } from "../platform/resources" + +// The integrations page's Model and update, hosted by the Google Cloud labs (apps/web's +// `gcp-connections-lab.tsx`, `gcp-connect-lab.tsx`) over fixture connectors. + +/** The labs run against a fixture org: the page's status reads are never sent. */ +export const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab_gcp"), + pinnedNamespace: null, + timeZone: "UTC", +} + +/** The lab's clock (legacy's module-level `Date.now()`) and the browser's zone. */ +export const GcpLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export type GcpLabFlags = typeof GcpLabFlags.Type + +export const gcpLabFlags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), +}) + +const decodeConnector = Schema.decodeUnknownSync(V2GcpConnector) + +/** `connector(over)`: the lab's base connector with its fields replaced, as the wire encodes them. */ +export const connectorOf = ( + base: Readonly>, + over: Readonly> = {}, +): V2GcpConnector => decodeConnector({ ...base, ...over }) + +/** Legacy's cards share one id; each needs its own here, as menus and dialogs are keyed by it. */ +export const withIndexId = (connector: V2GcpConnector, index: number): V2GcpConnector => ({ + ...connector, + id: Schema.decodeSync(GcpConnectorId)( + `018f2b3c-4d5e-4f70-8192-a3b4c5d6e7${String(index).padStart(2, "0")}`, + ), +}) + +/** `ago(minutes)` against the lab clock, as an ISO string. */ +export const agoOf = + (nowMs: number) => + (minutes: number): string => + new Date(nowMs - minutes * 60_000).toISOString() + +/** The page's Model with the Google Cloud status loaded, never requested. */ +export const hubOf = (nowMs: number, connectors: ReadonlyArray): IntegrationsModel => { + const hub = Integrations.init({ search: {} }, LAB_CONTEXT).model + return { + ...hub, + nowMs, + statuses: { + ...hub.statuses, + gcp: { + key: null, + data: AsyncData.Success({ + data: { object: "gcp_integration", metrics_available: true, connectors }, + }), + }, + }, + } +} + +type HubCommand = Command.Command + +/** Reads the page owns but the lab does not mount: its status, clock and the removed-connection list. */ +const PAGE_READS = + /^(Load(Integrations|GcpIntegration)|ReadIntegrationsClock|(Read|Write)GcpRemovedConnectors)/ + +export type HubStep = Readonly<{ hub: IntegrationsModel; commands: ReadonlyArray }> + +/** The page's own update; its navigation is the shell's, so it is not followed here. */ +export const hubStep = (hub: IntegrationsModel, message: IntegrationsMessage): HubStep => { + const next = Integrations.update(hub, message, LAB_CONTEXT) + return { + hub: next.model, + commands: (next.commands ?? []).filter((command) => !PAGE_READS.test(command.name)), + } +} diff --git a/apps/web-foldkit/src/lab/gcp-infra-lab/model.ts b/apps/web-foldkit/src/lab/gcp-infra-lab/model.ts new file mode 100644 index 0000000000..aa64f53ca2 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-infra-lab/model.ts @@ -0,0 +1,34 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { ChartRuntime } from "../../charts/chart-runtime" +import { ThemeColors } from "../../charts/theme-colors" +import * as GcpList from "../../page/infra-integrations/gcp/list/model" +import * as Menu from "../../ui/menu" + +// `/lab/gcp-infra` (apps/web `lab/gcp-infra-lab.tsx`): the Google Cloud list over fixtures, its +// `GcpListState` held here instead of the URL. + +export const ListState = Schema.Struct({ + view: Schema.optional(Schema.String), + q: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + project: Schema.optional(Schema.String), + region: Schema.optional(Schema.String), +}) +export type ListState = typeof ListState.Type + +export const Model = Schema.Struct({ + state: ListState, + projectMenu: Menu.Model, + themeColors: Schema.NullOr(ThemeColors), + chartRuntime: ChartRuntime, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + /** The lab's own pivot: a fresh state on that view (`setState({ view })`). */ + ClickedLabView: { view: Schema.String }, + CompletedLoadIntegrationsPage: {}, + GotListMessage: { message: GcpList.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/gcp-infra-lab/update.test.ts b/apps/web-foldkit/src/lab/gcp-infra-lab/update.test.ts new file mode 100644 index 0000000000..29b750d1f9 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-infra-lab/update.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest" +import * as GcpList from "../../page/infra-integrations/gcp/list/model" +import { ALL_PROJECTS, MANAGE_CONNECTIONS } from "../../page/infra-integrations/gcp/list/view-project-menu" +import * as Menu from "../../ui/menu" +import { Message, type Model } from "./model" +import { init, LoadIntegrationsPage, update } from "./update" + +const list = (model: Model, message: GcpList.Message): Model => + update(model, Message.GotListMessage({ message })).model + +const menu = (model: Model, message: Menu.Message) => + update(model, Message.GotListMessage({ message: GcpList.Message.GotProjectMenuMessage({ message }) })) + +describe("Google Cloud list lab", () => { + test("the page's view pivot keeps the project, the lab's own pivot starts over", () => { + const scoped = list( + list(init().model, GcpList.Message.SelectedProject({ project: "acme-prod" })), + GcpList.Message.ClickedScope({ scope: "5xx" }), + ) + const paged = list(scoped, GcpList.Message.ClickedView({ view: "pubsub" })) + expect(paged.state).toEqual({ view: "pubsub", project: "acme-prod", scope: undefined, q: undefined }) + const lab = update(scoped, Message.ClickedLabView({ view: "cloudSql" })).model + expect(lab.state).toEqual({ view: "cloudSql" }) + }) + + test("a project picked in the menu scopes the list and closes the menu", () => { + const opened = menu(init().model, Menu.Message.ClickedTrigger()).model + expect(opened.projectMenu.isOpen).toBe(true) + const picked = menu( + opened, + Menu.Message.ClickedItem({ index: 2, item: { kind: "Radio", value: "acme-data" } }), + ).model + expect([picked.state.project, picked.projectMenu.isOpen]).toEqual(["acme-data", false]) + const all = menu( + menu(picked, Menu.Message.ClickedTrigger()).model, + Menu.Message.ClickedItem({ index: 0, item: { kind: "Radio", value: ALL_PROJECTS } }), + ).model + expect(all.state.project).toBeUndefined() + }) + + test("Manage connections leaves the lab for the integration page", () => { + const opened = menu(init().model, Menu.Message.ClickedTrigger()).model + const left = menu( + opened, + Menu.Message.ClickedItem({ index: 5, item: { kind: "Item", value: MANAGE_CONNECTIONS } }), + ) + expect(left.commands?.map((command) => command.name)).toContain(LoadIntegrationsPage.name) + expect(left.model.state).toEqual(opened.state) + }) + + test("a search filters until it is cleared; reads the route sends are dropped", () => { + const searched = list(init().model, GcpList.Message.ChangedSearch({ value: "checkout" })) + expect(searched.state.q).toBe("checkout") + expect(list(searched, GcpList.Message.ClickedClearSearch()).state.q).toBeUndefined() + const reload = update(searched, Message.GotListMessage({ message: GcpList.Message.ClickedReload() })) + expect([reload.model, reload.commands ?? []]).toEqual([searched, []]) + }) +}) diff --git a/apps/web-foldkit/src/lab/gcp-infra-lab/update.ts b/apps/web-foldkit/src/lab/gcp-infra-lab/update.ts new file mode 100644 index 0000000000..51ca7cee93 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-infra-lab/update.ts @@ -0,0 +1,90 @@ +import { Effect } from "effect" +import { Command, type Update } from "foldkit" +import { load } from "foldkit/navigation" +import { LoadChartRuntime } from "../../charts/chart-runtime" +import * as GcpList from "../../page/infra-integrations/gcp/list/model" +import { stepProjectMenu } from "../../page/infra-integrations/gcp/list/project-menu" +import type { Resources } from "../../platform/resources" +import { AppRoute, hrefOf } from "../../route" +import * as Menu from "../../ui/menu" +import { type ListState, Message, type Model } from "./model" + +type Return = Update.Return + +/** "Manage connections" is a link out of the lab, as legacy's ``. */ +export const LoadIntegrationsPage = Command.define("LoadGcpIntegrationsPage", { + messages: [Message.CompletedLoadIntegrationsPage], + execute: load(hrefOf(AppRoute.Integrations({ search: { integration: "gcp" } }))).pipe( + Effect.as(Message.CompletedLoadIntegrationsPage()), + ), +}) + +export const init = (): Return => ({ + model: { + state: { view: "services" }, + projectMenu: Menu.init("gcp-project-menu"), + themeColors: null, + chartRuntime: { _tag: "Loading" }, + }, + commands: [ + Command.mapMessage(LoadChartRuntime(), (message) => + Message.GotListMessage({ + message: + message._tag === "CompletedLoadChartRuntime" + ? GcpList.Message.CompletedLoadChartRuntime() + : GcpList.Message.FailedLoadChartRuntime({ reason: message.reason }), + }), + ), + ], +}) + +const patch = (model: Model, next: Partial): Return => ({ + model: { ...model, state: { ...model.state, ...next } }, +}) + +/** `GcpListPage`'s `onStateChange` and the page's own Mounts, minus every read the route sends. */ +const stepList = (model: Model, message: GcpList.Message): Return => { + switch (message._tag) { + case "CompletedLoadChartRuntime": + return { model: { ...model, chartRuntime: { _tag: "Ready" } } } + case "FailedLoadChartRuntime": + return { model: { ...model, chartRuntime: { _tag: "Failed", reason: message.reason } } } + case "ResolvedThemeColors": + return { model: { ...model, themeColors: message.colors } } + case "ClickedView": + return patch(model, { view: message.view, scope: undefined, q: undefined }) + case "ClickedScope": + return patch(model, { scope: message.scope }) + case "ChangedSearch": + return patch(model, { q: message.value === "" ? undefined : message.value }) + case "ClickedClearSearch": + return patch(model, { q: undefined }) + case "ClickedClearRegion": + return patch(model, { region: undefined, scope: undefined }) + case "SelectedProject": + return patch(model, { project: message.project, scope: undefined }) + case "GotProjectMenuMessage": { + const step = stepProjectMenu(model.projectMenu, message.message) + const next = { ...model, projectMenu: step.menu } + const commands = Command.mapMessages(step.commands, (inner) => + Message.GotListMessage({ message: inner }), + ) + const pick = step.pick + if (pick === null) return { model: next, commands } + if (pick._tag === "ManageConnections") { + return { model: next, commands: [...commands, LoadIntegrationsPage()] } + } + const picked = patch(next, { project: pick.project, scope: undefined }) + return { model: picked.model, commands } + } + default: + return { model } + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + ClickedLabView: ({ view }) => ({ model: { ...model, state: { view } } }), + CompletedLoadIntegrationsPage: () => ({ model }), + GotListMessage: ({ message: inner }) => stepList(model, inner), + }) diff --git a/apps/web-foldkit/src/lab/gcp-infra-lab/view.ts b/apps/web-foldkit/src/lab/gcp-infra-lab/view.ts new file mode 100644 index 0000000000..a28f2b0e28 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-infra-lab/view.ts @@ -0,0 +1,78 @@ +import { INITIAL_LOCALE } from "../../page/kit/locale" +import { + BUCKET_SECONDS, + CLOUD_SQL_INSTANCES, + END, + FLEET, + PROJECT_HEALTH, + SPARKLINE_READS, + START, + VIEW_LABEL, + VIEWS, +} from "@maple/web/lab/gcp-infra-fixture" +import { gcpRequestedView, gcpSparks } from "@maple/web/components/infra/gcp/list-model" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { ResolveThemeColors } from "../../charts/theme-colors" +import type * as GcpList from "../../page/infra-integrations/gcp/list/model" +import { gcpListPage, THEME_TOKENS } from "../../page/infra-integrations/gcp/list/view" +import { dashboardPage } from "../../page/kit/dashboard-page" +import { segmentedSelect } from "../../ui/segmented-select" +import { Message, type Model } from "./model" + +const SPARKS = gcpSparks(SPARKLINE_READS, { startTime: START, endTime: END, bucketSeconds: BUCKET_SECONDS }) + +/** The page as the lab mounts it: `DashboardPage gap="lg"` around `GcpListPage`, no time controls. */ +const listView = Submodel.defineView((model, h) => + h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: THEME_TOKENS }))], + [ + dashboardPage(h, { gap: "lg" }, [ + gcpListPage(h, { + fleet: FLEET, + sparks: SPARKS, + cloudSqlInstances: CLOUD_SQL_INSTANCES, + projectHealth: PROJECT_HEALTH, + collecting: false, + notice: [], + resources: () => h.empty, + state: { ...model.state, view: gcpRequestedView(model.state.view, undefined) }, + timeSearch: { startTime: START, endTime: END }, + theme: model.themeColors, + isChartReady: model.chartRuntime._tag !== "Loading", + isWaiting: false, + projectMenu: model.projectMenu, + locale: INITIAL_LOCALE, + }), + ]), + ], + ), +) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "gcp-list", + model, + view: listView, + toParentMessage: (message) => Message.GotListMessage({ message }), + }) + +/** The floating lab bar: which view the list opens on. */ +export const overlay = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.Class( + "fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg border bg-popover px-3 py-2 shadow-lg", + ), + ], + [ + h.span([h.Class("font-mono text-2xs text-muted-foreground")], ["Google Cloud lab"]), + segmentedSelect(h, { + size: "xs", + ariaLabel: "Lab view", + options: VIEWS.map((value) => ({ value, label: VIEW_LABEL[value] })), + value: VIEWS.find((view) => view === model.state.view) ?? "services", + onChange: (view) => Message.ClickedLabView({ view }), + }), + ], + ) diff --git a/apps/web-foldkit/src/lab/gcp-workload-lab/model.ts b/apps/web-foldkit/src/lab/gcp-workload-lab/model.ts new file mode 100644 index 0000000000..d31bfe3447 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-workload-lab/model.ts @@ -0,0 +1,27 @@ +import { PAGES } from "@maple/web/lab/gcp-workload-fixture" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Workload from "../../page/infra-integrations/gcp/workload/model" + +// `/lab/gcp-workload` (apps/web `lab/gcp-workload-lab.tsx`): the workload page over fixture series, +// a Cloud Run burst and a Cloud SQL instance, picked from the floating lab bar. + +export const LabPage = Schema.Literals(PAGES) +export type LabPage = typeof LabPage.Type + +export const Model = Schema.Struct({ + page: LabPage, + timeZone: Schema.String, + /** The page's own Model; its slots hold the fixture, so it never fetches. */ + workload: Workload.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + ClickedLabPage: { page: Schema.String }, + GotWorkloadMessage: { message: Workload.Message }, +}) +export type Message = typeof Message.Type + +export const GcpWorkloadLabFlags = Schema.Struct({ timeZone: Schema.String }) +export type GcpWorkloadLabFlags = typeof GcpWorkloadLabFlags.Type diff --git a/apps/web-foldkit/src/lab/gcp-workload-lab/update.test.ts b/apps/web-foldkit/src/lab/gcp-workload-lab/update.test.ts new file mode 100644 index 0000000000..583c10b846 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-workload-lab/update.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest" +import * as Chart from "../../charts/chart" +import * as Workload from "../../page/infra-integrations/gcp/workload/model" +import * as TimeRangePicker from "../../page/kit/time-range-picker" +import { Message, type Model } from "./model" +import { init, update } from "./update" + +const start = () => init({ timeZone: "UTC" }).model + +const page = (model: Model, message: Workload.Message) => + update(model, Message.GotWorkloadMessage({ message })) + +const ready = (model: Model): Model => page(model, Workload.Message.CompletedLoadChartRuntime()).model + +describe("Google Cloud workload lab", () => { + test("opens on the Cloud Run service with its series in place and nothing to fetch", () => { + const opened = init({ timeZone: "UTC" }) + expect(opened.model.workload.service).toBe("cloudRun") + expect(opened.model.workload.window).not.toBeNull() + expect(opened.model.workload.timeseries.data._tag).toBe("Success") + expect(opened.commands?.length).toBe(1) + }) + + test("switching pages mounts the other workload fresh, keeping the loaded chart runtime", () => { + const hovered = page( + ready(start()), + Workload.Message.GotChartMessage({ chartId: "gcp-chart-0", message: Chart.Message.LeftLegend() }), + ).model + expect(Object.keys(hovered.workload.charts)).toEqual(["gcp-chart-0"]) + const sql = update(hovered, Message.ClickedLabPage({ page: "sql" })).model + expect([sql.workload.service, sql.workload.name]).toEqual(["cloudSql", "acme-prod:orders-db"]) + expect([sql.workload.charts, sql.workload.chartRuntime._tag]).toEqual([{}, "Ready"]) + expect(update(sql, Message.ClickedLabPage({ page: "nope" })).model).toEqual(sql) + }) + + test("Reload spins without a request, and a stale spin end leaves the newer spin running", () => { + const first = page(start(), Workload.Message.ClickedReload()) + expect(first.model.workload.isReloading).toBe(true) + const second = page(first.model, Workload.Message.ClickedReload()).model + const stale = page(second, Workload.Message.CompletedReloadSpin({ version: 1 })).model + expect(stale.workload.isReloading).toBe(true) + const done = page(stale, Workload.Message.CompletedReloadSpin({ version: 2 })).model + expect(done.workload.isReloading).toBe(false) + }) + + test("the time picker takes input, but a picked preset leaves the fixture window", () => { + const before = start() + const typed = page( + before, + Workload.Message.GotTimePickerMessage({ + message: TimeRangePicker.Message.ChangedShorthand({ value: "3h" }), + }), + ).model + expect(typed.workload.timePicker).not.toEqual(before.workload.timePicker) + const picked = page( + typed, + Workload.Message.GotTimePickerMessage({ + message: TimeRangePicker.Message.ClickedPreset({ value: "1h" }), + }), + ).model + expect([picked.workload.window, picked.workload.search]).toEqual([before.workload.window, {}]) + }) +}) diff --git a/apps/web-foldkit/src/lab/gcp-workload-lab/update.ts b/apps/web-foldkit/src/lab/gcp-workload-lab/update.ts new file mode 100644 index 0000000000..8746c0d4a0 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-workload-lab/update.ts @@ -0,0 +1,125 @@ +import { type LabWorkload, PAGES, RANGE, WORKLOADS } from "@maple/web/lab/gcp-workload-fixture" +import { Effect } from "effect" +import { AsyncData, Command, type Update } from "foldkit" +import * as Chart from "../../charts/chart" +import { LoadChartRuntime } from "../../charts/chart-runtime" +import { stepTimePicker } from "../../page/infra-integrations/kit/page-time" +import { CompleteReloadSpin } from "../../page/infra-integrations/gcp/workload/command" +import * as Workload from "../../page/infra-integrations/gcp/workload/model" +import { initialModel } from "../../page/infra-integrations/gcp/workload/update" +import type { Resources } from "../../platform/resources" +import { type GcpWorkloadLabFlags, type LabPage, Message, type Model } from "./model" + +type Return = Update.Return +type Carry = Pick + +/** The browser's zone, which the trace-gap callout formats its times in. */ +export const gcpWorkloadLabFlags: Effect.Effect = Effect.sync(() => ({ + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, +})) + +export const workloadOf = (page: LabPage): LabWorkload => WORKLOADS[page] + +/** The page's Model with the fixture in its slots and the window fixed, so nothing is fetched. */ +export const workloadModel = (page: LabPage, carry: Carry): Workload.Model => { + const workload = workloadOf(page) + return { + ...initialModel(workload.service, workload.keys[0] ?? "", {}), + window: { startTime: RANGE.startTime, endTime: RANGE.endTime }, + timeseries: { key: null, data: AsyncData.Success({ data: workload.points }) }, + topQueries: { key: null, data: AsyncData.Success({ data: [] }) }, + ...carry, + } +} + +export const init = (flags: GcpWorkloadLabFlags): Return => ({ + model: { + page: "run", + timeZone: flags.timeZone, + workload: workloadModel("run", { chartRuntime: { _tag: "Loading" }, themeColors: null }), + }, + commands: [ + Command.mapMessage(LoadChartRuntime(), (message) => + Message.GotWorkloadMessage({ + message: + message._tag === "CompletedLoadChartRuntime" + ? Workload.Message.CompletedLoadChartRuntime() + : Workload.Message.FailedLoadChartRuntime({ reason: message.reason }), + }), + ), + ], +}) + +const wrap = ( + commands: ReadonlyArray> | undefined, +): ReadonlyArray> => + Command.mapMessages(commands ?? [], (message) => Message.GotWorkloadMessage({ message })) + +/** The page's own Messages, minus every read and navigation: the lab's window stays put. */ +const stepWorkload = (model: Model, message: Workload.Message): Return => { + const workload = model.workload + const set = ( + next: Workload.Model, + commands?: ReadonlyArray>, + ) => ({ + model: { ...model, workload: next }, + commands: commands ?? [], + }) + switch (message._tag) { + case "CompletedLoadChartRuntime": + return set({ ...workload, chartRuntime: { _tag: "Ready" } }) + case "FailedLoadChartRuntime": + return set({ ...workload, chartRuntime: { _tag: "Failed", reason: message.reason } }) + case "ResolvedThemeColors": + return set({ ...workload, themeColors: message.colors }) + case "ClickedShowAllQueries": + return set({ ...workload, showAllQueries: !workload.showAllQueries }) + case "GotChartMessage": { + const { chartId } = message + const next = Chart.update(workload.charts[chartId] ?? Chart.init(), message.message) + const commands = Command.mapMessages(next.commands ?? [], (chart) => + Message.GotWorkloadMessage({ + message: Workload.Message.GotChartMessage({ chartId, message: chart }), + }), + ) + return set({ ...workload, charts: { ...workload.charts, [chartId]: next.model } }, commands) + } + // Legacy's `onChange` is a no-op: the picker opens and closes, the window stays. + case "GotTimePickerMessage": { + const step = stepTimePicker( + workload.timePicker, + message.message, + (picker) => Workload.Message.GotTimePickerMessage({ message: picker }), + () => "", + ) + return set({ ...workload, timePicker: step.timePicker }, wrap(step.commands)) + } + case "ClickedReload": { + const version = workload.refreshVersion + 1 + return set( + { ...workload, refreshVersion: version, isReloading: true }, + wrap([CompleteReloadSpin({ version })]), + ) + } + case "CompletedReloadSpin": + return message.version === workload.refreshVersion + ? set({ ...workload, isReloading: false }) + : { model } + default: + return { model } + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + // `key={page}`: the other page mounts fresh; the loaded chart runtime and theme carry over. + ClickedLabPage: ({ page }) => { + const next = PAGES.find((candidate) => candidate === page) + if (next === undefined || next === model.page) return { model } + const { chartRuntime, themeColors } = model.workload + return { + model: { ...model, page: next, workload: workloadModel(next, { chartRuntime, themeColors }) }, + } + }, + GotWorkloadMessage: ({ message: inner }) => stepWorkload(model, inner), + }) diff --git a/apps/web-foldkit/src/lab/gcp-workload-lab/view.ts b/apps/web-foldkit/src/lab/gcp-workload-lab/view.ts new file mode 100644 index 0000000000..2f66d46761 --- /dev/null +++ b/apps/web-foldkit/src/lab/gcp-workload-lab/view.ts @@ -0,0 +1,87 @@ +import { BUCKET_SECONDS, PAGE_LABEL, PAGES } from "@maple/web/lab/gcp-workload-fixture" +import { gcpWorkloadName } from "@maple/web/components/infra/gcp/tabs" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { type PageData, workloadData } from "../../page/infra-integrations/gcp/workload/derive" +import type * as Workload from "../../page/infra-integrations/gcp/workload/model" +import { workloadPageView } from "../../page/infra-integrations/gcp/workload/view" +import type * as Shell from "../../shell" +import { segmentedSelect } from "../../ui/segmented-select" +import { type LabPage, Message, type Model } from "./model" +import { workloadOf } from "./update" + +/** `WorkloadPage`'s props, read off the fixture as the legacy lab hands them to the page. */ +const dataOf = (page: LabPage): PageData => { + const workload = workloadOf(page) + const sql = workload.cloudSql + return workloadData({ + service: workload.service, + keys: workload.keys, + points: workload.points, + bucketSeconds: BUCKET_SECONDS, + resource: workload.resource, + overviewLoaded: true, + tracedErrors: workload.tracedErrors, + instance: sql?.instance, + traceSide: + sql === undefined + ? undefined + : { databases: sql.databases, tracedQueries: sql.tracedQueries, services: sql.services }, + statements: sql?.statements ?? [], + replicas: sql?.replicas ?? [], + }) +} + +const DATA = { run: dataOf("run"), sql: dataOf("sql") } as const satisfies Record + +const WorkloadView = Submodel.defineView< + Workload.Model, + Workload.Message, + { timeZone: string; page: LabPage } +>((model, inputs, h) => + // The route's header: the time controls beside the title; the fixture window stays put. + workloadPageView(h, model, DATA[inputs.page], { timeZone: inputs.timeZone, defaultPreset: "6h" }), +) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Key(model.page), h.Class("contents")], + [ + h.submodel({ + slotId: `gcp-workload-${model.page}`, + model: model.workload, + view: WorkloadView, + viewInputs: { timeZone: model.timeZone, page: model.page }, + toParentMessage: (message) => Message.GotWorkloadMessage({ message }), + }), + ], + ) + +export const breadcrumbs = (model: Model): ReadonlyArray => { + const workload = workloadOf(model.page) + return [ + { label: "Infrastructure" }, + { label: "Google Cloud" }, + { label: gcpWorkloadName(workload.service, workload.keys) }, + ] +} + +/** The floating lab bar: which workload the page shows. */ +export const overlay = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.Class( + "fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg border bg-popover px-3 py-2 shadow-lg", + ), + ], + [ + h.span([h.Class("font-mono text-2xs text-muted-foreground")], ["Google Cloud lab"]), + segmentedSelect(h, { + size: "xs", + ariaLabel: "Lab page", + options: PAGES.map((value) => ({ value, label: PAGE_LABEL[value] })), + value: model.page, + onChange: (page) => Message.ClickedLabPage({ page }), + }), + ], + ) diff --git a/apps/web-foldkit/src/lab/infra-lab/update.ts b/apps/web-foldkit/src/lab/infra-lab/update.ts new file mode 100644 index 0000000000..440c7fcb47 --- /dev/null +++ b/apps/web-foldkit/src/lab/infra-lab/update.ts @@ -0,0 +1,80 @@ +import { OrgId } from "@maple/domain" +import { Clock, Effect, Schema } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import type { PageContext } from "../../page/contract" +import * as HostsList from "../../page/infra/hosts/list/message" +import { SortState, nextSort } from "../../page/kit/table-sort" +import * as RailwayList from "../../page/infra-integrations/railway/list/message" +import { Model as RailwayModel } from "../../page/infra-integrations/railway/list/model" +import * as RailwayUpdate from "../../page/infra-integrations/railway/list/update" +import type { Resources } from "../../platform/resources" + +// `/lab/infra` (apps/web `lab/infra-lab.tsx`): the infra overview, hosts list and Railway list +// over fixture rows. The Railway list's own Model hosts its table's sort and row tooltips. + +const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab_infra"), + pinnedNamespace: null, + timeZone: "UTC", +} + +export const Model = Schema.Struct({ + /** `const NOW = Date.now()` at module load: the fixture is built from it. */ + nowMs: Schema.Number, + timeZone: Schema.String, + hostQuery: Schema.String, + hostScope: Schema.optional(HostsList.HostScope), + /** `HostTable`'s own `useTableSort`: CPU, descending. */ + hostSort: SortState, + railway: RailwayModel, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + ChangedHostQuery: { value: Schema.String }, + ChangedHostScope: { scope: Schema.optional(HostsList.HostScope) }, + ClickedHostSort: { key: HostsList.HostSortKey }, + GotRailwayMessage: { message: RailwayList.Message }, +}) +export type Message = typeof Message.Type + +export const Flags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), +}) + +type Return = Update.Return + +/** The Railway list's reads are not sent: its Model only carries the table's local state. */ +export const init = (flags: Flags): Return => ({ + model: { + ...flags, + hostQuery: "", + hostSort: { key: "cpuPct", dir: "desc" }, + railway: RailwayUpdate.init({ search: {} }, LAB_CONTEXT).model, + }, +}) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + ChangedHostQuery: ({ value }) => ({ model: { ...model, hostQuery: value } }), + ChangedHostScope: ({ scope }) => ({ model: { ...model, hostScope: scope } }), + ClickedHostSort: ({ key }) => ({ + model: { ...model, hostSort: nextSort(model.hostSort, key, { stringKeys: ["hostName"] }) }, + }), + // Only the table's sort and tooltips; legacy's Railway search box here is inert. + GotRailwayMessage: ({ message: inner }) => { + if (inner._tag !== "ClickedSort" && inner._tag !== "GotRowTooltipMessage") return { model } + const next = RailwayUpdate.update(model.railway, inner, LAB_CONTEXT) + return { + model: { ...model, railway: next.model }, + commands: Command.mapMessages(next.commands ?? [], (child) => + Message.GotRailwayMessage({ message: child }), + ), + } + }, + }) diff --git a/apps/web-foldkit/src/lab/infra-lab/view.ts b/apps/web-foldkit/src/lab/infra-lab/view.ts new file mode 100644 index 0000000000..23ea7e50e4 --- /dev/null +++ b/apps/web-foldkit/src/lab/infra-lab/view.ts @@ -0,0 +1,152 @@ +import { buildInfraLabFixture, type InfraLabFixture } from "@maple/web/lab/infra-fixture" +import { RailwayIntegrationStatus } from "@maple/domain/http" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { FLEET_BAND_BOXED } from "../../page/infra/kit/fleet-band" +import { pageHero, searchToolbar } from "../../page/infra/kit/page-parts" +import { filterHosts } from "../../page/infra/hosts/list/derive" +import { HOST_SORT_ACCESSORS } from "../../page/infra/hosts/list/view" +import { hostSummaryBand } from "../../page/infra/hosts/list/view-band" +import { hostTable } from "../../page/infra/hosts/list/view-table" +import type { Finding } from "../../page/infra/overview/model" +import { FINDINGS_LIST_CLASS, findingRow } from "../../page/infra/overview/view-findings" +import { sourceRow } from "../../page/infra/overview/view-sources" +import type * as RailwayList from "../../page/infra-integrations/railway/list/message" +import type { Model as RailwayModel } from "../../page/infra-integrations/railway/list/model" +import { servicesList } from "../../page/infra-integrations/railway/list/view" +import { sectionHeading } from "../../page/kit/section-heading" +import { sortRowsBy } from "../../page/kit/table-sort" +import { eyebrow } from "../../ui/eyebrow" +import { Message, type Model } from "./update" + +type H = HtmlBuilder + +const RANK = { crit: 0, warn: 1, stale: 2 } as const + +/** `Frame`: one surface under its route label. */ +const frame = (h: H, label: string, children: ReadonlyArray): Html => + h.section( + [h.Class("space-y-6 border-b pb-16"), h.DataAttribute("lab-frame", label)], + [eyebrow(h, { as: "div" }, [label]), ...children], + ) + +const overview = (h: H, fixture: InfraLabFixture): Html => { + const findings: ReadonlyArray = fixture.summaries + .flatMap(([, summary]) => summary.findings) + .toSorted((a, b) => RANK[a.tone] - RANK[b.tone]) + return frame(h, "/infra overview", [ + pageHero(h, { + title: "Infrastructure", + description: "6 sources reporting. What needs a look comes first.", + }), + h.div( + [h.Class("space-y-3")], + [ + sectionHeading(h, { title: "Needs attention", hint: "across every source, worst first" }), + h.div( + [h.Class(FINDINGS_LIST_CLASS)], + findings.map((finding) => findingRow(h, finding, {})), + ), + ], + ), + h.div( + [h.Class("space-y-3")], + [ + sectionHeading(h, { title: "Sources", hint: "one row per source reporting to this org" }), + h.div( + [h.Class("divide-y overflow-hidden rounded-lg border")], + fixture.summaries.map(([id, summary]) => + sourceRow(h, id, { status: "ready", summary }, {}), + ), + ), + ], + ), + ]) +} + +const hosts = (h: H, model: Model, fixture: InfraLabFixture): Html => { + const shown = filterHosts(fixture.hosts, model.hostScope, model.hostQuery, fixture.end) + return frame(h, "/infra/hosts", [ + pageHero(h, { + title: "Hosts", + description: "Every machine sending CPU, memory, disk and network metrics, busiest first.", + }), + h.div( + [h.Class("space-y-4")], + [ + hostSummaryBand(h, { + hosts: fixture.hosts, + referenceTime: fixture.end, + activeScope: model.hostScope, + onScopeChange: (scope) => Message.ChangedHostScope({ scope }), + className: FLEET_BAND_BOXED, + }), + searchToolbar(h, { + id: "lab-hosts-search", + value: model.hostQuery, + onInput: (value) => Message.ChangedHostQuery({ value }), + onClear: Message.ChangedHostQuery({ value: "" }), + placeholder: "Search hosts…", + trailing: `${shown.length} of ${fixture.hosts.length} hosts`, + }), + // `HostTable` keeps its own sort: CPU, descending, until a head is pressed. + hostTable(h, sortRowsBy(shown, model.hostSort, HOST_SORT_ACCESSORS), { + nowMs: model.nowMs, + timeZone: model.timeZone, + isWaiting: false, + sort: model.hostSort, + onSort: (key) => Message.ClickedHostSort({ key }), + }), + ], + ), + ]) +} + +type RailwayInputs = Readonly<{ fixture: InfraLabFixture; nowMs: number; timeZone: string }> + +/** `RailwayFleetBand`, the inert search and `RailwayServiceTable`: the Railway page's list body. */ +const railwayView = Submodel.defineView( + (railway, inputs, h) => + servicesList( + h, + railway, + new RailwayIntegrationStatus({ + connected: true, + workspaceNames: null, + connectedByUserId: null, + connectedAt: null, + authFailed: false, + lastSyncedAt: null, + lastError: null, + environments: inputs.fixture.railwayEnvironments, + }), + inputs.fixture.railway, + { nowMs: inputs.nowMs, timeZone: inputs.timeZone }, + false, + false, + ), +) + +const railway = (h: H, model: Model, fixture: InfraLabFixture): Html => + frame(h, "/infra/railway", [ + pageHero(h, { + title: "Railway", + description: + "CPU, memory, network and disk for every Railway service, polled from Railway's metrics API.", + }), + h.submodel({ + slotId: "lab-railway", + model: model.railway, + view: railwayView, + viewInputs: { fixture, nowMs: model.nowMs, timeZone: model.timeZone }, + toParentMessage: (message: RailwayList.Message) => Message.GotRailwayMessage({ message }), + }), + ]) + +export const view = (model: Model, h: H): Html => { + const fixture = buildInfraLabFixture(model.nowMs) + return h.div( + [h.Class("mx-auto max-w-6xl space-y-16 p-8")], + [overview(h, fixture), hosts(h, model, fixture), railway(h, model, fixture)], + ) +} diff --git a/apps/web-foldkit/src/lab/kit/content-sections-more.ts b/apps/web-foldkit/src/lab/kit/content-sections-more.ts new file mode 100644 index 0000000000..c4371f7fef --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/content-sections-more.ts @@ -0,0 +1,171 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { type BubbleVariant, bubble, bubbleContent, bubbleGroup } from "../../ui/bubble" +import type { CopyStatus } from "../../ui/copy-button" +import { message, messageAvatar, messageContent, messageGroup, messageHeader } from "../../ui/message" +import { middleTruncate } from "../../ui/middle-truncate" +import { settingRow } from "../../ui/setting-row" +import { icon } from "../../ui/icon" +import { trace, traceDescription, traceIcon, traceLine, traceTitle } from "../../ui/trace" +import { truncatedId } from "../../ui/truncated-id" +import { truncatedText } from "../../ui/truncated-text" +import { avatarFallback } from "../../ui/display" +import { scrollArea } from "../../ui/scroll-area" +import { switchControl } from "../../ui/switch" +import { kitRow, kitSection } from "./kit-layout" + +const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +const LONG_URL = + "https://api.maple.dev/v1/organizations/org_2f9a/dashboards/checkout-latency/widgets/p95-by-route" +const BUBBLE_VARIANTS: ReadonlyArray = [ + "default", + "secondary", + "muted", + "tinted", + "outline", + "ghost", + "destructive", +] +const SCROLL_ROWS = Array.from( + { length: 12 }, + (_, index) => `span-${String(index + 1).padStart(2, "0")} GET /api/cart`, +) + +const assistantMessage = (h: HtmlBuilder): Html => + message(h, {}, [ + messageAvatar(h, [avatarFallback(h, { className: "size-6" }, "M")]), + messageContent(h, [ + messageHeader(h, ["Maple"]), + bubbleGroup(h, [ + bubble(h, { variant: "muted" }, [ + bubbleContent(h, ["payment-svc errors rose to 4.2% after the 11:40 deploy."]), + ]), + ]), + ]), + ]) + +const userMessage = (h: HtmlBuilder): Html => + message(h, { align: "end" }, [ + messageContent(h, [bubble(h, { align: "end" }, [bubbleContent(h, ["Which route is failing?"])])]), + ]) + +export const conversationSection = (h: HtmlBuilder): Html => + kitSection(h, "Message and bubble", [ + messageGroup(h, [assistantMessage(h), userMessage(h)], "max-w-xl"), + kitRow( + h, + undefined, + BUBBLE_VARIANTS.map((variant) => bubble(h, { variant }, [bubbleContent(h, [variant])])), + ), + ]) + +export const traceSection = (h: HtmlBuilder): Html => + kitSection(h, "Trace", [ + trace( + h, + [ + traceLine(h, {}, [ + traceIcon(h, [icon(h, "CheckIcon")]), + traceTitle(h, ["Searched traces"]), + traceDescription(h, ["service.name = payment-svc"]), + ]), + traceLine(h, { status: "error" }, [ + traceIcon(h, [icon(h, "MagnifierIcon")]), + traceTitle(h, ["Read logs"]), + traceDescription(h, ["query timed out"]), + ]), + traceLine(h, { status: "pending" }, [traceTitle(h, ["Group by route"])]), + ], + "max-w-md", + ), + ]) + +export const truncationSection = ( + h: HtmlBuilder, + copy: { readonly copyStatus: CopyStatus; readonly onCopy: (value: string) => M }, +): Html => + kitSection(h, "Truncation", [ + h.div( + [h.Class("flex max-w-sm flex-col gap-2 text-sm")], + [ + truncatedId(h, { value: TRACE_ID, kind: "trace" }), + truncatedId(h, { + value: TRACE_ID, + kind: "trace", + ellipsis: true, + copy: { status: copy.copyStatus, onClick: copy.onCopy(TRACE_ID) }, + }), + truncatedText(h, { text: LONG_URL, mono: true }), + middleTruncate(h, { text: LONG_URL, tail: 18, mono: true }), + ], + ), + ]) + +// Base UI `useId` values of the React page, kept verbatim (S1: ids are part of the DOM). +const baseUiId = (n: string) => `base-ui-_r_${n}_` + +export const settingRowSection = (h: HtmlBuilder): Html => + kitSection(h, "Setting row", [ + h.div( + [h.Class("flex max-w-xl flex-col gap-3")], + [ + settingRow(h, { + ids: { control: baseUiId("0"), label: baseUiId("1"), description: baseUiId("2") }, + label: "Weekly digest", + description: "A summary of incidents every Monday.", + filled: true, + control: switchControl(h, { + id: baseUiId("3"), + inputId: baseUiId("0"), + labelledBy: baseUiId("1"), + describedBy: baseUiId("2"), + isChecked: true, + isFilled: true, + }), + }), + settingRow(h, { + ids: { control: baseUiId("6"), label: baseUiId("7"), description: baseUiId("8") }, + label: "Framed, active", + description: "Framed setting row.", + framed: true, + active: true, + control: switchControl(h, { + id: baseUiId("9"), + inputId: baseUiId("6"), + labelledBy: baseUiId("7"), + describedBy: baseUiId("8"), + isChecked: false, + }), + }), + settingRow(h, { + ids: { control: baseUiId("c"), label: baseUiId("d"), description: baseUiId("f") }, + label: "Disabled", + disabled: true, + control: switchControl(h, { + id: baseUiId("e"), + inputId: baseUiId("c"), + labelledBy: baseUiId("d"), + isChecked: false, + isDisabled: true, + }), + }), + ], + ), + ]) + +export const scrollAreaSection = (h: HtmlBuilder): Html => + kitSection(h, "Scroll area", [ + scrollArea( + h, + { + idPrefix: baseUiId("h"), + className: "h-40 max-w-md rounded-md border", + overflowY: { endPx: 146, thumbHeightPx: 77.96052631578948 }, + }, + [ + h.div( + [h.Class("flex flex-col p-2 font-mono text-xs")], + SCROLL_ROWS.map((row) => h.span([h.Class("py-1")], [row])), + ), + ], + ), + ]) diff --git a/apps/web-foldkit/src/lab/kit/content-sections.ts b/apps/web-foldkit/src/lab/kit/content-sections.ts new file mode 100644 index 0000000000..b7478e30d7 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/content-sections.ts @@ -0,0 +1,101 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { + breadcrumb, + breadcrumbEllipsis, + breadcrumbItem, + breadcrumbLink, + breadcrumbList, + breadcrumbPage, + breadcrumbSeparator, +} from "../../ui/breadcrumb" +import { button } from "../../ui/button" +import type { CopyStatus } from "../../ui/copy-button" +import { eyebrow } from "../../ui/eyebrow" +import { pageHeader, pageHeaderActions, pageLayoutRoot } from "../../ui/page-layout" +import { spinner } from "../../ui/spinner" +import { icon } from "../../ui/icon" +import { marker, markerContent, markerIcon } from "../../ui/marker" +import { avatarFallback } from "../../ui/display" +import { kitRow, kitSection } from "./kit-layout" +import { + conversationSection, + scrollAreaSection, + settingRowSection, + traceSection, + truncationSection, +} from "./content-sections-more" + +export interface ContentInputs { + readonly copyStatus: CopyStatus + readonly onCopy: (value: string) => M + readonly calendar: Html +} + +const pageLayoutSection = (h: HtmlBuilder): Html => + kitSection(h, "Page layout header", [ + h.div( + [h.Class("overflow-hidden rounded-md border")], + [ + pageLayoutRoot(h, [ + pageHeader( + h, + { + title: "Services", + description: "Every service that sent spans in the selected window.", + }, + [pageHeaderActions(h, [button(h, { variant: "outline", size: "sm" }, ["Export"])])], + ), + ]), + ], + ), + ]) + +const breadcrumbSection = (h: HtmlBuilder): Html => + kitSection(h, "Eyebrow and breadcrumb", [ + kitRow(h, undefined, [ + eyebrow(h, {}, ["Default eyebrow"]), + eyebrow(h, { variant: "label" }, ["Label eyebrow"]), + ]), + breadcrumb(h, undefined, [ + breadcrumbList(h, undefined, [ + breadcrumbItem(h, {}, [breadcrumbLink(h, { href: "#dashboards" }, ["Dashboards"])]), + breadcrumbSeparator(h, undefined), + breadcrumbItem(h, {}, [breadcrumbEllipsis(h)]), + breadcrumbSeparator(h, undefined), + breadcrumbItem(h, {}, [breadcrumbPage(h, undefined, ["Checkout latency"])]), + ]), + ]), + ]) + +const markerSection = (h: HtmlBuilder): Html => + kitSection(h, "Avatar, spinner, marker", [ + kitRow(h, undefined, [ + avatarFallback(h, {}, "DM"), + avatarFallback(h, { className: "size-6" }, "AB"), + spinner(h), + spinner(h, "size-6"), + ]), + h.div( + [h.Class("flex max-w-md flex-col gap-3")], + [ + marker(h, {}, [ + markerIcon(h, [icon(h, "BellIcon")]), + markerContent(h, ["Alert fired at 12:00 UTC"]), + ]), + marker(h, { variant: "separator" }, [markerContent(h, ["Yesterday"])]), + marker(h, { variant: "border" }, [markerContent(h, ["Border marker"])]), + ], + ), + ]) + +export const contentSections = (h: HtmlBuilder, inputs: ContentInputs): ReadonlyArray => [ + pageLayoutSection(h), + breadcrumbSection(h), + markerSection(h), + conversationSection(h), + traceSection(h), + truncationSection(h, inputs), + settingRowSection(h), + scrollAreaSection(h), + kitSection(h, "Calendar", [inputs.calendar]), +] diff --git a/apps/web-foldkit/src/lab/kit/display-sections-more.ts b/apps/web-foldkit/src/lab/kit/display-sections-more.ts new file mode 100644 index 0000000000..abdf10fe6c --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/display-sections-more.ts @@ -0,0 +1,106 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import type { CopyStatus } from "../../ui/copy-button" +import { delta } from "../../ui/delta" +import { keyValue, keyValueList } from "../../ui/key-value" +import { meter, segmentedBar } from "../../ui/meter" +import { miniBars } from "../../ui/mini-bars" +import { skeleton, skeletonList } from "../../ui/skeleton" +import { statFigure } from "../../ui/stat-figure" +import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "../../ui/table" +import { tableSkeleton } from "../../ui/table-skeleton" +import { ROWS } from "./display-sections" +import { kitRow, kitSection } from "./kit-layout" + +// The second half of apps/web/src/lab/kit/display.tsx: values, loading states and tables. + +const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +const BARS = [3, 5, 4, 8, 6, 9, 12, 7, 5, 6, 10, 14, 11, 8, 6, 4] + +export const keyValueSection = (h: HtmlBuilder, copyStatus: CopyStatus, onCopy: M): Html => + kitSection(h, "Key value", [ + keyValueList(h, { divided: true, className: "max-w-md" }, [ + keyValue(h, { label: "Service" }, ["checkout-svc"]), + keyValue(h, { label: "Trace id", mono: true, copy: { status: copyStatus, onCopy } }, [TRACE_ID]), + keyValue(h, { label: "Status" }, ["Error"]), + ]), + ]) + +export const meterSection = (h: HtmlBuilder): Html => + kitSection(h, "Meter, segmented bar, mini bars", [ + h.div( + [h.Class("flex max-w-md flex-col gap-3")], + [ + meter(h, { value: 0.62, label: "Quota used" }), + meter(h, { value: 0.91, tone: "crit", label: "Error budget" }), + segmentedBar(h, { + label: "Status mix", + segments: [ + { key: "ok", value: 82, className: "bg-severity-info" }, + { key: "warn", value: 12, className: "bg-severity-warn" }, + { key: "crit", value: 6, className: "bg-severity-error" }, + ], + }), + miniBars(h, { + values: BARS, + label: "Requests per minute", + className: "h-8 w-48 text-primary", + }), + ], + ), + ]) + +export const statSection = (h: HtmlBuilder): Html => + kitSection(h, "Stat figure and delta", [ + kitRow(h, undefined, [ + statFigure(h, { value: "412", unit: "req/s", size: "sm" }), + statFigure(h, { value: "238", unit: "ms", size: "md", mono: true }), + statFigure(h, { value: "99.82", unit: "%", size: "lg" }), + delta(h, { ratio: 0.12 }), + delta(h, { ratio: -0.08 }), + delta(h, { ratio: 0.24, invert: true }), + delta(h, { ratio: 0 }), + ]), + ]) + +export const skeletonSection = (h: HtmlBuilder): Html => + kitSection(h, "Skeleton and table skeleton", [ + h.div( + [h.Class("grid grid-cols-2 gap-6")], + [ + h.div( + [h.Class("flex flex-col gap-2")], + [ + skeleton(h, "h-4 w-48"), + skeleton(h, "h-4 w-32"), + skeletonList(h, { rows: 3, label: "Loading services" }), + ], + ), + tableSkeleton(h, { columns: 3, rows: 3 }), + ], + ), + ]) + +export const tableSection = (h: HtmlBuilder): Html => + kitSection(h, "Table", [ + table(h, {}, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Service"]), + tableHead(h, ["Req/s"], "text-right"), + tableHead(h, ["Errors"], "text-right"), + tableHead(h, ["p95"], "text-right"), + ]), + ]), + tableBody( + h, + ROWS.map((row) => + tableRow(h, [ + tableCell(h, [row.service]), + tableCell(h, [row.rps], "text-right"), + tableCell(h, [row.errors], "text-right"), + tableCell(h, [row.p95], "text-right"), + ]), + ), + ), + ]), + ]) diff --git a/apps/web-foldkit/src/lab/kit/display-sections.ts b/apps/web-foldkit/src/lab/kit/display-sections.ts new file mode 100644 index 0000000000..456bec1a92 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/display-sections.ts @@ -0,0 +1,142 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "../../ui/alert" +import { button } from "../../ui/button" +import { card, cardDescription, cardFooter, cardHeader, cardPanel, cardTitle } from "../../ui/card" +import { delta } from "../../ui/delta" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyMessage, emptyTitle } from "../../ui/empty" +import { + item, + itemActions, + itemContent, + itemDescription, + itemGroup, + itemMedia, + itemTitle, +} from "../../ui/item" +import { listRow } from "../../ui/list-row" +import { panel, panelBody, panelHeader } from "../../ui/panel" +import { statFigure } from "../../ui/stat-figure" +import { icon } from "../../ui/icon" +import { badge } from "../../ui/display" +import { kitSection } from "./kit-layout" + +// The first half of apps/web/src/lab/kit/display.tsx: surfaces, alerts, empty states and rows. + +export const ALERT_VARIANTS = ["default", "info", "ok", "warn", "crit", "done"] as const +export const ROWS = [ + { service: "checkout-svc", rps: "412", errors: "1.8%", p95: "238 ms" }, + { service: "payment-svc", rps: "188", errors: "4.2%", p95: "612 ms" }, + { service: "inventory-svc", rps: "96", errors: "0.1%", p95: "41 ms" }, +] + +const ingestCard = (h: HtmlBuilder): Html => + card(h, [ + cardHeader(h, [ + cardTitle(h, ["Ingest volume"]), + cardDescription(h, ["Spans received in the current billing cycle."]), + ]), + cardPanel(h, [statFigure(h, { value: "12.4M", unit: "spans", size: "lg" })]), + cardFooter(h, [button(h, { variant: "outline", size: "sm" }, ["View usage"])]), + ]) + +const servicesPanel = (h: HtmlBuilder): Html => + panel(h, {}, [ + panelHeader(h, { + title: "Top services", + scope: badge(h, { variant: "meta" }, ["production"]), + action: button(h, { variant: "ghost", size: "xs" }, ["All"]), + }), + panelBody( + h, + ROWS.map((row) => + listRow(h, { + divided: true, + title: row.service, + meta: `${row.rps} req/s`, + trailing: delta(h, { ratio: 0.12 }), + }), + ), + ), + ]) + +export const cardAndPanelSection = (h: HtmlBuilder): Html => + kitSection(h, "Card and panel", [ + h.div([h.Class("grid grid-cols-2 gap-6")], [ingestCard(h), servicesPanel(h)]), + ]) + +export const alertSection = (h: HtmlBuilder): Html => + kitSection(h, "Alert", [ + h.div( + [h.Class("grid grid-cols-2 gap-3")], + [ + ...ALERT_VARIANTS.map((variant) => + alert(h, { variant }, [ + icon(h, "CircleInfoIcon"), + alertTitle(h, ["Alert ", variant]), + alertDescription(h, ["The ingest key was rotated 3 days ago."]), + ]), + ), + alert(h, { size: "sm", variant: "warn" }, [ + icon(h, "CircleInfoIcon"), + alertDescription(h, ["Small alert, one line."]), + ]), + ], + ), + ]) + +export const emptySection = (h: HtmlBuilder): Html => + kitSection(h, "Empty", [ + h.div( + [h.Class("grid grid-cols-2 gap-6")], + [ + empty(h, [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "DatabaseIcon")]), + emptyTitle(h, ["No traces yet"]), + emptyDescription(h, ["Send spans from any OpenTelemetry SDK to see them here."]), + ]), + ]), + emptyMessage(h, { dashed: true }, ["No dashboards match this filter."]), + ], + ), + ]) + +export const itemSection = (h: HtmlBuilder): Html => + kitSection(h, "Item and list row", [ + itemGroup( + h, + [ + item(h, { variant: "outline" }, [ + itemMedia(h, { variant: "icon" }, [icon(h, "ServerIcon")]), + itemContent(h, [ + itemTitle(h, ["api-gateway"]), + itemDescription(h, ["3 instances, us-east-1"]), + ]), + itemActions(h, [button(h, { variant: "outline", size: "sm" }, ["Open"])]), + ]), + item(h, { variant: "muted", size: "sm" }, [ + itemContent(h, [itemTitle(h, ["Muted item, small"])]), + ]), + ], + "max-w-xl", + ), + h.div( + [h.Class("max-w-xl rounded-md border")], + [ + listRow(h, { + divided: true, + selected: true, + title: "payment-svc", + meta: "selected", + trailing: "612 ms", + }), + listRow(h, { + divided: true, + density: "compact", + title: "inventory-svc", + meta: "compact", + trailing: "41 ms", + }), + ], + ), + ]) diff --git a/apps/web-foldkit/src/lab/kit/kit-layout.ts b/apps/web-foldkit/src/lab/kit/kit-layout.ts new file mode 100644 index 0000000000..92cecc14d8 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/kit-layout.ts @@ -0,0 +1,48 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "../../ui/eyebrow" + +// Port of apps/web/src/lab/kit/kit-layout.tsx: the frame every `/lab/kit/` page shares. + +export const kitPage = ( + h: HtmlBuilder, + options: Readonly<{ title: string; description: string }>, + children: ReadonlyArray, +): Html => + h.div( + [h.Class("mx-auto flex max-w-5xl flex-col gap-10 p-8")], + [ + h.header( + [h.Class("flex flex-col gap-1")], + [ + h.h1([h.Class("font-semibold text-lg")], [options.title]), + h.p([h.Class("text-muted-foreground text-sm")], [options.description]), + ], + ), + ...children, + ], + ) + +export const kitSection = ( + h: HtmlBuilder, + title: string, + children: ReadonlyArray, +): Html => + h.section( + [h.AriaLabel(title), h.Class("flex flex-col gap-3")], + [eyebrow(h, { variant: "label", as: "h2" }, [title]), ...children], + ) + +export const kitRow = ( + h: HtmlBuilder, + label: string | undefined, + children: ReadonlyArray, +): Html => + h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + ...(label === undefined + ? [] + : [h.span([h.Class("w-36 shrink-0 font-mono text-muted-foreground text-xs")], [label])]), + ...children, + ], + ) diff --git a/apps/web-foldkit/src/lab/kit/navigation-sidebar.ts b/apps/web-foldkit/src/lab/kit/navigation-sidebar.ts new file mode 100644 index 0000000000..5fa249ee69 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/navigation-sidebar.ts @@ -0,0 +1,131 @@ +import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { icon, type IconName } from "../../ui/icon" +import { + sidebarContent, + sidebarDesktop, + sidebarFooter, + sidebarGroup, + sidebarGroupAction, + sidebarGroupContent, + sidebarGroupLabel, + sidebarHeader, + sidebarInset, + sidebarMenu, + sidebarMenuBadge, + sidebarMenuButton, + sidebarMenuItem, + sidebarMenuSub, + sidebarMenuSubButton, + sidebarMenuSubItem, + sidebarSeparator, + type SidebarState, + sidebarWrapper, +} from "../../ui/sidebar" + +// apps/web/src/lab/kit/navigation.tsx `SidebarFrame`: a layout-contained frame holds the sidebar's +// fixed container, so an expanded and a collapsed sidebar sit side by side. + +const TELEMETRY: ReadonlyArray< + Readonly<{ title: string; icon: IconName; badge?: string; isActive: boolean }> +> = [ + { title: "Traces", icon: "ChartLineIcon", badge: "12", isActive: true }, + { title: "Logs", icon: "UiLayersIcon", isActive: false }, + { title: "Metrics", icon: "DatabaseIcon", isActive: false }, +] + +export type SidebarFrameOptions = Readonly<{ + name: string + state: SidebarState + /** The tooltip trigger attributes of the button titled `title` (the collapsed rail's tooltips). */ + tooltipTrigger: (title: string) => ReadonlyArray + /** The open tooltip's portal, if any. */ + tooltipPortal: Html | null +}> + +export const sidebarFrame = (h: HtmlBuilder, options: SidebarFrameOptions): Html => { + const menuButton = (title: string, iconName: IconName, isActive: boolean) => + sidebarMenuButton(h, { isActive, hasTooltip: true, attributes: options.tooltipTrigger(title) }, [ + icon(h, iconName), + h.span([], [title]), + ]) + return h.section( + [ + h.AriaLabel(options.name), + h.Class("relative h-[480px] flex-1 overflow-hidden rounded-md border [contain:layout]"), + ], + [ + sidebarWrapper( + h, + [ + sidebarDesktop( + h, + options.state, + [ + sidebarHeader(h, [ + h.span([h.Class("truncate px-2 font-semibold text-sm")], ["Maple"]), + ]), + sidebarContent(h, [ + sidebarGroup(h, undefined, [ + sidebarGroupLabel(h, undefined, ["Telemetry"]), + sidebarGroupAction(h, { ariaLabel: "Add view" }, [icon(h, "PlusIcon")]), + sidebarGroupContent(h, [ + sidebarMenu( + h, + undefined, + TELEMETRY.map((item) => + sidebarMenuItem(h, undefined, [ + menuButton(item.title, item.icon, item.isActive), + ...(item.badge === undefined + ? [] + : [sidebarMenuBadge(h, [item.badge])]), + ]), + ), + ), + ]), + ]), + sidebarSeparator(h), + sidebarGroup(h, undefined, [ + sidebarGroupLabel(h, undefined, ["Infrastructure"]), + sidebarMenu(h, undefined, [ + sidebarMenuItem(h, undefined, [ + menuButton("Hosts", "ServerIcon", false), + sidebarMenuSub(h, undefined, [ + sidebarMenuSubItem(h, undefined, [ + sidebarMenuSubButton( + h, + { isActive: true, href: "#kubernetes" }, + ["Kubernetes"], + ), + ]), + sidebarMenuSubItem(h, undefined, [ + sidebarMenuSubButton( + h, + { isActive: false, href: "#railway" }, + ["Railway"], + ), + ]), + ]), + ]), + ]), + ]), + ]), + sidebarFooter(h, [ + sidebarMenu(h, undefined, [ + sidebarMenuItem(h, undefined, [ + menuButton("Alerts", "BellIcon", false), + sidebarMenuBadge(h, ["3"]), + ]), + ]), + ]), + ], + "h-full", + ), + sidebarInset(h, [options.name], "p-4 text-muted-foreground text-sm"), + ...(options.tooltipPortal === null ? [] : [options.tooltipPortal]), + ], + [], + "h-full min-h-0", + ), + ], + ) +} diff --git a/apps/web-foldkit/src/lab/kit/overlays/cards.ts b/apps/web-foldkit/src/lab/kit/overlays/cards.ts new file mode 100644 index 0000000000..898f2a2a46 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/cards.ts @@ -0,0 +1,77 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../ui/button" +import * as HoverCard from "../../../ui/hover-card" +import * as Popover from "../../../ui/popover" +import * as Tooltip from "../../../ui/tooltip" +import { Message } from "./model" + +// apps/web/src/lab/kit/overlays-dialogs.tsx `KitPopover` and `KitHoverCard`. +type H = HtmlBuilder + +const itemRow = (h: H, children: ReadonlyArray) => + h.div([h.Class("flex min-h-48 items-start")], children) + +export const kitHoverCard = (h: H, model: HoverCard.Model): Html => + h.submodel({ + slotId: "hover-card", + model, + view: HoverCard.view, + viewInputs: { + className: "w-72", + toView: ({ trigger, portal }) => + itemRow(h, [button(h, { variant: "link", attributes: trigger }, ["payment-svc"]), portal]), + toContent: () => [ + h.div( + [h.Class("flex flex-col gap-1")], + [ + h.span([h.Class("font-medium text-sm")], ["payment-svc"]), + h.span( + [h.Class("text-muted-foreground text-xs")], + ["412 req/s, 1.8% errors, p95 238 ms"], + ), + ], + ), + ], + }, + toParentMessage: (message) => Message.GotHoverCardMessage({ message }), + }) + +// `KitTooltip`: `` on an outline button. +export const kitTooltip = (h: H, model: Tooltip.Model): Html => + h.submodel({ + slotId: "tooltip", + model, + view: Tooltip.view, + viewInputs: { + toView: ({ trigger, portal }) => + itemRow(h, [ + button(h, { variant: "outline", slot: "tooltip-trigger", attributes: trigger() }, [ + "Hover target", + ]), + ...(portal === null ? [] : [portal]), + ]), + toContent: () => ["Last refreshed 2 minutes ago"], + }, + toParentMessage: (message) => Message.GotTooltipMessage({ message }), + }) + +export const kitPopover = (h: H, model: Popover.Model): Html => + h.submodel({ + slotId: "popover", + model, + view: Popover.view, + viewInputs: { + className: "w-72", + toView: ({ trigger, withTrigger }) => + itemRow(h, withTrigger(button(h, { variant: "outline", attributes: trigger }, ["Share"]))), + toContent: () => [ + Popover.popoverTitle(h, model.id, "Share this view"), + Popover.popoverDescription( + h, + model.id, + "Anyone in the org with the link sees the same filters.", + ), + ], + }, + toParentMessage: (message) => Message.GotPopoverMessage({ message }), + }) diff --git a/apps/web-foldkit/src/lab/kit/overlays/command.ts b/apps/web-foldkit/src/lab/kit/overlays/command.ts new file mode 100644 index 0000000000..f1439fac6c --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/command.ts @@ -0,0 +1,56 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import type * as Combobox from "../../../ui/combobox" +import { + type CommandGroup, + commandFooter, + commandHiddenInput, + commandInput, + commandList, +} from "../../../ui/command" +import type * as Dialog from "../../../ui/dialog" +import { view as dialogView } from "../../../ui/dialog-view" +import { kbd } from "../../../ui/display" +import { Message } from "./model" + +// apps/web/src/lab/kit/overlays-menus.tsx `KitCommand`: the command palette, open on mount. +type H = HtmlBuilder + +export const COMMANDS: ReadonlyArray = [ + { + label: "Pages", + items: [ + { title: "Overview", shortcut: "G O" }, + { title: "Traces", shortcut: "G T" }, + { title: "Logs", shortcut: "G L" }, + ], + }, + { label: "Actions", items: [{ title: "Create dashboard" }, { title: "Invite teammate" }] }, +] + +const hint = (h: H, key: string, text: string): Html => + h.span([h.Class("flex items-center gap-1.5")], [kbd(h, undefined, [key]), text]) + +export const kitCommand = (h: H, dialog: Dialog.Model, list: Combobox.Model): Html => { + const toMessage = (message: Combobox.Message) => Message.GotCommandListMessage({ message }) + return h.submodel({ + slotId: "command", + model: dialog, + view: dialogView, + viewInputs: { + hasTrigger: false, + toView: ({ portal }) => h.div([h.Class("flex min-h-48 items-start")], [portal]), + toContent: () => [ + commandInput( + h, + list, + { placeholder: "Search pages, dashboards, actions", groups: COMMANDS }, + toMessage, + ), + commandList(h, list, COMMANDS, toMessage), + commandFooter(h, [hint(h, "Enter", " to open"), hint(h, "Esc", " to close")]), + commandHiddenInput(h, list), + ], + }, + toParentMessage: (message) => Message.GotCommandMessage({ message }), + }) +} diff --git a/apps/web-foldkit/src/lab/kit/overlays/dialogs.ts b/apps/web-foldkit/src/lab/kit/overlays/dialogs.ts new file mode 100644 index 0000000000..692ee1928b --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/dialogs.ts @@ -0,0 +1,143 @@ +import { cn } from "@maple/ui/lib/utils" +import { inputControlChromeStyles, inputControlLayoutStyles, inputStyles } from "@maple/ui/styles/ui/input" +import { labelStyles } from "@maple/ui/styles/ui/label" +import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../ui/button" +import type * as Dialog from "../../../ui/dialog" +import { + dialogDescription, + dialogFooter, + dialogHeader, + dialogPanel, + dialogTitle, + view as dialogView, +} from "../../../ui/dialog-view" +import { Message } from "./model" + +// apps/web/src/lab/kit/overlays-dialogs.tsx: dialog-like overlays, each open on mount. +type H = HtmlBuilder + +const itemRow = (h: H, children: ReadonlyArray) => + h.div([h.Class("flex min-h-48 items-start")], children) + +/** ``: an uncontrolled input inside its label. */ +const labelledInput = (h: H, id: string, label: string, value: string): Html => + h.label( + [h.Class(cn(labelStyles, "flex flex-col items-start gap-2")), h.DataAttribute("slot", "label")], + [ + label, + h.span( + [ + h.Class(cn(inputControlLayoutStyles, inputControlChromeStyles)), + h.DataAttribute("size", "default"), + h.DataAttribute("slot", "input-control"), + ], + [ + h.input([ + h.Class(cn(inputStyles)), + h.DataAttribute("slot", "input"), + h.Id(id), + h.Value(value), + ]), + ], + ), + ], + ) + +const closeButton = ( + h: H, + close: ReadonlyArray, + slot: string, + variant: "ghost" | "outline", + text: string, +) => button(h, { variant, attributes: [h.DataAttribute("slot", slot), h.Tabindex(0), ...close] }, [text]) + +export const kitDialog = (h: H, model: Dialog.Model): Html => + h.submodel({ + slotId: "dialog", + model, + view: dialogView, + viewInputs: { + toView: ({ trigger, portal }) => + itemRow(h, [button(h, { variant: "outline", attributes: trigger }, ["Open dialog"]), portal]), + toContent: (close) => [ + dialogHeader(h, "dialog", [ + dialogTitle(h, "dialog", model.id, "Rename dashboard"), + dialogDescription( + h, + "dialog", + model.id, + "The new name shows in the sidebar for everyone in the org.", + ), + ]), + dialogPanel(h, "dialog", model.id, [ + labelledInput(h, `${model.id}-name`, "Name", "Checkout latency"), + ]), + dialogFooter(h, "dialog", [ + closeButton(h, close, "dialog-close", "ghost", "Cancel"), + button(h, {}, ["Save"]), + ]), + ], + }, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + }) + +export const kitAlertDialog = (h: H, model: Dialog.Model): Html => + h.submodel({ + slotId: "alert-dialog", + model, + view: dialogView, + viewInputs: { + toView: ({ trigger, portal }) => + itemRow(h, [ + button(h, { variant: "destructive-outline", attributes: trigger }, ["Delete alert rule"]), + portal, + ]), + toContent: (close) => [ + dialogHeader(h, "alert-dialog", [ + dialogTitle(h, "alert-dialog", model.id, "Delete this alert rule?"), + dialogDescription( + h, + "alert-dialog", + model.id, + "Checkout p95 latency stops evaluating and its open incidents are resolved.", + ), + ]), + dialogFooter(h, "alert-dialog", [ + closeButton(h, close, "alert-dialog-cancel", "outline", "Cancel"), + button( + h, + { + variant: "destructive", + attributes: [h.DataAttribute("slot", "alert-dialog-action")], + }, + ["Delete rule"], + ), + ]), + ], + }, + toParentMessage: (message) => Message.GotAlertDialogMessage({ message }), + }) + +export const kitSheet = (h: H, model: Dialog.Model): Html => + h.submodel({ + slotId: "sheet", + model, + view: dialogView, + viewInputs: { + side: "right", + toView: ({ trigger, portal }) => + itemRow(h, [button(h, { variant: "outline", attributes: trigger }, ["Open sheet"]), portal]), + toContent: () => [ + dialogHeader(h, "sheet", [ + dialogTitle(h, "sheet", model.id, "Span details"), + dialogDescription(h, "sheet", model.id, "POST /api/checkout on checkout-svc"), + ]), + dialogPanel(h, "sheet", model.id, [ + h.p([h.Class("text-sm")], ["Duration 412 ms, status Error, 14 child spans."]), + ]), + dialogFooter(h, "sheet", [button(h, { variant: "outline" }, ["Open trace"])]), + ], + }, + toParentMessage: (message) => Message.GotSheetMessage({ message }), + }) diff --git a/apps/web-foldkit/src/lab/kit/overlays/menus.ts b/apps/web-foldkit/src/lab/kit/overlays/menus.ts new file mode 100644 index 0000000000..85dba00654 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/menus.ts @@ -0,0 +1,93 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import type * as ContextMenu from "../../../ui/context-menu" +import { type Entry, view as contextMenuView } from "../../../ui/context-menu-view" +import { button } from "../../../ui/button" +import * as MenuView from "../../../ui/menu-view" +import type { MenuEntry } from "../../../ui/menu-view" +import { Message, type Model } from "./model" + +// apps/web/src/lab/kit/overlays-menus.tsx `KitContextMenu` (opened by a right-click in the scenario). +type H = HtmlBuilder + +const CONTEXT_MENU_ENTRIES: ReadonlyArray = [ + { _tag: "Item", label: "Filter by service", shortcut: "F" }, + { _tag: "Item", label: "Exclude service" }, + { _tag: "Separator" }, + { _tag: "Item", label: "Hide span", variant: "destructive" }, +] + +const dropdownEntries = (settings: Model["menuSettings"]): ReadonlyArray => [ + { + _tag: "Group", + label: "Trace", + entries: [ + { _tag: "Item", value: "open-trace", label: "Open trace", shortcut: "Enter" }, + { _tag: "Item", value: "copy-trace-id", label: "Copy trace id", shortcut: "Cmd C" }, + ], + }, + { + _tag: "Submenu", + value: "add-to-dashboard", + label: "Add to dashboard", + entries: [ + { _tag: "Item", value: "checkout-latency", label: "Checkout latency" }, + { _tag: "Item", value: "payments", label: "Payments" }, + ], + }, + { _tag: "Separator" }, + { + _tag: "Checkbox", + value: "show-attributes", + label: "Show attributes", + isChecked: settings.showAttributes, + }, + { _tag: "Checkbox", value: "wrap-lines", label: "Wrap lines", isChecked: settings.wrapLines }, + { _tag: "Separator" }, + { + _tag: "RadioGroup", + items: [ + { value: "compact", label: "Compact", isChecked: settings.density === "compact" }, + { value: "comfortable", label: "Comfortable", isChecked: settings.density === "comfortable" }, + ], + }, + { _tag: "Separator" }, + { _tag: "Item", value: "delete", label: "Delete", variant: "destructive" }, +] + +// apps/web/src/lab/kit/overlays-menus.tsx `KitDropdownMenu` (`defaultOpen`). +export const kitDropdownMenu = (h: H, model: Model): Html => + h.submodel({ + slotId: "dropdown-menu", + model: model.dropdownMenu, + view: MenuView.view, + viewInputs: { + entries: dropdownEntries(model.menuSettings), + className: "w-56", + toView: ({ trigger, withTrigger }) => + h.div( + [h.Class("flex min-h-48 items-start")], + withTrigger(button(h, { variant: "outline", attributes: trigger }, ["Row actions"])), + ), + }, + toParentMessage: (message) => Message.GotDropdownMenuMessage({ message }), + }) + +export const kitContextMenu = (h: H, model: ContextMenu.Model): Html => + h.div( + [h.Class("flex min-h-48 items-start")], + [ + h.submodel({ + slotId: "context-menu", + model, + view: contextMenuView, + viewInputs: { + entries: CONTEXT_MENU_ENTRIES, + className: "w-52", + triggerClassName: + "flex h-40 w-80 items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm", + triggerContent: ["Right-click a span"], + }, + toParentMessage: (message) => Message.GotContextMenuMessage({ message }), + }), + ], + ) diff --git a/apps/web-foldkit/src/lab/kit/overlays/model.ts b/apps/web-foldkit/src/lab/kit/overlays/model.ts new file mode 100644 index 0000000000..27325332ab --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/model.ts @@ -0,0 +1,81 @@ +import { OVERLAY_ITEMS } from "@maple/web/lab/kit/overlay-items" +import { Option, Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Combobox from "../../../ui/combobox" +import * as ContextMenu from "../../../ui/context-menu" +import * as Dialog from "../../../ui/dialog" +import * as HoverCard from "../../../ui/hover-card" +import * as Menu from "../../../ui/menu" +import * as Popover from "../../../ui/popover" +import * as Select from "../../../ui/select" +import * as Toast from "../../../ui/toast" +import * as Tooltip from "../../../ui/tooltip" + +export const OverlayItem = Schema.Literals(OVERLAY_ITEMS) +export type OverlayItem = typeof OverlayItem.Type + +/** `?item=`, read before init: the one overlay rendered open (overlays cannot share a page). */ +export const Flags = Schema.Struct({ item: Schema.NullOr(Schema.String) }) +export type Flags = typeof Flags.Type + +export const Model = Schema.Struct({ + item: Schema.Option(OverlayItem), + dialog: Dialog.Model, + alertDialog: Dialog.Model, + sheet: Dialog.Model, + command: Dialog.Model, + popover: Popover.Model, + hoverCard: HoverCard.Model, + contextMenu: ContextMenu.Model, + select: Select.Model, + combobox: Combobox.Model, + autocomplete: Combobox.Model, + commandList: Combobox.Model, + toasts: Toast.Model, + tooltip: Tooltip.Model, + dropdownMenu: Menu.Model, + /** `KitDropdownMenu`'s checkbox and radio items (`defaultChecked`, `defaultValue`). */ + menuSettings: Schema.Struct({ + showAttributes: Schema.Boolean, + wrapLines: Schema.Boolean, + density: Schema.Literals(["compact", "comfortable"]), + }), +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + GotAlertDialogMessage: { message: Dialog.Message }, + GotSheetMessage: { message: Dialog.Message }, + GotCommandMessage: { message: Dialog.Message }, + GotPopoverMessage: { message: Popover.Message }, + GotHoverCardMessage: { message: HoverCard.Message }, + GotContextMenuMessage: { message: ContextMenu.Message }, + GotSelectMessage: { message: Select.Message }, + GotComboboxMessage: { message: Combobox.Message }, + GotAutocompleteMessage: { message: Combobox.Message }, + GotCommandListMessage: { message: Combobox.Message }, + GotToastMessage: { message: Toast.Message }, + GotTooltipMessage: { message: Tooltip.Message }, + GotDropdownMenuMessage: { message: Menu.Message }, +}) +export type Message = typeof Message.Type + +export const decodeItem = (raw: string | null): Option.Option => + Schema.decodeUnknownOption(OverlayItem)(raw) + +/** The `toast-` items' toast type. */ +export const toastTypeOf = (item: OverlayItem): Option.Option => + item.startsWith("toast-") + ? Schema.decodeUnknownOption(Toast.ToastType)(item.slice("toast-".length)) + : Option.none() + +// apps/web/src/lab/kit/overlays-menus.tsx `TOAST_COPY` +export const TOAST_COPY = { + default: { title: "Dashboard saved", description: "Checkout latency is up to date." }, + success: { title: "Schema applied", description: "All 14 tables are on the latest version." }, + error: { title: "Failed to delete rule", description: "The alert rule is referenced by a policy." }, + info: { title: "New version available", description: "Reload to get the latest Maple." }, + warning: { title: "Approaching ingest quota", description: "82% of this month's spans are used." }, + loading: { title: "Exporting logs", description: "Preparing 12,480 rows." }, +} satisfies Record diff --git a/apps/web-foldkit/src/lab/kit/overlays/overlays.scene.test.ts b/apps/web-foldkit/src/lab/kit/overlays/overlays.scene.test.ts new file mode 100644 index 0000000000..9ace4231a0 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/overlays.scene.test.ts @@ -0,0 +1,82 @@ +import { all, click, expect, expectAll, given, Mount, role, scene, text, type } from "foldkit/scene" +import { describe, test } from "vitest" +import * as Dialog from "../../../ui/dialog" +import * as Menu from "../../../ui/menu" +import * as Toast from "../../../ui/toast" +import * as Tooltip from "../../../ui/tooltip" +import { init, update } from "./update" +import { view } from "./view" + +/** The `/lab/kit/overlays` gallery: `?item=` opens one overlay; the command palette filters. */ +const config = { update, view } +const toastPortals = () => [ + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), +] + +describe("kit overlays gallery", () => { + test("without an item it lists every overlay and opens none", () => { + scene( + config, + given(init({ item: null }).model), + ...toastPortals(), + expectAll(all.role("link")).toHaveCount(18), + expect(role("dialog")).toBeAbsent(), + ) + }) + + test("an unknown item is ignored; a known one is marked current and rendered open", () => { + scene( + config, + given(init({ item: "nope" }).model), + ...toastPortals(), + expect(role("region", { name: "nope" })).toBeAbsent(), + ) + scene( + config, + given(init({ item: "alert-dialog" }).model), + ...toastPortals(), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + expect(role("link", { name: "alert-dialog" })).toHaveAttr("aria-current", "page"), + expect(role("alertdialog", { name: "Delete this alert rule?" })).toExist(), + ) + }) + + test("the dropdown menu item opens the menu; its checkbox item toggles the setting it shows", () => { + scene( + config, + given(init({ item: "dropdown-menu" }).model), + ...toastPortals(), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expect(role("menuitemcheckbox", { name: "Wrap lines" })).toHaveAttr("aria-checked", "false"), + click(role("menuitemcheckbox", { name: "Wrap lines" })), + expect(role("menuitemcheckbox", { name: "Wrap lines" })).toHaveAttr("aria-checked", "true"), + expect(role("menuitemradio", { name: "Comfortable" })).toHaveAttr("aria-checked", "true"), + ) + }) + + test("the tooltip item renders the tooltip open over its trigger", () => { + scene( + config, + given(init({ item: "tooltip" }).model), + ...toastPortals(), + Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), + expect(role("button", { name: "Hover target" })).toHaveAttr("data-popup-open", ""), + expect(text("Last refreshed 2 minutes ago")).toExist(), + ) + }) + + test("the command palette filters its groups as the input changes, keeping the first match active", () => { + scene( + config, + given(init({ item: "command" }).model), + ...toastPortals(), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + expectAll(all.role("option")).toHaveCount(5), + type(role("combobox"), "da"), + expectAll(all.role("option")).toHaveCount(1), + expect(role("option", { name: "Create dashboard" })).toHaveAttr("data-highlighted", ""), + expect(role("group", { name: "Pages" })).toBeAbsent(), + ) + }) +}) diff --git a/apps/web-foldkit/src/lab/kit/overlays/update.ts b/apps/web-foldkit/src/lab/kit/overlays/update.ts new file mode 100644 index 0000000000..49daa2397e --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/update.ts @@ -0,0 +1,191 @@ +import { Effect, Option } from "effect" +import { Update } from "foldkit" +import * as Combobox from "../../../ui/combobox" +import * as ContextMenu from "../../../ui/context-menu" +import * as Dialog from "../../../ui/dialog" +import * as HoverCard from "../../../ui/hover-card" +import * as Menu from "../../../ui/menu" +import * as Popover from "../../../ui/popover" +import * as Select from "../../../ui/select" +import * as Toast from "../../../ui/toast" +import * as Tooltip from "../../../ui/tooltip" +import { initKitAutocomplete, initKitComboboxChips, initKitSelect } from "../pickers" +import { + decodeItem, + type Flags, + Message, + type Model, + type OverlayItem, + TOAST_COPY, + toastTypeOf, +} from "./model" + +export const flags: Effect.Effect = Effect.sync(() => ({ + item: new URLSearchParams(window.location.search).get("item"), +})) + +const isItem = (item: Option.Option, name: OverlayItem) => Option.contains(item, name) + +const toastFold = { + read: (model: Model) => Option.some(model.toasts), + write: (model: Model, toasts: Toast.Model): Model => ({ ...model, toasts }), + toParentMessage: (message: Toast.Message) => Message.GotToastMessage({ message }), +} + +/** `KitToast`: one toast of the item's type with no timeout. */ +const addKitToast = (type: Toast.ToastType) => + Update.foldChildStep({ + ...toastFold, + update: (toasts: Toast.Model) => Toast.add(toasts, { ...TOAST_COPY[type], type, timeoutMs: 0 }), + }) + +const toastItemType = (item: Option.Option): Option.Option => + Option.flatMap(item, toastTypeOf) + +/** `defaultOpen`: the overlay named by `?item=` starts open. */ +export const init = (flags: Flags): Update.Return => { + const item = decodeItem(flags.item) + const model: Model = { + item, + dialog: Dialog.init("kit-dialog", "dialog", { isOpen: isItem(item, "dialog") }), + alertDialog: Dialog.init("kit-alert-dialog", "alert-dialog", { + isOpen: isItem(item, "alert-dialog"), + }), + sheet: Dialog.init("kit-sheet", "sheet", { isOpen: isItem(item, "sheet") }), + command: Dialog.init("kit-command", "command-dialog", { isOpen: isItem(item, "command") }), + popover: Popover.init("kit-popover", { isOpen: isItem(item, "popover") }), + hoverCard: HoverCard.init("kit-hover-card", { isOpen: isItem(item, "hover-card") }), + contextMenu: ContextMenu.init("kit-context-menu"), + select: initKitSelect("kit-select", isItem(item, "select")), + combobox: initKitComboboxChips("kit-combobox", isItem(item, "combobox")), + autocomplete: initKitAutocomplete("kit-autocomplete", isItem(item, "autocomplete")), + commandList: Combobox.init("kit-command-list", "autocomplete", { isOpen: true, autoHighlight: true }), + toasts: Toast.init("kit-toast"), + tooltip: Tooltip.init("kit-tooltip", { isOpen: isItem(item, "tooltip") }), + dropdownMenu: Menu.init("kit-dropdown-menu", { isOpen: isItem(item, "dropdown-menu") }), + menuSettings: { showAttributes: true, wrapLines: false, density: "comfortable" }, + } + return Option.match(toastItemType(item), { + onNone: () => ({ model }), + onSome: (type) => addKitToast(type)(model), + }) +} + +const foldToast = Update.foldChild({ ...toastFold, update: Toast.update }) + +/** A gallery field holding one overlay Submodel whose update returns no OutMessage. */ +const fieldFold = ( + field: Field, + update: (model: Model[Field], message: ChildMessage) => Update.Return, + toParentMessage: (message: ChildMessage) => Message, +) => + Update.foldChild({ + update, + read: (model: Model) => Option.some(model[field]), + write: (model: Model, next: Model[Field]): Model => ({ ...model, [field]: next }), + toParentMessage, + }) + +/** The gallery shows the menu only: a picked item needs no reaction. */ +/** Checkbox and radio items update the settings they show; other picks need no reaction. */ +const applyMenuPick = (settings: Model["menuSettings"], outMessage: Menu.OutMessage): Model["menuSettings"] => + Menu.OutMessage.match(outMessage, { + ToggledCheckbox: ({ value }): Model["menuSettings"] => + value === "show-attributes" + ? { ...settings, showAttributes: !settings.showAttributes } + : value === "wrap-lines" + ? { ...settings, wrapLines: !settings.wrapLines } + : settings, + SelectedRadio: ({ value }): Model["menuSettings"] => + value === "compact" || value === "comfortable" ? { ...settings, density: value } : settings, + SelectedItem: () => settings, + SelectedSubmenuItem: () => settings, + }) + +const foldDropdownMenu = Update.foldChild({ + update: Menu.update, + read: (model: Model) => Option.some(model.dropdownMenu), + write: (model: Model, dropdownMenu: Menu.Model): Model => ({ ...model, dropdownMenu }), + toParentMessage: (message: Menu.Message) => Message.GotDropdownMenuMessage({ message }), + foldOutMessage: (outMessage: Menu.OutMessage) => (model: Model) => ({ + model: { ...model, menuSettings: applyMenuPick(model.menuSettings, outMessage) }, + }), +}) + +const foldContextMenu = Update.foldChild({ + update: ContextMenu.update, + read: (model: Model) => Option.some(model.contextMenu), + write: (model: Model, contextMenu: ContextMenu.Model): Model => ({ ...model, contextMenu }), + toParentMessage: (message: ContextMenu.Message) => Message.GotContextMenuMessage({ message }), + foldOutMessage: (_outMessage: ContextMenu.OutMessage) => (model: Model) => ({ model }), +}) + +const comboboxFold = ( + field: "combobox" | "autocomplete" | "commandList", + toParentMessage: (message: Combobox.Message) => Message, +) => + Update.foldChild({ + update: Combobox.update, + read: (model: Model) => Option.some(model[field]), + write: (model: Model, next: Combobox.Model): Model => ({ ...model, [field]: next }), + toParentMessage, + foldOutMessage: (_outMessage: Combobox.OutMessage) => (model: Model) => ({ model }), + }) + +const foldCombobox = comboboxFold("combobox", (message) => Message.GotComboboxMessage({ message })) +const foldAutocomplete = comboboxFold("autocomplete", (message) => + Message.GotAutocompleteMessage({ message }), +) + +const foldCommandList = comboboxFold("commandList", (message) => Message.GotCommandListMessage({ message })) + +const foldSelect = Update.foldChild({ + update: Select.update, + read: (model: Model) => Option.some(model.select), + write: (model: Model, select: Select.Model): Model => ({ ...model, select }), + toParentMessage: (message: Select.Message) => Message.GotSelectMessage({ message }), + foldOutMessage: (_outMessage: Select.OutMessage) => (model: Model) => ({ model }), +}) + +const foldTooltip = fieldFold("tooltip", Tooltip.update, (message) => Message.GotTooltipMessage({ message })) +const foldPopover = fieldFold("popover", Popover.update, (message) => Message.GotPopoverMessage({ message })) +const foldHoverCard = fieldFold("hoverCard", HoverCard.update, (message) => + Message.GotHoverCardMessage({ message }), +) + +type DialogField = "dialog" | "alertDialog" | "sheet" | "command" + +/** The gallery reads `isOpen`; Opened and Closed need no reaction. */ +const ignoreOutMessage = (_outMessage: Dialog.OutMessage) => (model: Model) => ({ model }) + +const dialogFold = (field: DialogField, toParentMessage: (message: Dialog.Message) => Message) => + Update.foldChild({ + update: Dialog.update, + read: (model: Model) => Option.some(model[field]), + write: (model: Model, next: Dialog.Model): Model => ({ ...model, [field]: next }), + toParentMessage, + foldOutMessage: ignoreOutMessage, + }) + +const foldDialog = dialogFold("dialog", (message) => Message.GotDialogMessage({ message })) +const foldAlertDialog = dialogFold("alertDialog", (message) => Message.GotAlertDialogMessage({ message })) +const foldSheet = dialogFold("sheet", (message) => Message.GotSheetMessage({ message })) +const foldCommand = dialogFold("command", (message) => Message.GotCommandMessage({ message })) + +export const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotDialogMessage: ({ message: child }) => foldDialog(model, child), + GotAlertDialogMessage: ({ message: child }) => foldAlertDialog(model, child), + GotSheetMessage: ({ message: child }) => foldSheet(model, child), + GotCommandMessage: ({ message: child }) => foldCommand(model, child), + GotPopoverMessage: ({ message: child }) => foldPopover(model, child), + GotHoverCardMessage: ({ message: child }) => foldHoverCard(model, child), + GotContextMenuMessage: ({ message: child }) => foldContextMenu(model, child), + GotSelectMessage: ({ message: child }) => foldSelect(model, child), + GotComboboxMessage: ({ message: child }) => foldCombobox(model, child), + GotAutocompleteMessage: ({ message: child }) => foldAutocomplete(model, child), + GotCommandListMessage: ({ message: child }) => foldCommandList(model, child), + GotToastMessage: ({ message: child }) => foldToast(model, child), + GotTooltipMessage: ({ message: child }) => foldTooltip(model, child), + GotDropdownMenuMessage: ({ message: child }) => foldDropdownMenu(model, child), + }) diff --git a/apps/web-foldkit/src/lab/kit/overlays/view.ts b/apps/web-foldkit/src/lab/kit/overlays/view.ts new file mode 100644 index 0000000000..f9e197e434 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/overlays/view.ts @@ -0,0 +1,116 @@ +import { OVERLAY_ITEMS } from "@maple/web/lab/kit/overlay-items" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { kitPage, kitSection } from "../kit-layout" +import * as ToastView from "../../../ui/toast-view" +import { kitHoverCard, kitPopover, kitTooltip } from "./cards" +import { kitAlertDialog, kitDialog, kitSheet } from "./dialogs" +import { kitCommand } from "./command" +import { kitContextMenu, kitDropdownMenu } from "./menus" +import { kitAutocomplete, kitComboboxChips, kitSelect } from "../pickers" +import { Message, type Model, type OverlayItem, toastTypeOf } from "./model" + +type H = HtmlBuilder + +const itemRow = (h: H, children: ReadonlyArray) => + h.div([h.Class("flex min-h-48 items-start")], children) + +const renderItem = (h: H, model: Model, item: OverlayItem): Html => { + switch (item) { + case "dialog": + return kitDialog(h, model.dialog) + case "alert-dialog": + return kitAlertDialog(h, model.alertDialog) + case "popover": + return kitPopover(h, model.popover) + case "tooltip": + return kitTooltip(h, model.tooltip) + case "hover-card": + return kitHoverCard(h, model.hoverCard) + case "dropdown-menu": + return kitDropdownMenu(h, model) + case "context-menu": + return kitContextMenu(h, model.contextMenu) + case "select": + return kitSelect( + h, + model.select, + (message) => Message.GotSelectMessage({ message }), + (parts) => itemRow(h, parts), + ) + case "combobox": + return kitComboboxChips( + h, + model.combobox, + (message) => Message.GotComboboxMessage({ message }), + (parts) => itemRow(h, parts), + ) + case "autocomplete": + return kitAutocomplete( + h, + model.autocomplete, + (message) => Message.GotAutocompleteMessage({ message }), + (parts) => itemRow(h, parts), + ) + case "command": + return kitCommand(h, model.command, model.commandList) + case "sheet": + return kitSheet(h, model.sheet) + default: + return h.div( + [h.Class("flex min-h-48 items-start")], + Option.toArray(Option.map(toastTypeOf(item), (type) => kitToastNote(h, type))), + ) + } +} + +/** `KitToast`'s note; the toast itself is in the bottom-right region. Three text nodes, as React + * renders `{type}` between literals: one merged node shapes differently at the joins. */ +const kitToastNote = (h: H, type: string): Html => + h.p( + [h.Class("text-muted-foreground text-sm")], + ["Toast type: ", type, ", shown in the bottom right corner."], + ) + +/** TanStack ``: the active one also gets `active`, `aria-current` and `data-status`. */ +const itemLink = (h: H, name: OverlayItem, isActive: boolean): Html => + h.a( + [ + ...(isActive ? [h.AriaCurrent("page")] : []), + h.Class( + isActive + ? "font-medium text-foreground active" + : "text-muted-foreground hover:text-foreground", + ), + ...(isActive ? [h.DataAttribute("status", "active")] : []), + h.Href(`/lab/kit/overlays?item=${name}`), + ], + [name], + ) + +// apps/web/src/lab/kit/overlays.tsx `KitOverlaysLab` +export const view = (model: Model, h: H): Html => + h.div( + [h.Id("app")], + [ + kitPage( + h, + { + title: "Kit: overlays", + description: "One overlay from @maple/ui per ?item=, rendered open on mount.", + }, + [ + kitSection(h, "Items", [ + h.nav( + [h.AriaLabel("Overlay items"), h.Class("flex flex-wrap gap-x-3 gap-y-1 text-sm")], + OVERLAY_ITEMS.map((name) => itemLink(h, name, Option.contains(model.item, name))), + ), + ]), + ...Option.toArray( + Option.map(model.item, (item) => kitSection(h, item, [renderItem(h, model, item)])), + ), + ], + ), + ...ToastView.regions(h, model.toasts, (message) => Message.GotToastMessage({ message })), + ], + ) diff --git a/apps/web-foldkit/src/lab/kit/pickers.ts b/apps/web-foldkit/src/lab/kit/pickers.ts new file mode 100644 index 0000000000..6c588b18a9 --- /dev/null +++ b/apps/web-foldkit/src/lab/kit/pickers.ts @@ -0,0 +1,130 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Autocomplete from "../../ui/autocomplete" +import * as Combobox from "../../ui/combobox" +import { chipsView, view as comboboxView } from "../../ui/combobox-view" +import { icon } from "../../ui/icon" +import * as Select from "../../ui/select" +import { type SelectEntry, selectedIndexOf, view as selectView } from "../../ui/select-view" + +// apps/web/src/lab/kit/pickers.tsx: the select, combobox and autocomplete fixtures shared by the +// forms page (closed) and the overlays page (open). `place` positions each one's parts. + +export const ENVIRONMENT_ENTRIES: ReadonlyArray = [ + { + _tag: "Group", + label: "Deployed", + items: [ + { value: "production", label: "Production" }, + { value: "staging", label: "Staging" }, + ], + }, + { _tag: "Separator" }, + { _tag: "Item", item: { value: "development", label: "Development" } }, +] + +export const SERVICES = ["api-gateway", "checkout-svc", "payment-svc", "inventory-svc", "notification-svc"] + +export const initKitSelect = (id: string, isOpen = false): Select.Model => + Select.init(id, { + value: "staging", + selectedIndex: selectedIndexOf(ENVIRONMENT_ENTRIES, "staging"), + isOpen, + }) + +export const initKitCombobox = (id: string, isOpen = false): Combobox.Model => + Combobox.init(id, "single", { selected: ["payment-svc"], isOpen }) + +export const initKitComboboxChips = (id: string, isOpen = false): Combobox.Model => + Combobox.init(id, "multiple", { selected: ["checkout-svc", "payment-svc"], isOpen }) + +export const initKitAutocomplete = (id: string, isOpen = false): Combobox.Model => + Combobox.init(id, "autocomplete", { isOpen }) + +type Place = (parts: ReadonlyArray) => Html + +/** `
` around a field, then its hidden input and portal. */ +const sized = + (h: HtmlBuilder, place: Place) => + (parts: Readonly<{ field: Html; rest: ReadonlyArray }>) => + place([h.div([h.Class("w-64")], [parts.field]), ...parts.rest]) + +export const kitSelect = ( + h: HtmlBuilder, + model: Select.Model, + toParentMessage: (message: Select.Message) => M, + place: Place, +): Html => + h.submodel({ + slotId: model.id, + model, + view: selectView, + viewInputs: { + entries: ENVIRONMENT_ENTRIES, + ariaLabel: "Environment", + className: "w-56", + toView: place, + }, + toParentMessage, + }) + +export const kitCombobox = ( + h: HtmlBuilder, + model: Combobox.Model, + toParentMessage: (message: Combobox.Message) => M, + place: Place, +): Html => + h.submodel({ + slotId: model.id, + model, + view: comboboxView, + viewInputs: { + items: SERVICES, + ariaLabel: "Service", + placeholder: "Pick a service", + emptyText: "No services found.", + startAddon: icon(h, "MagnifierIcon"), + toView: sized(h, place), + }, + toParentMessage, + }) + +export const kitComboboxChips = ( + h: HtmlBuilder, + model: Combobox.Model, + toParentMessage: (message: Combobox.Message) => M, + place: Place, +): Html => + h.submodel({ + slotId: model.id, + model, + view: chipsView, + viewInputs: { + items: SERVICES, + ariaLabel: "Services", + placeholder: "Add a service", + emptyText: "No services found.", + className: "w-96", + toView: place, + }, + toParentMessage, + }) + +export const kitAutocomplete = ( + h: HtmlBuilder, + model: Combobox.Model, + toParentMessage: (message: Combobox.Message) => M, + place: Place, +): Html => + h.submodel({ + slotId: model.id, + model, + view: Autocomplete.view, + viewInputs: { + items: SERVICES, + ariaLabel: "Search services", + placeholder: "Search services", + emptyText: "No matches.", + toView: sized(h, place), + }, + toParentMessage, + }) diff --git a/apps/web-foldkit/src/lab/logs-lab/model.ts b/apps/web-foldkit/src/lab/logs-lab/model.ts new file mode 100644 index 0000000000..40e5b85ee8 --- /dev/null +++ b/apps/web-foldkit/src/lab/logs-lab/model.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Shell from "../../shell" +import * as LogsStream from "../../ui/logs/logs-stream" +import * as Menu from "../../ui/menu" +import * as Toast from "../../ui/toast" + +/** The lab's header toggles (apps/web's `LogsLabPage`), pins aside. */ +export const Toggle = Schema.Literals(["Wrap", "Comfortable", "Search", "Empty"]) +export type Toggle = typeof Toggle.Type + +export const PIN_OPTIONS = ["k8s.pod.name", "user.id", "http.route"] as const + +// MODEL + +export const Model = Schema.Struct({ + stream: LogsStream.Model, + footerText: Schema.String, + timeZone: Schema.String, + locale: Schema.String, + refreshVersion: Schema.Number, + isReloading: Schema.Boolean, + /** Auto-refresh cadence in seconds, 0 for off. */ + refreshInterval: Schema.Number, + refreshMenu: Menu.Model, + isInspecting: Schema.Boolean, + isScrolledAway: Schema.Boolean, + wrap: Schema.Boolean, + density: Schema.Literals(["compact", "comfortable"]), + search: Schema.Option(Schema.String), + pinned: Schema.Array(Schema.String), + isEmpty: Schema.Boolean, + attrs: Schema.Array(Schema.String), + toasts: Toast.Model, + /** The real app shell around the page (apps/web's `DashboardLayout`). */ + shell: Shell.Model, +}) +export type Model = typeof Model.Type + +// MESSAGE + +export const Message = defineMessageUnion({ + GotStreamMessage: { message: LogsStream.Message }, + ClickedToggle: { toggle: Toggle }, + ClickedPin: { key: Schema.String }, + ClickedReload: {}, + ClickedJumpToLatest: {}, + TickedAutoRefresh: {}, + ElapsedReloadSpin: { version: Schema.Number }, + GotToastMessage: { message: Toast.Message }, + GotShellMessage: { message: Shell.Message }, + GotRefreshMenuMessage: { message: Menu.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/lab/logs-lab/update.ts b/apps/web-foldkit/src/lab/logs-lab/update.ts new file mode 100644 index 0000000000..39c2b5698c --- /dev/null +++ b/apps/web-foldkit/src/lab/logs-lab/update.ts @@ -0,0 +1,211 @@ +import { buildLogsLabFixture } from "@maple/web/lab/logs-fixture" +import { + addLogAttributeFilter, + type LogAttributeFilter, + decodeLogAttributeFilters, + matchesLogAttributeFilters, +} from "@maple/web/lib/logs/log-attribute-filters" +import { Duration, Effect, Option, Schema } from "effect" +import { Command, Update } from "foldkit" +import { modifyFields } from "foldkit/struct" +import { logTimeFormat, toLogRowView } from "../../ui/logs/log-row-view" +import * as LogsStream from "../../ui/logs/logs-stream" +import * as Menu from "../../ui/menu" +import type { Resources } from "../../platform/resources" +import { listFooterText, type ViewerFlags } from "../../ui/logs/viewer" +import { foldLabShell, initLabShell, type LabShellFlags } from "../app-shell" +import { foldLabToasts, initLabToasts } from "../toasts" +import { Message, type Model, type Toggle } from "./model" + +const ANCHOR_MS = Date.UTC(2026, 9, 8, 14, 32, 10, 482) +const ALL_LOGS = buildLogsLabFixture(ANCHOR_MS) +/** Newest fixture rows held back at load; each reload reveals the next ones at the top. */ +const LIVE_BACKLOG = 60 +const ROWS_PER_TICK = 2 +/** `PageRefreshProvider` keeps the reload button busy this long. */ +const RELOAD_SPIN = Duration.millis(600) + +type Return = Update.Return +type Step = Update.Step + +// DERIVED + +const visibleLogs = (model: Model) => { + const hidden = Math.max(0, LIVE_BACKLOG - model.refreshVersion * ROWS_PER_TICK) + const filters = decodeLogAttributeFilters(model.attrs) + return ALL_LOGS.slice(hidden).filter((log) => + matchesLogAttributeFilters(filters, { log: log.logAttributes, resource: log.resourceAttributes }), + ) +} + +/** The stream's rows, rebuilt whenever the data, pins or emptiness change. */ +const rowsOf = (model: Model) => { + const time = logTimeFormat(model.timeZone) + return (model.isEmpty ? [] : visibleLogs(model)).map((log) => toLogRowView(log, model.pinned, time)) +} + +const footerOf = (model: Model, shown: number) => { + const scope = Option.match(model.search, { onNone: () => "", onSome: (text) => ` matching “${text}”` }) + return listFooterText(model.locale, { shown, noun: `logs${scope}`, capped: false, hasMore: false }) +} + +// INIT + +export const init = (flags: ViewerFlags & LabShellFlags): Return => { + const shell = initLabShell(flags) + const base: Model = { + stream: LogsStream.init("logs-lab", []), + footerText: "", + timeZone: flags.timeZone, + locale: flags.locale, + refreshVersion: 0, + isReloading: false, + refreshInterval: 0, + refreshMenu: Menu.init("logs-lab-refresh-menu"), + isInspecting: false, + isScrolledAway: false, + wrap: false, + density: "compact", + search: Option.none(), + pinned: [], + isEmpty: false, + attrs: [], + toasts: initLabToasts(), + shell: shell.model, + } + const rows = rowsOf(base) + return { + model: modifyFields(base, { + stream: () => LogsStream.init("logs-lab", rows), + footerText: () => footerOf(base, rows.length), + }), + commands: (shell.commands ?? []).map( + Command.mapMessage((message) => Message.GotShellMessage({ message })), + ), + } +} + +// COMMAND + +export const WaitReloadSpin = Command.define("WaitReloadSpin", { + args: { version: Schema.Number }, + messages: [Message.ElapsedReloadSpin], + execute: ({ version }) => + Effect.sleep(RELOAD_SPIN).pipe(Effect.as(Message.ElapsedReloadSpin({ version }))), +}) + +// STREAM + +const unchanged: Step = (model) => ({ model }) + +/** A chip's "Filter in / out": the same `attrs` entry the /logs route would add to its URL. */ +const addFilter = + (filter: LogAttributeFilter): Step => + (model) => + replaceRows(modifyFields(model, { attrs: (attrs) => addLogAttributeFilter(attrs, filter) })) + +const streamOutMessage = LogsStream.OutMessage.match({ + // Opens `LogDetailSheet` in apps/web; the sheet is not ported yet. + OpenedLog: () => unchanged, + ChangedInspecting: + ({ isInspecting, isScrolledAway }) => + (model) => ({ + model: modifyFields(model, { + isInspecting: () => isInspecting, + isScrolledAway: () => isScrolledAway, + }), + }), + ChoseAttributeFilter: addFilter, +}) + +const streamSlot = { + read: (model: Model) => Option.some(model.stream), + write: (model: Model, stream: LogsStream.Model) => modifyFields(model, { stream: () => stream }), + toParentMessage: (message: LogsStream.Message) => Message.GotStreamMessage({ message }), + foldOutMessage: streamOutMessage, +} + +const foldStream = Update.foldChild({ ...streamSlot, update: LogsStream.update }) +const foldSetRows = Update.foldChild({ ...streamSlot, update: LogsStream.setRows }) +const foldReplaceRows = Update.foldChild({ ...streamSlot, update: LogsStream.replaceRows }) +const jumpToLatest = Update.foldChildStep({ ...streamSlot, update: LogsStream.jumpToLatest }) + +// UPDATE + +/** Pins or search changed: the same logs, derived again. */ +const rederive = (model: Model): Return => { + const rows = rowsOf(model) + return foldSetRows(modifyFields(model, { footerText: () => footerOf(model, rows.length) }), rows) +} + +/** New rows on top, a filter, or emptied: the stream drops expansion it made on the old rows. */ +const replaceRows = (model: Model): Return => { + const rows = rowsOf(model) + return foldReplaceRows(modifyFields(model, { footerText: () => footerOf(model, rows.length) }), rows) +} + +const reload = (model: Model): Return => { + const version = model.refreshVersion + 1 + return Update.combine(model, [ + (current) => + replaceRows(modifyFields(current, { refreshVersion: () => version, isReloading: () => true })), + (current) => ({ model: current, commands: [WaitReloadSpin({ version })] }), + ]) +} + +const toggle = (model: Model, which: Toggle): Return => { + switch (which) { + case "Wrap": + return { model: modifyFields(model, { wrap: (wrap) => !wrap }) } + case "Comfortable": + return { + model: modifyFields(model, { density: (d) => (d === "compact" ? "comfortable" : "compact") }), + } + case "Search": + return rederive( + modifyFields(model, { + search: (s) => (Option.isSome(s) ? Option.none() : Option.some("pool")), + }), + ) + case "Empty": + return replaceRows(modifyFields(model, { isEmpty: (isEmpty) => !isEmpty })) + } +} + +const foldToasts = foldLabToasts((message) => Message.GotToastMessage({ message })) +const foldShell = foldLabShell((message) => Message.GotShellMessage({ message })) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotStreamMessage: ({ message: streamMessage }) => foldStream(model, streamMessage), + ClickedToggle: ({ toggle: which }) => toggle(model, which), + ClickedPin: ({ key }) => + rederive( + modifyFields(model, { + pinned: (pinned) => + pinned.includes(key) ? pinned.filter((k) => k !== key) : [...pinned, key], + }), + ), + ClickedReload: () => reload(model), + TickedAutoRefresh: () => reload(model), + ClickedJumpToLatest: () => Update.combine(model, [jumpToLatest, reload]), + ElapsedReloadSpin: ({ version }) => + version === model.refreshVersion + ? { model: modifyFields(model, { isReloading: () => false }) } + : { model }, + GotToastMessage: ({ message }) => foldToasts(model, message), + GotShellMessage: ({ message }) => foldShell(model, message), + GotRefreshMenuMessage: ({ message }) => { + const next = Menu.update(model.refreshMenu, message) + return { + model: modifyFields(model, { + refreshMenu: () => next.model, + refreshInterval: (current) => + next.outMessage?._tag === "SelectedRadio" ? Number(next.outMessage.value) : current, + }), + commands: Command.mapMessages(next.commands ?? [], (inner) => + Message.GotRefreshMenuMessage({ message: inner }), + ), + } + }, + }) diff --git a/apps/web-foldkit/src/lab/logs-lab/view.ts b/apps/web-foldkit/src/lab/logs-lab/view.ts new file mode 100644 index 0000000000..81efb4888a --- /dev/null +++ b/apps/web-foldkit/src/lab/logs-lab/view.ts @@ -0,0 +1,138 @@ +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../ui/button" +import * as LogsStreamView from "../../ui/logs/logs-stream-view" +import { eyebrow } from "../../ui/eyebrow" +import * as ToastView from "../../ui/toast-view" +import { liveControls } from "../../page/logs/live-controls" +import { labShellView } from "../app-shell" +import { Message, type Model, PIN_OPTIONS, type Toggle } from "./model" + +// apps/web/src/lab/logs-lab.tsx `Toggle` +const toggleButton = (h: HtmlBuilder, label: string, isOn: boolean, onClick: Message): Html => + button( + h, + { + variant: isOn ? "secondary" : "outline", + size: "sm", + attributes: [h.AriaPressed(isOn ? "true" : "false"), h.OnClick(onClick)], + }, + [label], + ) + +const toggles = (h: HtmlBuilder, model: Model): ReadonlyArray => { + const flag = (label: string, isOn: boolean, which: Toggle) => + toggleButton(h, label, isOn, Message.ClickedToggle({ toggle: which })) + return [ + flag("Wrap", model.wrap, "Wrap"), + flag("Comfortable", model.density === "comfortable", "Comfortable"), + flag("Search “pool”", Option.isSome(model.search), "Search"), + ...PIN_OPTIONS.map((key) => + toggleButton(h, `Pin ${key}`, model.pinned.includes(key), Message.ClickedPin({ key })), + ), + flag("Empty", model.isEmpty, "Empty"), + ] +} + +const pageHeader = (h: HtmlBuilder, model: Model): Html => + h.div( + [ + h.Class( + "flex flex-col gap-2 @2xl/page:flex-row @2xl/page:items-start @2xl/page:justify-between @2xl/page:gap-4", + ), + h.DataAttribute("slot", "page-header"), + ], + [ + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.div( + [h.Class("flex flex-wrap items-center gap-1.5")], + [eyebrow(h, { className: "mr-1" }, ["Lab"]), ...toggles(h, model)], + ), + ], + ), + h.div( + [h.Class("min-w-0 @2xl/page:shrink-0"), h.DataAttribute("slot", "page-header-actions")], + [ + liveControls(h, { + ...model, + onReload: Message.ClickedReload(), + onJumpToLatest: Message.ClickedJumpToLatest(), + menu: model.refreshMenu, + toMenuMessage: (message) => Message.GotRefreshMenuMessage({ message }), + }), + ], + ), + ], + ) + +const pageBody = (h: HtmlBuilder, model: Model): Html => + h.div( + [h.Class("flex min-h-0 flex-1 overflow-hidden"), h.DataAttribute("slot", "page-body")], + [ + h.main( + [ + h.Class("@container/page flex min-h-0 min-w-0 flex-1 flex-col"), + h.DataAttribute("slot", "page-content"), + h.Id("main-content"), + ], + [ + h.div( + [ + h.Class( + cn( + "shrink-0 space-y-4 p-4 transition-shadow", + model.isScrolledAway && "shadow-[0_2px_8px_rgba(0,0,0,0.08)]", + ), + ), + h.DataAttribute("slot", "page-sticky-area"), + ], + [pageHeader(h, model)], + ), + h.div( + [ + h.Class("flex min-h-0 min-w-0 flex-1 flex-col"), + h.DataAttribute("slot", "page-fill"), + ], + // The empty state (`SignalEmptyState`) and the filter chip strip are not ported yet. + model.stream.rows.length === 0 + ? [] + : [ + h.submodel({ + slotId: "logs-stream", + model: model.stream, + view: LogsStreamView.view, + toParentMessage: (message) => Message.GotStreamMessage({ message }), + viewInputs: { + wrap: model.wrap, + density: model.density, + pinnedColumns: model.pinned, + searchText: Option.getOrUndefined(model.search), + timeZone: model.timeZone, + isWaiting: false, + footerText: model.footerText, + canFilter: true, + }, + }), + ], + ), + ], + ), + ], + ) + +/** apps/web's `DashboardLayout` around the lab page: the real shell, then the toast regions. */ +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + [ + ...labShellView(h, model.shell, (message) => Message.GotShellMessage({ message }), { + currentPath: "/lab/logs", + breadcrumbs: [{ label: "Lab" }, { label: "Logs" }], + page: () => pageBody(h, model), + }), + ...ToastView.regions(h, model.toasts, (message) => Message.GotToastMessage({ message })), + ], + ) diff --git a/apps/web-foldkit/src/lab/nodes-lab.ts b/apps/web-foldkit/src/lab/nodes-lab.ts new file mode 100644 index 0000000000..b135c7f9e6 --- /dev/null +++ b/apps/web-foldkit/src/lab/nodes-lab.ts @@ -0,0 +1,141 @@ +import { buildNodeCatalogue } from "@maple/web/lab/node-catalogue/node-catalogue" +import { Clock, Effect, Option, Schema } from "effect" +import { Command, Submodel, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { load } from "foldkit/navigation" +import { Message as InvestigationMessage } from "../page/investigations/detail/message" +import { edgeLabel, edgeView, nodeChildren } from "../page/investigations/detail/view-canvas" +import { dashboardPage } from "../page/kit/dashboard-page" +import * as Canvas from "../service-map/xyflow/canvas" +import { edgesLayer, flowCanvas, flowNode, nodesLayer } from "../service-map/xyflow/view" + +// apps/web `routes/lab/nodes.tsx` + `lab/node-catalogue/node-catalogue-canvas.tsx`: every +// provenance-canvas node and edge state in the investigation page's renderer, at 1:1. + +const CATALOGUE = buildNodeCatalogue() +/** Breathing room around the grid, and the offset the default viewport opens at. */ +const PAD = 24 + +export const Model = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + canvas: Canvas.Model, +}) +export type Model = typeof Model.Type + +/** The cards are the investigation page's: its canvas and link Messages are the ones acted on. */ +export const Message = defineMessageUnion({ + GotInvestigationMessage: { message: InvestigationMessage }, + CompletedLoadNodeLink: {}, +}) +export type Message = typeof Message.Type + +/** A card's link leaves the studio for the page it names, as legacy's router link does. */ +const LoadNodeLink = Command.define("LoadNodeLinkFromLab", { + args: { href: Schema.String }, + messages: [Message.CompletedLoadNodeLink], + execute: ({ href }) => Effect.as(load(href), Message.CompletedLoadNodeLink()), +}) + +export const Flags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), +}) + +/** `defaultViewport={{ x: PAD, y: PAD, zoom: 1 }}`, `minZoom={0.2}`, `maxZoom={1.5}`. */ +export const init = (flags: Flags) => ({ + model: { + ...flags, + canvas: { + ...Canvas.init({ minZoom: 0.2, maxZoom: 1.5, fitOnInit: Option.none() }), + viewport: { x: PAD, y: PAD, zoom: 1 }, + }, + }, +}) + +export const update = (model: Model, message: Message): Update.Return => + Message.match>(message, { + GotInvestigationMessage: ({ message: inner }) => { + if (inner._tag === "GotCanvasMessage") + return { model: { ...model, canvas: Canvas.update(model.canvas, inner.message, []) } } + if (inner._tag === "ClickedNodeLink") + return { model, commands: [LoadNodeLink({ href: inner.href })] } + return { model } + }, + CompletedLoadNodeLink: () => ({ model }), + }) + +type CanvasInputs = Readonly<{ nowMs: number; timeZone: string }> + +const ANCHORS = new Map(CATALOGUE.nodes.map((node) => [node.id, node])) +const someOf = (value: A | null) => Option.toArray(Option.fromNullishOr(value)) + +/** `` over the catalogue: `nodeTypes`/`edgeTypes` are the investigation canvas's own. */ +const canvasView = Submodel.defineView( + (canvas, inputs, h) => + flowCanvas(h, { + model: canvas, + toMessage: (message) => InvestigationMessage.GotCanvasMessage({ message }), + edges: edgesLayer( + h, + CATALOGUE.edges.flatMap((edge) => someOf(edgeView(h, edge, ANCHORS))), + ), + edgeLabels: CATALOGUE.edges.flatMap((edge) => someOf(edgeLabel(h, edge, ANCHORS))), + nodes: nodesLayer( + h, + CATALOGUE.nodes.map((node) => + flowNode(h, { + id: node.id, + type: node.type, + position: node.position, + measured: true, + style: { + width: `${node.width}px`, + height: `${node.height}px`, + "pointer-events": "all", + }, + children: nodeChildren(h, node, inputs), + }), + ), + ), + panels: [], + }), +) + +export const view = (model: Model, h: HtmlBuilder): Html => + dashboardPage(h, {}, [ + h.div( + [h.Class("flex flex-col")], + [ + h.div( + [h.Class("border-b px-4 py-3")], + [ + h.h1([h.Class("text-sm font-semibold")], ["Node Lab"]), + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + "Every provenance-canvas node and edge state, in the production renderer, at 1:1. Scroll the page; pan and zoom inside the canvas.", + ], + ), + ], + ), + h.div( + [h.Style({ height: `${CATALOGUE.height + PAD * 2}px`, width: "100%" })], + [ + h.submodel({ + slotId: "node-catalogue-canvas", + model: model.canvas, + view: canvasView, + viewInputs: { nowMs: model.nowMs, timeZone: model.timeZone }, + toParentMessage: (message: InvestigationMessage) => + Message.GotInvestigationMessage({ message }), + }), + ], + ), + ], + ), + ]) diff --git a/apps/web-foldkit/src/lab/query-builder-lab/command.ts b/apps/web-foldkit/src/lab/query-builder-lab/command.ts new file mode 100644 index 0000000000..a640484cfb --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/command.ts @@ -0,0 +1,164 @@ +import { createFormulaDraft, createQueryDraft } from "@maple/query-engine/query-builder" +import { listMetrics } from "@maple/web-data/warehouse/metrics" +import { getQueryBuilderTimeseries } from "@maple/web-data/warehouse/query-builder-timeseries" +import { Clock, Duration, Effect, Option, Queue, Schema, Stream } from "effect" +import { Command, Mount } from "foldkit" +import { toLoadFailure } from "../../data/load-failure" +import { currentWindow, TimeWindow } from "../../data/time-range" +import { AutocompleteLookup, autocompleteLookup } from "../../page/kit/autocomplete-lookups" +import { MapleAuthApi, readSessionToken } from "../../platform/auth-api" +import { debugWarnings, toRunPoints } from "./derive" +import { Message, RequestMode, SubmittedInput, TimeSearch } from "./model" + +/** The lab boots without the app root, so it resolves its own org from the stored session. */ +export const CheckSession = Command.define("CheckQueryBuilderLabSession", { + messages: [Message.SucceededCheckSession, Message.FailedCheckSession], + execute: Effect.gen(function* () { + const maybeToken = yield* readSessionToken + if (Option.isNone(maybeToken)) return Message.FailedCheckSession({ session: "SignedOut" }) + const auth = yield* MapleAuthApi + const tenant = yield* auth.session(maybeToken.value) + return Message.SucceededCheckSession({ orgId: tenant.orgId }) + }).pipe( + Effect.catchTag("@maple/web-foldkit/SessionCheckError", (error) => + Effect.succeed( + Message.FailedCheckSession({ + session: error.reason === "Rejected" ? "SignedOut" : "Unreachable", + }), + ), + ), + ), +}) + +/** `useEffectiveTimeRange(search, preset)`: snapped on entry, unsnapped after Reload. */ +export const ResolveWindow = Command.define("ResolveQueryBuilderLabWindow", { + args: { search: TimeSearch, preset: Schema.String, timeZone: Schema.String, mode: RequestMode }, + messages: [Message.CompletedResolveWindow], + execute: ({ search, preset, timeZone, mode }) => + Effect.map(currentWindow({ ...search, preset, timeZone, snap: mode === "Load" }), (window) => + Message.CompletedResolveWindow({ window, mode }), + ), +}) + +/** One `AutocompleteValuesProvider` lookup over the page's window. */ +export const FetchAutocomplete = Command.define("FetchQueryBuilderLabAutocomplete", { + args: { key: Schema.String, lookup: AutocompleteLookup, window: TimeWindow }, + messages: [Message.CompletedFetchAutocomplete], + execute: ({ key, lookup, window }) => + autocompleteLookup(lookup, window).pipe( + Effect.mapError(toLoadFailure), + Effect.result, + Effect.map((result) => Message.CompletedFetchAutocomplete({ key, lookup, result })), + ), +}) + +/** `listMetricsResultAtom({ data: { limit: 300 } })`. */ +export const FetchMetrics = Command.define("FetchQueryBuilderLabMetrics", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchMetrics], + execute: ({ key }) => + listMetrics({ data: { limit: 300 } }).pipe( + Effect.map((response) => + response.data.map((row) => ({ metricName: row.metricName, metricType: row.metricType })), + ), + Effect.mapError(toLoadFailure), + Effect.result, + Effect.map((result) => Message.CompletedFetchMetrics({ key, result })), + ), +}) + +/** `getQueryBuilderTimeseriesResultAtom({ data: submittedInput })`. */ +export const FetchTimeseries = Command.define("FetchQueryBuilderLabTimeseries", { + args: { key: Schema.String, input: SubmittedInput }, + messages: [Message.CompletedFetchTimeseries], + execute: ({ key, input }) => + getQueryBuilderTimeseries({ + data: { + startTime: input.startTime, + endTime: input.endTime, + queries: input.queries.map((query) => ({ ...query })), + formulas: input.formulas.map((formula) => ({ ...formula })), + }, + }).pipe( + Effect.map((response) => ({ + points: toRunPoints(response.data), + warnings: debugWarnings(response.diagnostics), + })), + Effect.mapError(toLoadFailure), + Effect.result, + Effect.map((result) => Message.CompletedFetchTimeseries({ key, result })), + ), +}) + +/** `PageRefreshProvider`'s Reload spin. */ +export const CompleteReloadSpin = Command.define("CompleteQueryBuilderLabReloadSpin", { + args: { version: Schema.Number }, + messages: [Message.CompletedReloadSpin], + execute: ({ version }) => + Effect.as(Effect.sleep(Duration.millis(600)), Message.CompletedReloadSpin({ version })), +}) + +/** `createQueryDraft` draws a random id, so it runs here. */ +export const CreateQuery = Command.define("CreateQueryBuilderLabQuery", { + args: { index: Schema.Number }, + messages: [Message.CreatedQuery], + execute: ({ index }) => Effect.sync(() => Message.CreatedQuery({ query: createQueryDraft(index) })), +}) + +export const CreateFormula = Command.define("CreateQueryBuilderLabFormula", { + args: { index: Schema.Number, queryNames: Schema.Array(Schema.String) }, + messages: [Message.CreatedFormula], + execute: ({ index, queryNames }) => + Effect.sync(() => Message.CreatedFormula({ formula: createFormulaDraft(index, [...queryNames]) })), +}) + +/** `cloneQuery`'s `crypto.randomUUID()`. */ +export const CreateCloneId = Command.define("CreateQueryBuilderLabCloneId", { + args: { queryId: Schema.String }, + messages: [Message.CreatedCloneId], + execute: ({ queryId }) => Effect.sync(() => Message.CreatedCloneId({ queryId, id: crypto.randomUUID() })), +}) + +/** `setLastRunAt(new Date().toLocaleTimeString())`. */ +export const StampRun = Command.define("StampQueryBuilderLabRun", { + messages: [Message.StampedRun], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => + Message.StampedRun({ at: new Date(nowMs).toLocaleTimeString() }), + ), +}) + +const viewportOf = (content: Element): Option.Option => + Option.fromNullishOr(content.closest("[data-slot=scroll-area-viewport]")) + +/** + * Base UI's `ScrollArea` measures its viewport on mount and whenever it or its content resizes. + * Mounted on the content, the viewport's only child. + */ +export const MeasureScrollArea = Mount.defineStream("MeasureQueryBuilderLabScrollArea", { + messages: [Message.MeasuredScroll], + execute: ({ element }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const viewport = viewportOf(element) + const observer = new ResizeObserver(() => + Option.map(viewport, (node) => + Queue.offerUnsafe( + queue, + Message.MeasuredScroll({ + metrics: { + clientHeight: node.clientHeight, + scrollHeight: node.scrollHeight, + }, + }), + ), + ), + ) + Option.map(viewport, (node) => observer.observe(node)) + observer.observe(element) + return observer + }), + (observer) => Effect.sync(() => observer.disconnect()), + ).pipe(Effect.andThen(Effect.never)), + ), +}) diff --git a/apps/web-foldkit/src/lab/query-builder-lab/derive.test.ts b/apps/web-foldkit/src/lab/query-builder-lab/derive.test.ts new file mode 100644 index 0000000000..8868f30d27 --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/derive.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest" +import { groupByOptionsOf, parseMetricSelection, scrollOverflowOf, seriesKeysOf, toRunPoints } from "./derive" +import { searchOf } from "./flags" + +describe("query builder lab derivations", () => { + test("run points keep the bucket and the finite numeric series only", () => { + const points = toRunPoints([{ bucket: "2026-10-01 11:00:00", A: 3, B: "4.5", C: "n/a" }]) + expect(points).toEqual([{ bucket: "2026-10-01 11:00:00", series: { A: 3, B: 4.5 } }]) + }) + + test("the table shows at most six series, in first-seen order", () => { + const series = Object.fromEntries(["a", "b", "c", "d", "e", "f", "g"].map((key) => [key, 1])) + expect(seriesKeysOf([{ bucket: "x", series }])).toEqual(["a", "b", "c", "d", "e", "f"]) + }) + + test("traces group by span attributes, header attributes excluded; other sources do not", () => { + const keys = ["http.route", "http.request.header.cookie", "http.response.header.etag"] + expect(groupByOptionsOf("traces", keys)).toEqual([ + "service.name", + "span.name", + "status.code", + "http.method", + "none", + "attr.http.route", + ]) + expect(groupByOptionsOf("logs", keys)).toEqual(["service.name", "severity", "none"]) + }) + + test("a metric selection needs a name and a known type", () => { + expect(parseMetricSelection("latency::gauge")).toEqual({ metricName: "latency", metricType: "gauge" }) + expect(parseMetricSelection("latency::summary")).toBeNull() + expect(parseMetricSelection("::gauge")).toBeNull() + }) + + test("the scrollbar thumb scales the track by the visible share, as Base UI measures it", () => { + expect(scrollOverflowOf({ clientHeight: 832, scrollHeight: 1401 })).toEqual({ + endPx: 569, + thumbHeightPx: (824 * 832) / 1401, + }) + expect(scrollOverflowOf({ clientHeight: 832, scrollHeight: 832 })).toBeUndefined() + }) + + test("the URL search keeps only the window fields that are set", () => { + expect(searchOf("?timePreset=6h&startTime=&tab=x")).toEqual({ timePreset: "6h" }) + }) +}) diff --git a/apps/web-foldkit/src/lab/query-builder-lab/derive.ts b/apps/web-foldkit/src/lab/query-builder-lab/derive.ts new file mode 100644 index 0000000000..8b8cf6477c --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/derive.ts @@ -0,0 +1,123 @@ +import { formulaLabel, queryLabel } from "@maple/query-engine/query-builder" +import type { TimeseriesQuerySetDiagnostics } from "@maple/query-engine/query-set" +import { QUERY_BUILDER_METRIC_TYPES, type QueryBuilderMetricType } from "@maple/query-model" +import type { QueryDraft } from "../../page/kit/query-builder/draft" +import type { ScrollAreaOverflowY } from "../../ui/scroll-area" +import type { FormulaDraft, MetricRow, RunPoint, ScrollMetrics } from "./model" + +// The lab's pure helpers (apps/web `lab/query-builder-lab.tsx`). + +const isMetricType = (value: string): value is QueryBuilderMetricType => + QUERY_BUILDER_METRIC_TYPES.some((type) => type === value) + +/** `toRunPoints`: each row's bucket and its finite numeric series. */ +export const toRunPoints = ( + rows: ReadonlyArray>>, +): ReadonlyArray => + rows.map((row) => ({ + bucket: String(row["bucket"] ?? ""), + series: Object.fromEntries( + Object.entries(row).flatMap(([key, value]) => { + if (key === "bucket") return [] + const numeric = typeof value === "number" ? value : Number(value) + return Number.isFinite(numeric) ? [[key, numeric] as const] : [] + }), + ), + })) + +/** `debugWarnings`: the queries that fell back to a wider range. */ +export const debugWarnings = (diagnostics: TimeseriesQuerySetDiagnostics): ReadonlyArray => + diagnostics.queries + .filter((entry) => entry.fallbackUsed) + .map((entry) => `${entry.queryName} used fallback range`) + +/** The first six series keys across the points, in first-seen order. */ +export const seriesKeysOf = (points: ReadonlyArray): ReadonlyArray => + Array.from(new Set(points.flatMap((point) => Object.keys(point.series)))).slice(0, 6) + +export type MetricOption = Readonly<{ + value: string + label: string + metricName: string + metricType: QueryBuilderMetricType +}> + +/** `metricOptions`: one option per known name and type, first occurrence wins. */ +export const metricOptionsOf = (rows: ReadonlyArray): ReadonlyArray => { + const options = rows.flatMap((row): ReadonlyArray => { + const { metricType } = row + if (!isMetricType(metricType)) return [] + const value = `${row.metricName}::${metricType}` + return [{ value, label: `${row.metricName} (${metricType})`, metricName: row.metricName, metricType }] + }) + return options.filter( + (option, index) => options.findIndex((other) => other.value === option.value) === index, + ) +} + +/** `parseMetricSelection`: `name::type` back into its parts. */ +export const parseMetricSelection = ( + raw: string, +): Readonly<{ metricName: string; metricType: QueryBuilderMetricType }> | null => { + const [metricName = "", metricType = ""] = raw.split("::") + if (metricName === "" || !isMetricType(metricType)) return null + return { metricName, metricType } +} + +/** The effect on `metricOptions`: a metrics query with no metric takes the first one. */ +export const withFirstMetric = ( + queries: ReadonlyArray, + options: ReadonlyArray, +): ReadonlyArray => { + const [first] = options + if (first === undefined) return queries + return queries.map((query) => + query.dataSource !== "metrics" || query.metricName !== "" + ? query + : { ...query, metricName: first.metricName, metricType: first.metricType }, + ) +} + +export const relabelQueries = (queries: ReadonlyArray): ReadonlyArray => + queries.map((query, index) => ({ ...query, name: queryLabel(index) })) + +export const relabelFormulas = (formulas: ReadonlyArray): ReadonlyArray => + formulas.map((formula, index) => ({ ...formula, name: formulaLabel(index) })) + +// copied: the lab's own `GROUP_BY_OPTIONS` (not query-engine's, which lists other fields). +const GROUP_BY_OPTIONS = { + traces: ["service.name", "span.name", "status.code", "http.method", "none"], + logs: ["service.name", "severity", "none"], + metrics: ["service.name", "none"], + product_events: ["event.name", "page.path", "none"], +} satisfies Record> + +/** `GroupByAutocomplete`'s options: the static fields, then span attributes as `attr.*` for traces. */ +export const groupByOptionsOf = ( + dataSource: QueryDraft["dataSource"], + attributeKeys: ReadonlyArray, +): ReadonlyArray => [ + ...GROUP_BY_OPTIONS[dataSource], + ...(dataSource === "traces" + ? attributeKeys + .filter( + (key) => + !key.startsWith("http.request.header.") && !key.startsWith("http.response.header."), + ) + .map((key) => `attr.${key}`) + : []), +] + +/** Base UI's thumb: the track (viewport less the scrollbar's `m-1`) scaled by the visible share. */ +const SCROLLBAR_MARGIN_PX = 8 +const MIN_THUMB_PX = 16 + +export const scrollOverflowOf = (metrics: ScrollMetrics | null): ScrollAreaOverflowY | undefined => { + if (metrics === null || metrics.scrollHeight <= metrics.clientHeight) return undefined + const { clientHeight, scrollHeight } = metrics + const track = clientHeight - SCROLLBAR_MARGIN_PX + return { + endPx: scrollHeight - clientHeight, + thumbHeightPx: Math.max(MIN_THUMB_PX, (track * clientHeight) / scrollHeight), + } +} diff --git a/apps/web-foldkit/src/lab/query-builder-lab/flags.ts b/apps/web-foldkit/src/lab/query-builder-lab/flags.ts new file mode 100644 index 0000000000..e2de471f25 --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/flags.ts @@ -0,0 +1,24 @@ +import { createFormulaDraft, createQueryDraft } from "@maple/query-engine/query-builder" +import { Clock, Effect, Option, Schema } from "effect" +import { readEffectiveTimeZone } from "../../platform/time-zone" +import { type Flags, TimeSearch } from "./model" + +/** The route's `TimeRangeSearchFields`, read once from the URL (a lab entry has no router). */ +const SEARCH_KEYS = new Set(["startTime", "endTime", "timePreset"]) +const decodeSearch = Schema.decodeUnknownOption(TimeSearch) + +export const searchOf = (query: string): TimeSearch => { + const present = [...new URLSearchParams(query)].filter( + ([key, value]) => SEARCH_KEYS.has(key) && value !== "", + ) + return Option.getOrElse(decodeSearch(Object.fromEntries(present)), () => ({})) +} + +/** The clock, zone, URL window and the first drafts (`createQueryDraft` draws random ids). */ +export const flags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + timeZone: readEffectiveTimeZone, + search: Effect.sync(() => searchOf(window.location.search)), + queries: Effect.sync(() => [createQueryDraft(0), createQueryDraft(1)]), + formulas: Effect.sync(() => [createFormulaDraft(0, ["A", "B"])]), +}) diff --git a/apps/web-foldkit/src/lab/query-builder-lab/model.ts b/apps/web-foldkit/src/lab/query-builder-lab/model.ts new file mode 100644 index 0000000000..a33717375f --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/model.ts @@ -0,0 +1,141 @@ +import { OrgId } from "@maple/domain" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { KeyedSchema } from "../../data/keyed" +import { LoadFailure } from "../../data/load-failure" +import { TimeWindow } from "../../data/time-range" +import { AutocompleteLookup, LookupNames } from "../../page/kit/autocomplete-lookups" +import { QueryChange } from "../../page/kit/query-builder/change" +import { QueryDraft } from "../../page/kit/query-builder/draft" +import * as TimeRangePicker from "../../page/kit/time-range-picker" +import * as Combobox from "../../ui/combobox" +import * as Select from "../../ui/select" + +// apps/web `lab/query-builder-lab.tsx` and its route (`routes/lab/query-builder.tsx`). + +export const Session = Schema.Literals(["Checking", "SignedIn", "SignedOut", "Unreachable"]) +export type Session = typeof Session.Type + +export const RequestMode = Schema.Literals(["Load", "Refresh"]) + +/** `TimeRangeSearchFields`: the route's search, held locally (a lab has no router). */ +export const TimeSearch = Schema.Struct({ + startTime: Schema.optionalKey(Schema.String), + endTime: Schema.optionalKey(Schema.String), + timePreset: Schema.optionalKey(Schema.String), +}) +export type TimeSearch = typeof TimeSearch.Type + +/** `QueryBuilderFormulaDraft`, in `createFormulaDraft`'s key order (the state preview prints it). */ +export const FormulaDraft = Schema.Struct({ + id: Schema.String, + name: Schema.String, + expression: Schema.String, + legend: Schema.String, + hidden: Schema.Boolean, +}) +export type FormulaDraft = typeof FormulaDraft.Type + +/** `QueryBuilderTimeseriesInput` as the lab submits it. */ +export const SubmittedInput = Schema.Struct({ + startTime: Schema.String, + endTime: Schema.String, + queries: Schema.Array(QueryDraft), + formulas: Schema.Array(FormulaDraft), +}) +export type SubmittedInput = typeof SubmittedInput.Type + +/** `listMetrics` rows, reduced to what the metric select lists. */ +export const MetricRow = Schema.Struct({ metricName: Schema.String, metricType: Schema.String }) +export type MetricRow = typeof MetricRow.Type + +/** `toRunPoints` and `debugWarnings` of a `getQueryBuilderTimeseries` response. */ +export const RunPoint = Schema.Struct({ + bucket: Schema.String, + series: Schema.Record(Schema.String, Schema.Number), +}) +export type RunPoint = typeof RunPoint.Type + +export const RunResult = Schema.Struct({ + points: Schema.Array(RunPoint), + warnings: Schema.Array(Schema.String), +}) +export type RunResult = typeof RunResult.Type + +/** What the scroll area's viewport measured (Base UI's `ScrollArea` state). */ +export const ScrollMetrics = Schema.Struct({ clientHeight: Schema.Number, scrollHeight: Schema.Number }) +export type ScrollMetrics = typeof ScrollMetrics.Type + +export const FormulaField = Schema.Literals(["expression", "legend"]) +export type FormulaField = typeof FormulaField.Type + +export const Model = Schema.Struct({ + session: Session, + orgId: Schema.NullOr(OrgId), + timeZone: Schema.String, + search: TimeSearch, + window: TimeWindow, + picker: TimeRangePicker.Model, + refreshVersion: Schema.Number, + isReloading: Schema.Boolean, + queries: Schema.Array(QueryDraft), + formulas: Schema.Array(FormulaDraft), + lastRunAt: Schema.NullOr(Schema.String), + submitted: Schema.NullOr(SubmittedInput), + noQueriesError: Schema.NullOr(Schema.String), + results: KeyedSchema(RunResult, LoadFailure), + metrics: KeyedSchema(Schema.Array(MetricRow), LoadFailure), + autocomplete: Schema.Record(Schema.String, KeyedSchema(LookupNames, LoadFailure)), + selects: Schema.Record(Schema.String, Select.Model), + groupBys: Schema.Record(Schema.String, Combobox.Model), + scroll: Schema.NullOr(ScrollMetrics), +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + SucceededCheckSession: { orgId: OrgId }, + FailedCheckSession: { session: Session }, + CompletedResolveWindow: { window: TimeWindow, mode: RequestMode }, + CompletedFetchAutocomplete: { + key: Schema.String, + lookup: AutocompleteLookup, + result: Schema.Result(LookupNames, LoadFailure), + }, + CompletedFetchMetrics: { + key: Schema.String, + result: Schema.Result(Schema.Array(MetricRow), LoadFailure), + }, + CompletedFetchTimeseries: { key: Schema.String, result: Schema.Result(RunResult, LoadFailure) }, + GotPickerMessage: { message: TimeRangePicker.Message }, + ClickedResetTimeRange: {}, + ClickedReload: {}, + CompletedReloadSpin: { version: Schema.Number }, + ClickedAddQuery: {}, + CreatedQuery: { query: QueryDraft }, + ClickedAddFormula: {}, + CreatedFormula: { formula: FormulaDraft }, + ClickedCloneQuery: { queryId: Schema.String }, + CreatedCloneId: { queryId: Schema.String, id: Schema.String }, + ClickedRemoveQuery: { queryId: Schema.String }, + ClickedRemoveFormula: { formulaId: Schema.String }, + ClickedRun: {}, + StampedRun: { at: Schema.String }, + ToggledEnabled: { queryId: Schema.String }, + ChangedQuery: { queryId: Schema.String, change: QueryChange }, + GotSelectMessage: { id: Schema.String, message: Select.Message }, + GotGroupByMessage: { queryId: Schema.String, message: Combobox.Message }, + ChangedFormulaText: { formulaId: Schema.String, field: FormulaField, value: Schema.String }, + PressedWhereEnter: {}, + MeasuredScroll: { metrics: ScrollMetrics }, +}) +export type Message = typeof Message.Type + +/** Read before init: the clock, the zone, the URL's window and the first drafts' random ids. */ +export const Flags = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + search: TimeSearch, + queries: Schema.Array(QueryDraft), + formulas: Schema.Array(FormulaDraft), +}) +export type Flags = typeof Flags.Type diff --git a/apps/web-foldkit/src/lab/query-builder-lab/update-controls.ts b/apps/web-foldkit/src/lab/query-builder-lab/update-controls.ts new file mode 100644 index 0000000000..6b2a229240 --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/update-controls.ts @@ -0,0 +1,90 @@ +import { toQueryBuilderDataSource } from "@maple/query-model" +import { Command, type Update } from "foldkit" +import { applyQueryChange, QueryChange } from "../../page/kit/query-builder/change" +import type { QueryDraft } from "../../page/kit/query-builder/draft" +import type { Resources } from "../../platform/resources" +import * as Combobox from "../../ui/combobox" +import * as Select from "../../ui/select" +import { parseMetricSelection } from "./derive" +import { Message, type Model } from "./model" + +// The lab's selects (`:`) and group-by comboboxes, each kept by id. + +type Return = Update.Return + +export const updateQuery = ( + model: Model, + queryId: string, + updater: (query: QueryDraft) => QueryDraft, +): Model => ({ + ...model, + queries: model.queries.map((query) => (query.id === queryId ? updater(query) : query)), +}) + +/** A select's value as the edit its `onValueChange` makes. */ +const selectEdit = (field: string, value: string): ((query: QueryDraft) => QueryDraft) | null => { + switch (field) { + case "source": { + const source = toQueryBuilderDataSource(value) + return source === null + ? null + : (query) => applyQueryChange(query, QueryChange.SetSource({ source })) + } + case "aggregation": + return (query) => applyQueryChange(query, QueryChange.SetAggregation({ aggregation: value })) + case "orderDirection": + return value === "asc" || value === "desc" + ? (query) => applyQueryChange(query, QueryChange.SetOrderDirection({ direction: value })) + : null + case "metric": { + const parsed = parseMetricSelection(value) + return parsed === null + ? null + : (query) => (query.dataSource === "metrics" ? { ...query, ...parsed } : query) + } + case "signal": + return value === "default" || value === "meter" + ? (query) => (query.dataSource === "metrics" ? { ...query, signalSource: value } : query) + : null + default: + return null + } +} + +export const selectStep = (model: Model, id: string, message: Select.Message): Return => { + const current = model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }) + const next = Select.update(current, message) + const lifted: Model = { ...model, selects: { ...model.selects, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotSelectMessage({ id, message: inner }), + ) + if (next.outMessage === undefined) return { model: lifted, commands } + const [field = "", queryId = ""] = id.split(":") + const edit = selectEdit(field, next.outMessage.value) + return { model: edit === null ? lifted : updateQuery(lifted, queryId, edit), commands } +} + +/** `GroupByAutocomplete`'s combobox for a query, seeded with its first group-by field. */ +export const groupByModelOf = (model: Model, query: QueryDraft): Combobox.Model => + model.groupBys[query.id] ?? + Combobox.init(`group-by-${query.id}`, "single", { selected: query.groupBy.slice(0, 1) }) + +export const groupByStep = (model: Model, queryId: string, message: Combobox.Message): Return => { + const query = model.queries.find((candidate) => candidate.id === queryId) + if (query === undefined) return { model } + const next = Combobox.update(groupByModelOf(model, query), message) + const lifted: Model = { ...model, groupBys: { ...model.groupBys, [queryId]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotGroupByMessage({ queryId, message: inner }), + ) + if (next.outMessage === undefined || next.outMessage._tag !== "ChangedValue") + return { model: lifted, commands } + const { value } = next.outMessage + const groupBy = value === "" ? [] : [value] + return { + model: updateQuery(lifted, queryId, (current) => + applyQueryChange(current, QueryChange.SetGroupBy({ groupBy })), + ), + commands, + } +} diff --git a/apps/web-foldkit/src/lab/query-builder-lab/update.test.ts b/apps/web-foldkit/src/lab/query-builder-lab/update.test.ts new file mode 100644 index 0000000000..e9bd5774a7 --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/update.test.ts @@ -0,0 +1,179 @@ +import { OrgId } from "@maple/domain" +import { createFormulaDraft, createQueryDraft } from "@maple/query-engine/query-builder" +import { Result, Schema } from "effect" +import { describe, expect, test } from "vitest" +import { requestKey } from "../../data/request-key" +import * as Select from "../../ui/select" +import type { QueryDraft } from "../../page/kit/query-builder/draft" +import * as TimeRangePicker from "../../page/kit/time-range-picker" +import { type Flags, Message, type Model } from "./model" +import { init, NO_ENABLED_QUERIES, update } from "./update" + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const NOON = Date.UTC(2026, 9, 1, 12, 0, 0) +const HOUR = { startTime: "2026-10-01 11:00:00", endTime: "2026-10-01 12:00:00" } + +const draft = (index: number): QueryDraft => ({ ...createQueryDraft(index), id: `q${index}` }) + +const flags = (search: Flags["search"] = {}): Flags => ({ + nowMs: NOON, + timeZone: "UTC", + search, + queries: [draft(0), draft(1)], + formulas: [{ ...createFormulaDraft(0, ["A", "B"]), id: "f0" }], +}) + +type Step = Readonly<{ + model: Model + commands?: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> +}> + +const names = (step: Step) => (step.commands ?? []).map((command) => command.name) +const argsOf = (step: Step, name: string) => + (step.commands ?? []).filter((command) => command.name === name).map((command) => command.args) + +const signedIn = (search?: Flags["search"]): Model => + update(init(flags(search)).model, Message.SucceededCheckSession({ orgId })).model + +describe("query builder lab", () => { + test("entry resolves the default hour from the flags' clock and checks the session first", () => { + const entry = init(flags()) + expect(entry.model.window).toEqual(HOUR) + expect(names(entry)).toEqual(["CheckQueryBuilderLabSession"]) + }) + + test("an absolute window from the URL is used as is", () => { + const window = { startTime: "2026-09-30 08:00:00", endTime: "2026-09-30 09:00:00" } + expect(init(flags(window)).model.window).toEqual(window) + }) + + test("the session sends every autocomplete lookup over the window and the metric list", () => { + const step = update(init(flags()).model, Message.SucceededCheckSession({ orgId })) + expect(argsOf(step, "FetchQueryBuilderLabAutocomplete")).toHaveLength(7) + expect(argsOf(step, "FetchQueryBuilderLabAutocomplete")).toContainEqual({ + key: requestKey(orgId, "autocomplete.spanAttributeKeys", HOUR), + lookup: "spanAttributeKeys", + window: HOUR, + }) + expect(argsOf(step, "FetchQueryBuilderLabMetrics")).toEqual([ + { key: requestKey(orgId, "listMetrics", { data: { limit: 300 } }) }, + ]) + }) + + test("a lookup answer for a superseded window is dropped", () => { + const later = { startTime: "2026-10-01 11:30:00", endTime: "2026-10-01 12:30:00" } + const moved = update( + signedIn(), + Message.CompletedResolveWindow({ window: later, mode: "Refresh" }), + ).model + const stale = update( + moved, + Message.CompletedFetchAutocomplete({ + key: requestKey(orgId, "autocomplete.spanAttributeKeys", HOUR), + lookup: "spanAttributeKeys", + result: Result.succeed({ keys: ["http.route"] }), + }), + ).model + expect(stale.autocomplete["spanAttributeKeys"]?.data._tag).toBe("Loading") + }) + + test("running with no enabled query shows the error and submits nothing", () => { + const disabled = update( + update(signedIn(), Message.ToggledEnabled({ queryId: "q0" })).model, + Message.ToggledEnabled({ queryId: "q1" }), + ) + const run = update(disabled.model, Message.ClickedRun()) + expect(run.model.noQueriesError).toBe(NO_ENABLED_QUERIES) + expect(run.model.submitted).toBeNull() + expect(names(run)).toEqual([]) + }) + + test("running submits the window, every draft and the formulas, and stamps the run", () => { + const model = signedIn() + const run = update(model, Message.ClickedRun()) + const input = { ...HOUR, queries: model.queries, formulas: model.formulas } + expect(run.model.submitted).toEqual(input) + expect(argsOf(run, "FetchQueryBuilderLabTimeseries")).toEqual([ + { key: requestKey(orgId, "getQueryBuilderTimeseries", { data: input }), input }, + ]) + expect(names(run)).toContain("StampQueryBuilderLabRun") + expect(update(run.model, Message.StampedRun({ at: "12:00:00 PM" })).model.lastRunAt).toBe( + "12:00:00 PM", + ) + }) + + test("reload re-resolves unsnapped, refreshes the metrics and the submitted run, and spins once", () => { + const metricsKey = requestKey(orgId, "listMetrics", { data: { limit: 300 } }) + const loaded = update( + signedIn(), + Message.CompletedFetchMetrics({ key: metricsKey, result: Result.succeed([]) }), + ) + const run = update(loaded.model, Message.ClickedRun()) + const [runArgs] = argsOf(run, "FetchQueryBuilderLabTimeseries") + const runKey = Schema.decodeUnknownSync(Schema.Struct({ key: Schema.String }))(runArgs).key + const answer = Result.succeed({ points: [], warnings: [] }) + const ran = update(run.model, Message.CompletedFetchTimeseries({ key: runKey, result: answer })).model + const reload = update(ran, Message.ClickedReload()) + expect(argsOf(reload, "ResolveQueryBuilderLabWindow")).toEqual([ + { search: {}, preset: "1h", timeZone: "UTC", mode: "Refresh" }, + ]) + expect(names(reload)).toContain("FetchQueryBuilderLabMetrics") + expect(names(reload)).toContain("FetchQueryBuilderLabTimeseries") + const again = update(reload.model, Message.ClickedReload()).model + expect(update(again, Message.CompletedReloadSpin({ version: 1 })).model.isReloading).toBe(true) + expect(update(again, Message.CompletedReloadSpin({ version: 2 })).model.isReloading).toBe(false) + }) + + test("a picked preset replaces the window search and resolves it snapped", () => { + const step = update( + signedIn({ startTime: "2026-09-30 08:00:00", endTime: "2026-09-30 09:00:00" }), + Message.GotPickerMessage({ message: TimeRangePicker.Message.ClickedPreset({ value: "6h" }) }), + ) + expect(step.model.search).toEqual({ timePreset: "6h" }) + expect(argsOf(step, "ResolveQueryBuilderLabWindow")).toEqual([ + { search: { timePreset: "6h" }, preset: "6h", timeZone: "UTC", mode: "Load" }, + ]) + }) + + test("clone appends a copy under a new id and relabels; remove relabels and keeps the last query", () => { + const cloned = update(signedIn(), Message.CreatedCloneId({ queryId: "q0", id: "q9" })).model + expect(cloned.queries.map((query) => [query.id, query.name])).toEqual([ + ["q0", "A"], + ["q1", "B"], + ["q9", "C"], + ]) + const removed = update(cloned, Message.ClickedRemoveQuery({ queryId: "q0" })).model + expect(removed.queries.map((query) => [query.id, query.name])).toEqual([ + ["q1", "A"], + ["q9", "B"], + ]) + const single = { ...removed, queries: removed.queries.slice(0, 1) } + expect(update(single, Message.ClickedRemoveQuery({ queryId: "q1" })).model.queries).toHaveLength(1) + }) + + test("switching a query to metrics resets it, and the metric list fills its metric", () => { + const switched = update( + signedIn(), + Message.GotSelectMessage({ + id: "source:q1", + message: Select.Message.SelectedItem({ index: 2, value: "metrics" }), + }), + ).model + const query = switched.queries[1] + expect(query?.dataSource).toBe("metrics") + const filled = update( + switched, + Message.CompletedFetchMetrics({ + key: requestKey(orgId, "listMetrics", { data: { limit: 300 } }), + result: Result.succeed([ + { metricName: "bogus", metricType: "summary" }, + { metricName: "http.server.duration", metricType: "histogram" }, + ]), + }), + ).model.queries[1] + expect(filled?.dataSource === "metrics" ? [filled.metricName, filled.metricType] : null).toEqual([ + "http.server.duration", + "histogram", + ]) + }) +}) diff --git a/apps/web-foldkit/src/lab/query-builder-lab/update.ts b/apps/web-foldkit/src/lab/query-builder-lab/update.ts new file mode 100644 index 0000000000..1d548f9a5b --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/update.ts @@ -0,0 +1,267 @@ +import type { OrgId } from "@maple/domain" +import { Option } from "effect" +import { Command, type Update } from "foldkit" +import * as Keyed from "../../data/keyed" +import { requestKey } from "../../data/request-key" +import { resolveWindow } from "../../data/time-range" +import type { Resources } from "../../platform/resources" +import { AUTOCOMPLETE_LOOKUPS } from "../../page/kit/autocomplete-lookups" +import { applyQueryChange } from "../../page/kit/query-builder/change" +import * as TimeRangePicker from "../../page/kit/time-range-picker" +import { + CheckSession, + CompleteReloadSpin, + CreateCloneId, + CreateFormula, + CreateQuery, + FetchAutocomplete, + FetchMetrics, + FetchTimeseries, + ResolveWindow, + StampRun, +} from "./command" +import { metricOptionsOf, relabelFormulas, relabelQueries, withFirstMetric } from "./derive" +import { type Flags, Message, type Model, type SubmittedInput, type TimeSearch } from "./model" +import { groupByStep, selectStep, updateQuery } from "./update-controls" + +export type Return = Update.Return +type Cmd = Command.Command + +/** `search.timePreset ?? "1h"`: the route's default window. */ +export const DEFAULT_PRESET = "1h" +const presetOf = (search: TimeSearch): string => search.timePreset ?? DEFAULT_PRESET + +export const NO_ENABLED_QUERIES = "No enabled queries to run" + +export const init = (flags: Flags): Return => ({ + model: { + session: "Checking", + orgId: null, + timeZone: flags.timeZone, + search: flags.search, + window: resolveWindow( + { ...flags.search, preset: presetOf(flags.search), timeZone: flags.timeZone, snap: true }, + flags.nowMs, + ), + picker: TimeRangePicker.init("query-builder-lab-time-range"), + refreshVersion: 0, + isReloading: false, + queries: flags.queries, + formulas: flags.formulas, + lastRunAt: null, + submitted: null, + noQueriesError: null, + results: Keyed.idle(), + metrics: Keyed.idle(), + autocomplete: {}, + selects: {}, + groupBys: {}, + scroll: null, + }, + commands: [CheckSession()], +}) + +const withCommands = (step: Return, commands: ReadonlyArray): Return => ({ + model: step.model, + commands: [...(step.commands ?? []), ...commands], +}) + +/** `AutocompleteValuesProvider` (not lazy): every lookup over the current window. */ +const requestLookups = (model: Model, orgId: OrgId): Return => + AUTOCOMPLETE_LOOKUPS.reduce( + (step, lookup) => { + const key = requestKey(orgId, `autocomplete.${lookup}`, step.model.window) + const slot = step.model.autocomplete[lookup] ?? Keyed.idle() + return Option.match(Keyed.request(slot, key, "Load"), { + onNone: () => step, + onSome: (next) => + withCommands( + { + model: { + ...step.model, + autocomplete: { ...step.model.autocomplete, [lookup]: next }, + }, + commands: step.commands, + }, + [FetchAutocomplete({ key, lookup, window: step.model.window })], + ), + }) + }, + { model, commands: [] }, + ) + +const requestMetrics = (model: Model, orgId: OrgId, mode: Keyed.RequestMode): Return => { + const key = requestKey(orgId, "listMetrics", { data: { limit: 300 } }) + return Option.match(Keyed.request(model.metrics, key, mode), { + onNone: () => ({ model }), + onSome: (metrics) => ({ model: { ...model, metrics }, commands: [FetchMetrics({ key })] }), + }) +} + +const requestResults = (model: Model, input: SubmittedInput, mode: Keyed.RequestMode): Return => { + if (model.orgId === null) return { model } + const key = requestKey(model.orgId, "getQueryBuilderTimeseries", { data: input }) + return Option.match(Keyed.request(model.results, key, mode), { + onNone: () => ({ model }), + onSome: (results) => ({ model: { ...model, results }, commands: [FetchTimeseries({ key, input })] }), + }) +} + +/** `navigate({ search: applyTimeRangeSearch(...) })`, kept local: the window re-resolves, snapped. */ +const withSearch = (model: Model, search: TimeSearch): Return => ({ + model: { ...model, search }, + commands: [ResolveWindow({ search, preset: presetOf(search), timeZone: model.timeZone, mode: "Load" })], +}) + +const liftPicker = (model: Model, message: TimeRangePicker.Message): Return => { + const next = TimeRangePicker.update(model.picker, message) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotPickerMessage({ message: inner }), + ) + const lifted = { ...model, picker: next.model } + return next.outMessage === undefined || next.outMessage._tag === "RejectedRange" + ? { model: lifted, commands } + : withCommands(withSearch(lifted, TimeRangePicker.searchPatchOf(next.outMessage)), commands) +} + +/** Reload: the window unsnapped, and every `useRefreshableAtomValue` read refreshed. */ +const reloaded = (model: Model): Return => { + const bumped = { ...model, refreshVersion: model.refreshVersion + 1, isReloading: true } + const resolve = ResolveWindow({ + search: model.search, + preset: presetOf(model.search), + timeZone: model.timeZone, + mode: "Refresh", + }) + const own = [resolve, CompleteReloadSpin({ version: bumped.refreshVersion })] + if (bumped.orgId === null) return { model: bumped, commands: own } + const metrics = requestMetrics(bumped, bumped.orgId, "Refresh") + const results: Return = + bumped.submitted === null + ? { model: metrics.model } + : requestResults(metrics.model, bumped.submitted, "Refresh") + return { + model: results.model, + commands: [...own, ...(metrics.commands ?? []), ...(results.commands ?? [])], + } +} + +/** `runQueries`: the drafts and window as they are now, or the no-enabled-queries error. */ +const ran = (model: Model): Return => { + if (!model.queries.some((query) => query.enabled)) + return { model: { ...model, noQueriesError: NO_ENABLED_QUERIES, submitted: null } } + const submitted: SubmittedInput = { + startTime: model.window.startTime, + endTime: model.window.endTime, + queries: model.queries, + formulas: model.formulas, + } + return withCommands(requestResults({ ...model, noQueriesError: null, submitted }, submitted, "Load"), [ + StampRun(), + ]) +} + +const cloned = (model: Model, queryId: string, id: string): Return => { + const source = model.queries.find((query) => query.id === queryId) + return source === undefined + ? { model } + : { model: { ...model, queries: relabelQueries([...model.queries, { ...source, id }]) } } +} + +const signedIn = (model: Model, orgId: OrgId): Return => { + const next: Model = { ...model, session: "SignedIn", orgId } + const lookups = requestLookups(next, orgId) + const metrics = requestMetrics(lookups.model, orgId, "Load") + return { model: metrics.model, commands: [...(lookups.commands ?? []), ...(metrics.commands ?? [])] } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + SucceededCheckSession: ({ orgId }) => signedIn(model, orgId), + FailedCheckSession: ({ session }) => ({ model: { ...model, session } }), + CompletedResolveWindow: ({ window }) => { + const next = { ...model, window } + return next.orgId === null ? { model: next } : requestLookups(next, next.orgId) + }, + CompletedFetchAutocomplete: ({ key, lookup, result }) => ({ + model: { + ...model, + autocomplete: { + ...model.autocomplete, + [lookup]: Keyed.accept(model.autocomplete[lookup] ?? Keyed.idle(), key, result), + }, + }, + }), + CompletedFetchMetrics: ({ key, result }) => { + const metrics = Keyed.accept(model.metrics, key, result) + if (metrics === model.metrics || result._tag === "Failure") + return { model: { ...model, metrics } } + return { + model: { + ...model, + metrics, + queries: withFirstMetric(model.queries, metricOptionsOf(result.success)), + }, + } + }, + CompletedFetchTimeseries: ({ key, result }) => ({ + model: { ...model, results: Keyed.accept(model.results, key, result) }, + }), + GotPickerMessage: ({ message: inner }) => liftPicker(model, inner), + ClickedResetTimeRange: () => withSearch(model, {}), + ClickedReload: () => reloaded(model), + CompletedReloadSpin: ({ version }) => + version === model.refreshVersion ? { model: { ...model, isReloading: false } } : { model }, + ClickedAddQuery: () => ({ model, commands: [CreateQuery({ index: model.queries.length })] }), + CreatedQuery: ({ query }) => ({ model: { ...model, queries: [...model.queries, query] } }), + ClickedAddFormula: () => ({ + model, + commands: [ + CreateFormula({ + index: model.formulas.length, + queryNames: model.queries.map((query) => query.name), + }), + ], + }), + CreatedFormula: ({ formula }) => ({ model: { ...model, formulas: [...model.formulas, formula] } }), + ClickedCloneQuery: ({ queryId }) => ({ model, commands: [CreateCloneId({ queryId })] }), + CreatedCloneId: ({ queryId, id }) => cloned(model, queryId, id), + ClickedRemoveQuery: ({ queryId }) => removedQuery(model, queryId), + ClickedRemoveFormula: ({ formulaId }) => ({ + model: { + ...model, + formulas: relabelFormulas(model.formulas.filter((formula) => formula.id !== formulaId)), + }, + }), + ClickedRun: () => ran(model), + StampedRun: ({ at }) => ({ model: { ...model, lastRunAt: at } }), + ToggledEnabled: ({ queryId }) => ({ + model: updateQuery(model, queryId, (query) => ({ ...query, enabled: !query.enabled })), + }), + ChangedQuery: ({ queryId, change }) => ({ + model: updateQuery(model, queryId, (query) => applyQueryChange(query, change)), + }), + GotSelectMessage: ({ id, message: inner }) => selectStep(model, id, inner), + GotGroupByMessage: ({ queryId, message: inner }) => groupByStep(model, queryId, inner), + ChangedFormulaText: ({ formulaId, field, value }) => ({ + model: { + ...model, + formulas: model.formulas.map((formula) => + formula.id === formulaId ? { ...formula, [field]: value } : formula, + ), + }, + }), + // The editor's Enter only applies an open suggestion; the textarea stays single-purpose. + PressedWhereEnter: () => ({ model }), + MeasuredScroll: ({ metrics }) => ({ model: { ...model, scroll: metrics } }), + }) + +const removedQuery = (model: Model, queryId: string): Return => + model.queries.length === 1 + ? { model } + : { + model: { + ...model, + queries: relabelQueries(model.queries.filter((query) => query.id !== queryId)), + }, + } diff --git a/apps/web-foldkit/src/lab/query-builder-lab/view-add-ons.ts b/apps/web-foldkit/src/lab/query-builder-lab/view-add-ons.ts new file mode 100644 index 0000000000..1a441d435c --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/view-add-ons.ts @@ -0,0 +1,102 @@ +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { Option } from "effect" +import { lookupField } from "../../page/kit/autocomplete-lookups" +import { QueryChange, type QueryTextField } from "../../page/kit/query-builder/change" +import type { QueryDraft } from "../../page/kit/query-builder/draft" +import { textInput } from "../../page/kit/query-builder/parts" +import { view as comboboxView } from "../../ui/combobox-view" +import { groupByOptionsOf } from "./derive" +import { Message, type Model } from "./model" +import { groupByModelOf } from "./update-controls" +import { labelEyebrow, labSelect } from "./view-controls" + +// The inputs each enabled add-on reveals under a lab query. + +type H = HtmlBuilder + +const GROUP_BY_PLACEHOLDER = "service.name | span.name | none | attr.http.route" + +/** `autocompleteValues.attributeKeys`: the span attribute keys, empty until they load. */ +const spanAttributeKeysOf = (model: Model): ReadonlyArray => + lookupField( + Option.getOrUndefined( + Option.flatMap(Option.fromNullishOr(model.autocomplete["spanAttributeKeys"]), (slot) => + AsyncData.getData(slot.data), + ), + ), + "keys", + ) + +/** `GroupByAutocomplete`. TODO(lab-query-builder): items lack `font-mono` (the item class). */ +const groupBy = (h: H, model: Model, query: QueryDraft): Html => + h.submodel({ + slotId: `query-builder-lab-group-by-${query.id}`, + model: { ...groupByModelOf(model, query), selected: query.groupBy.slice(0, 1) }, + view: comboboxView, + toParentMessage: (message) => Message.GotGroupByMessage({ queryId: query.id, message }), + viewInputs: { + items: groupByOptionsOf(query.dataSource, spanAttributeKeysOf(model)), + placeholder: GROUP_BY_PLACEHOLDER, + emptyText: "No fields found.", + className: "h-8 text-xs", + isUnfiltered: true, + toView: ({ field, rest }) => + h.div([h.Class("space-y-1")], [labelEyebrow(h, "Group By"), field, ...rest]), + }, + }) + +const textField = ( + h: H, + query: QueryDraft, + title: string, + field: QueryTextField, + placeholder: string, + type?: string, +): Html => + h.div( + [h.Class("space-y-1")], + [ + labelEyebrow(h, title), + textInput( + h, + `${field}-${query.id}`, + query[field], + placeholder, + "", + (value) => + Message.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ field, value }), + }), + type, + ), + ], + ) + +const DIRECTIONS = [ + { value: "desc", label: "desc" }, + { value: "asc", label: "asc" }, +] + +const orderBy = (h: H, model: Model, query: QueryDraft): Html => + h.div( + [h.Class("grid gap-2 md:grid-cols-[1fr_1fr]")], + [ + textField(h, query, "Order By (UI-only)", "orderBy", "p95(duration)"), + labSelect(h, model, `orderDirection:${query.id}`, query.orderByDirection, DIRECTIONS, { + className: "w-full", + toView: (parts) => h.div([h.Class("space-y-1")], [labelEyebrow(h, "Direction"), ...parts]), + }), + ], + ) + +export const addOnSections = (h: H, model: Model, query: QueryDraft): ReadonlyArray => [ + ...(query.addOns.groupBy ? [groupBy(h, model, query)] : []), + ...(query.addOns.having ? [textField(h, query, "Having (UI-only)", "having", "count() > 10")] : []), + ...(query.addOns.orderBy ? [orderBy(h, model, query)] : []), + ...(query.addOns.limit ? [textField(h, query, "Limit (UI-only)", "limit", "10", "number")] : []), + ...(query.addOns.legend + ? [textField(h, query, "Legend Format (UI-only)", "legend", "{{service.name}} - {{status.code}}")] + : []), +] diff --git a/apps/web-foldkit/src/lab/query-builder-lab/view-controls.ts b/apps/web-foldkit/src/lab/query-builder-lab/view-controls.ts new file mode 100644 index 0000000000..4b2ae73a04 --- /dev/null +++ b/apps/web-foldkit/src/lab/query-builder-lab/view-controls.ts @@ -0,0 +1,64 @@ +import { cn } from "@maple/ui/lib/utils" +import { eyebrowVariants } from "@maple/ui/styles/ui/eyebrow" +import { labelStyles } from "@maple/ui/styles/ui/label" +import type { Html, HtmlBuilder } from "foldkit/html" +import { badge } from "../../ui/display" +import { eyebrow } from "../../ui/eyebrow" +import * as Select from "../../ui/select" +import { type SelectOption, view as selectView } from "../../ui/select-view" +import { Message, type Model } from "./model" + +// The small pieces the lab's query and formula cards share. + +type H = HtmlBuilder + +/** ``. */ +export const fieldEyebrow = (h: H, text: string, className?: string): Html => + eyebrow(h, { variant: "label", as: "p", className }, [text]) + +/** `}>`: Label's classes under the eyebrow's. */ +export const labelEyebrow = (h: H, text: string): Html => + h.label( + [h.Class(cn(labelStyles, eyebrowVariants({ variant: "label" }))), h.DataAttribute("slot", "eyebrow")], + [text], + ) + +/** The `A` / `F1` badge in the row's 44px gutter. */ +export const nameBadge = (h: H, name: string): Html => + badge(h, { variant: "outline", className: "h-7 w-11 justify-center self-start font-mono text-2xs" }, [ + name, + ]) + +export type LabSelectOptions = Readonly<{ + className: string + toView: (parts: ReadonlyArray) => Html + /** `` while the value matches no option. */ + placeholder?: string +}> + +/** A `