From eef8f79d69f1eb78468c2e7bb604076f251e19f8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 09:35:25 +0100 Subject: [PATCH 1/5] perf(web): load each page's code on first use instead of at boot The app evaluated every page module before init. Each page now keeps only its schemas, routes and key in index.ts; the rest moves to implementation.ts, which loads on first entry or link intent. The route's page code loads at init beside the session check, and the open page's Subscriptions run in fixed registry slots. --- apps/web/src/command.ts | 20 ++ apps/web/src/entry.ts | 9 +- apps/web/src/main.ts | 34 +- apps/web/src/message.ts | 10 + apps/web/src/model.ts | 11 + apps/web/src/page-code.test.ts | 183 ++++++++++ .../agent-sessions/list/implementation.ts | 13 + .../web/src/page/agent-sessions/list/index.ts | 18 +- .../agent-sessions/session/implementation.ts | 15 + .../src/page/agent-sessions/session/index.ts | 19 +- .../agent-sessions/tool/implementation.ts | 19 + .../web/src/page/agent-sessions/tool/index.ts | 23 +- .../agent-sessions/tools/implementation.ts | 13 + .../src/page/agent-sessions/tools/index.ts | 18 +- .../src/page/alerts/editor/implementation.ts | 113 ++++++ apps/web/src/page/alerts/editor/index.ts | 101 +----- .../web/src/page/alerts/hub/implementation.ts | 14 + apps/web/src/page/alerts/hub/index.ts | 19 +- .../page/alerts/incident/implementation.ts | 37 ++ apps/web/src/page/alerts/incident/index.ts | 39 +- .../src/page/alerts/rule/implementation.ts | 43 +++ apps/web/src/page/alerts/rule/index.ts | 45 +-- .../page/alerts/uptime-form/implementation.ts | 33 ++ apps/web/src/page/alerts/uptime-form/index.ts | 37 +- .../alerts/uptime-monitor/implementation.ts | 33 ++ .../src/page/alerts/uptime-monitor/index.ts | 37 +- apps/web/src/page/analytics/implementation.ts | 15 + apps/web/src/page/analytics/index.ts | 20 +- .../page/anomalies/detail/implementation.ts | 42 +++ apps/web/src/page/anomalies/detail/index.ts | 42 +-- .../src/page/anomalies/list/implementation.ts | 79 ++++ apps/web/src/page/anomalies/list/index.ts | 82 +---- apps/web/src/page/chrome.ts | 10 + .../src/page/code-review/implementation.ts | 77 ++++ apps/web/src/page/code-review/index.ts | 80 +---- apps/web/src/page/contract.ts | 339 ++++++++++++------ .../page/dashboards/list/implementation.ts | 14 + apps/web/src/page/dashboards/list/index.ts | 19 +- .../dashboards/templates/implementation.ts | 12 + .../src/page/dashboards/templates/index.ts | 17 +- .../page/dashboards/view/implementation.ts | 24 ++ apps/web/src/page/dashboards/view/index.ts | 28 +- .../page/dashboards/widget/implementation.ts | 28 ++ apps/web/src/page/dashboards/widget/index.ts | 32 +- .../widget/widget-leave.scene.test.ts | 17 +- .../web/src/page/errors/hub/implementation.ts | 15 + apps/web/src/page/errors/hub/index.ts | 20 +- .../src/page/errors/issue/implementation.ts | 28 ++ apps/web/src/page/errors/issue/index.ts | 31 +- .../aws/list/implementation.ts | 15 + .../page/infra-integrations/aws/list/index.ts | 20 +- .../aws/resource/implementation.ts | 19 + .../infra-integrations/aws/resource/index.ts | 18 +- .../cloudflare/list/implementation.ts | 17 + .../cloudflare/list/index.ts | 25 +- .../cloudflare/worker/implementation.ts | 21 ++ .../cloudflare/worker/index.ts | 28 +- .../cloudflare/zone/implementation.ts | 21 ++ .../cloudflare/zone/index.ts | 28 +- .../gcp/list/implementation.ts | 17 + .../page/infra-integrations/gcp/list/index.ts | 25 +- .../gcp/workload/implementation.ts | 21 ++ .../infra-integrations/gcp/workload/index.ts | 28 +- .../planetscale/db/implementation.ts | 19 + .../planetscale/db/index.ts | 26 +- .../planetscale/list/implementation.ts | 15 + .../planetscale/list/index.ts | 23 +- .../railway/list/implementation.ts | 17 + .../infra-integrations/railway/list/index.ts | 22 +- .../railway/service/implementation.ts | 19 + .../railway/service/index.ts | 23 +- .../supabase/list/implementation.ts | 17 + .../infra-integrations/supabase/list/index.ts | 22 +- .../supabase/project/implementation.ts | 19 + .../supabase/project/index.ts | 23 +- .../infra/containers/detail/implementation.ts | 19 + .../src/page/infra/containers/detail/index.ts | 23 +- .../infra/containers/list/implementation.ts | 13 + .../src/page/infra/containers/list/index.ts | 18 +- .../page/infra/hosts/detail/implementation.ts | 19 + apps/web/src/page/infra/hosts/detail/index.ts | 23 +- .../page/infra/hosts/list/implementation.ts | 13 + apps/web/src/page/infra/hosts/list/index.ts | 18 +- .../infra/kubernetes/node/implementation.ts | 16 + .../src/page/infra/kubernetes/node/index.ts | 20 +- .../infra/kubernetes/nodes/implementation.ts | 14 + .../src/page/infra/kubernetes/nodes/index.ts | 17 +- .../infra/kubernetes/pod/implementation.ts | 16 + .../src/page/infra/kubernetes/pod/index.ts | 20 +- .../infra/kubernetes/pods/implementation.ts | 14 + .../src/page/infra/kubernetes/pods/index.ts | 17 +- .../kubernetes/service/implementation.ts | 19 + .../page/infra/kubernetes/service/index.ts | 19 +- .../kubernetes/workload/implementation.ts | 16 + .../page/infra/kubernetes/workload/index.ts | 21 +- .../kubernetes/workloads/implementation.ts | 14 + .../page/infra/kubernetes/workloads/index.ts | 17 +- .../src/page/infra/overview/implementation.ts | 14 + apps/web/src/page/infra/overview/index.ts | 19 +- apps/web/src/page/instance.scene.test.ts | 34 +- .../src/page/integrations/implementation.ts | 25 ++ apps/web/src/page/integrations/index.ts | 30 +- .../investigations/detail/implementation.ts | 15 + .../src/page/investigations/detail/index.ts | 19 +- .../investigations/list/implementation.ts | 12 + .../web/src/page/investigations/list/index.ts | 17 +- apps/web/src/page/logs/list/implementation.ts | 15 + apps/web/src/page/logs/list/index.ts | 20 +- apps/web/src/page/logs/log/implementation.ts | 11 + apps/web/src/page/logs/log/index.ts | 20 +- .../src/page/metrics/browse/implementation.ts | 13 + apps/web/src/page/metrics/browse/index.ts | 18 +- .../src/page/metrics/detail/implementation.ts | 18 + apps/web/src/page/metrics/detail/index.ts | 22 +- apps/web/src/page/overview/implementation.ts | 16 + apps/web/src/page/overview/index.ts | 21 +- apps/web/src/page/preload.test.ts | 13 +- .../page/recommendations/implementation.ts | 11 + apps/web/src/page/recommendations/index.ts | 15 +- apps/web/src/page/registry.ts | 209 ++++++++--- .../page/releases/detail/implementation.ts | 13 + apps/web/src/page/releases/detail/index.ts | 17 +- .../src/page/releases/list/implementation.ts | 13 + apps/web/src/page/releases/list/index.ts | 18 +- .../src/page/replays/detail/implementation.ts | 14 + apps/web/src/page/replays/detail/index.ts | 18 +- .../src/page/replays/list/implementation.ts | 14 + apps/web/src/page/replays/list/index.ts | 19 +- apps/web/src/page/revive.test.ts | 9 +- .../page/security/finding/implementation.ts | 11 + apps/web/src/page/security/finding/index.ts | 18 +- .../src/page/security/run/implementation.ts | 41 +++ apps/web/src/page/security/run/index.ts | 49 +-- .../src/page/security/run/run.scene.test.ts | 2 +- .../src/page/security/tabs/implementation.ts | 15 + apps/web/src/page/security/tabs/index.ts | 22 +- .../src/page/service-map/implementation.ts | 15 + apps/web/src/page/service-map/index.ts | 20 +- .../page/services/detail/implementation.ts | 13 + apps/web/src/page/services/detail/index.ts | 17 +- .../src/page/services/list/implementation.ts | 17 + apps/web/src/page/services/list/index.ts | 22 +- .../page/settings/account/implementation.ts | 12 + apps/web/src/page/settings/account/index.ts | 20 +- apps/web/src/page/settings/implementation.ts | 14 + apps/web/src/page/settings/index.ts | 19 +- .../settings/ingest-rule/implementation.ts | 15 + .../src/page/settings/ingest-rule/index.ts | 14 +- .../implementation.ts} | 48 +-- apps/web/src/page/settings/mcp-page/index.ts | 20 ++ apps/web/src/page/share/implementation.ts | 14 + apps/web/src/page/share/index.ts | 18 +- .../src/page/sources/detail/implementation.ts | 14 + apps/web/src/page/sources/detail/index.ts | 18 +- .../src/page/sources/list/implementation.ts | 11 + apps/web/src/page/sources/list/index.ts | 16 +- .../src/page/sources/new/implementation.ts | 14 + apps/web/src/page/sources/new/index.ts | 19 +- .../src/page/traces/detail/implementation.ts | 17 + apps/web/src/page/traces/detail/index.ts | 23 +- .../src/page/traces/list/implementation.ts | 16 + apps/web/src/page/traces/list/index.ts | 21 +- apps/web/src/platform/telemetry.ts | 4 +- apps/web/src/subscription.ts | 2 +- apps/web/src/update.customer-grace.test.ts | 11 +- apps/web/src/update.pending-task.test.ts | 11 +- apps/web/src/update.test.ts | 11 +- apps/web/src/update.ts | 94 ++++- apps/web/src/view.lazy.test.ts | 10 +- apps/web/src/view.scene.test.ts | 11 +- apps/web/src/view.ts | 16 +- docs/foldkit-decisions/s8-pages.md | 35 +- 172 files changed, 2709 insertions(+), 1824 deletions(-) create mode 100644 apps/web/src/page-code.test.ts create mode 100644 apps/web/src/page/agent-sessions/list/implementation.ts create mode 100644 apps/web/src/page/agent-sessions/session/implementation.ts create mode 100644 apps/web/src/page/agent-sessions/tool/implementation.ts create mode 100644 apps/web/src/page/agent-sessions/tools/implementation.ts create mode 100644 apps/web/src/page/alerts/editor/implementation.ts create mode 100644 apps/web/src/page/alerts/hub/implementation.ts create mode 100644 apps/web/src/page/alerts/incident/implementation.ts create mode 100644 apps/web/src/page/alerts/rule/implementation.ts create mode 100644 apps/web/src/page/alerts/uptime-form/implementation.ts create mode 100644 apps/web/src/page/alerts/uptime-monitor/implementation.ts create mode 100644 apps/web/src/page/analytics/implementation.ts create mode 100644 apps/web/src/page/anomalies/detail/implementation.ts create mode 100644 apps/web/src/page/anomalies/list/implementation.ts create mode 100644 apps/web/src/page/code-review/implementation.ts create mode 100644 apps/web/src/page/dashboards/list/implementation.ts create mode 100644 apps/web/src/page/dashboards/templates/implementation.ts create mode 100644 apps/web/src/page/dashboards/view/implementation.ts create mode 100644 apps/web/src/page/dashboards/widget/implementation.ts create mode 100644 apps/web/src/page/errors/hub/implementation.ts create mode 100644 apps/web/src/page/errors/issue/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/aws/list/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/aws/resource/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/cloudflare/list/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/cloudflare/worker/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/cloudflare/zone/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/gcp/list/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/gcp/workload/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/planetscale/db/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/planetscale/list/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/railway/list/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/railway/service/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/supabase/list/implementation.ts create mode 100644 apps/web/src/page/infra-integrations/supabase/project/implementation.ts create mode 100644 apps/web/src/page/infra/containers/detail/implementation.ts create mode 100644 apps/web/src/page/infra/containers/list/implementation.ts create mode 100644 apps/web/src/page/infra/hosts/detail/implementation.ts create mode 100644 apps/web/src/page/infra/hosts/list/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/node/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/nodes/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/pod/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/pods/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/service/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/workload/implementation.ts create mode 100644 apps/web/src/page/infra/kubernetes/workloads/implementation.ts create mode 100644 apps/web/src/page/infra/overview/implementation.ts create mode 100644 apps/web/src/page/integrations/implementation.ts create mode 100644 apps/web/src/page/investigations/detail/implementation.ts create mode 100644 apps/web/src/page/investigations/list/implementation.ts create mode 100644 apps/web/src/page/logs/list/implementation.ts create mode 100644 apps/web/src/page/logs/log/implementation.ts create mode 100644 apps/web/src/page/metrics/browse/implementation.ts create mode 100644 apps/web/src/page/metrics/detail/implementation.ts create mode 100644 apps/web/src/page/overview/implementation.ts create mode 100644 apps/web/src/page/recommendations/implementation.ts create mode 100644 apps/web/src/page/releases/detail/implementation.ts create mode 100644 apps/web/src/page/releases/list/implementation.ts create mode 100644 apps/web/src/page/replays/detail/implementation.ts create mode 100644 apps/web/src/page/replays/list/implementation.ts create mode 100644 apps/web/src/page/security/finding/implementation.ts create mode 100644 apps/web/src/page/security/run/implementation.ts create mode 100644 apps/web/src/page/security/tabs/implementation.ts create mode 100644 apps/web/src/page/service-map/implementation.ts create mode 100644 apps/web/src/page/services/detail/implementation.ts create mode 100644 apps/web/src/page/services/list/implementation.ts create mode 100644 apps/web/src/page/settings/account/implementation.ts create mode 100644 apps/web/src/page/settings/implementation.ts create mode 100644 apps/web/src/page/settings/ingest-rule/implementation.ts rename apps/web/src/page/settings/{mcp-page.ts => mcp-page/implementation.ts} (69%) create mode 100644 apps/web/src/page/settings/mcp-page/index.ts create mode 100644 apps/web/src/page/share/implementation.ts create mode 100644 apps/web/src/page/sources/detail/implementation.ts create mode 100644 apps/web/src/page/sources/list/implementation.ts create mode 100644 apps/web/src/page/sources/new/implementation.ts create mode 100644 apps/web/src/page/traces/detail/implementation.ts create mode 100644 apps/web/src/page/traces/list/implementation.ts diff --git a/apps/web/src/command.ts b/apps/web/src/command.ts index b557014b14..a6d886999d 100644 --- a/apps/web/src/command.ts +++ b/apps/web/src/command.ts @@ -5,11 +5,13 @@ import { Command } from "foldkit" import { load, pushUrl, replaceUrl } from "foldkit/navigation" import { Message } from "./message" import { PRELOAD_HOLD_MS } from "./page/preload" +import { loadPageCode } from "./page/registry" 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 { toLoadFailure } from "./data/load-failure" import { ReadCache } from "./data/read-cache" import { OrgCollections } from "./data/collections/org-collections" import { readSessionTimeRange, writeSessionTimeRange } from "./platform/session-time-range" @@ -100,6 +102,24 @@ export const LoadClerk = Command.define("LoadClerk", { ), }) +/** A page's code (`page/registry.ts`), for the route about to enter it or a link's preload. */ +export const LoadPageCode = Command.define("LoadPageCode", { + args: { pageId: Schema.String, preloadHref: Schema.NullOr(Schema.String) }, + messages: [Message.CompletedLoadPageCode, Message.FailedLoadPageCode], + execute: ({ pageId, preloadHref }) => + Effect.annotateCurrentSpan({ + "maple.page.id": pageId, + "maple.page.code_for_preload": preloadHref !== null, + }).pipe( + Effect.andThen(loadPageCode(pageId)), + Effect.andThen(Clock.currentTimeMillis), + Effect.map((nowMs) => Message.CompletedLoadPageCode({ pageId, preloadHref, nowMs })), + Effect.catchTag("@maple/web/PageLoadError", (error) => + Effect.succeed(Message.FailedLoadPageCode({ pageId, failure: toLoadFailure(error) })), + ), + ), +}) + export const WaitForAuthSettle = Command.define("WaitForAuthSettle", { messages: [Message.CompletedWaitForAuthSettle], execute: Effect.sleep(AUTH_SETTLE_TIMEOUT).pipe(Effect.as(Message.CompletedWaitForAuthSettle())), diff --git a/apps/web/src/entry.ts b/apps/web/src/entry.ts index 22480f8de7..a040d4eb73 100644 --- a/apps/web/src/entry.ts +++ b/apps/web/src/entry.ts @@ -2,12 +2,14 @@ import "./platform/oauth-return-boot" import "./styles.css" +import { Effect } from "effect" import { Runtime } from "foldkit" import type { UrlRequest } from "foldkit/navigation" import type { Url } from "foldkit/url" import { trackAvatarImageLoads } from "./ui/avatar-image" import { labEnabled, labPathOf, startLab } from "./lab/boot" import { Flags, flags, init, Message, Model, subscriptions, update, view } from "./main" +import { loadEveryPage } from "./page/registry" import { ResourcesLive } from "./platform/resources" import { instrument } from "./platform/telemetry" @@ -40,5 +42,8 @@ const startApplication = () => // `/lab/` boots that lab page's own program instead of the app (scenarios, benches). const labPath = labEnabled ? labPathOf(location.pathname) : undefined -if (labPath === undefined) startApplication() -else void startLab(labPath, container) +if (labPath !== undefined) void startLab(labPath, container) +// A dev reload can restore a Model holding a page: that skips `init`, which loads the page's code. +else if (import.meta.hot) + Effect.runFork(Effect.andThen(Effect.ignore(loadEveryPage), Effect.sync(startApplication))) +else startApplication() diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 8cae3fd4bc..03f3637462 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,12 +1,13 @@ import { isClerkAuthEnabled } from "./lib/services/common/auth-mode" import { Effect, Option, Schema } from "effect" -import type { Runtime } from "foldkit" +import type { Command, Runtime } from "foldkit" import type { Url } from "foldkit/url" -import { CheckSession, LoadClerk } from "./command" +import { CheckSession, LoadClerk, LoadPageCode } from "./command" import type { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { initCustomer, readKnownToRenderApp } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" +import { unloadedPageOf } from "./page/registry" import * as SignIn from "./page/sign-in" import type { Resources } from "./platform/resources" import { readSessionToken } from "./platform/auth-api" @@ -51,6 +52,16 @@ export const flags: Effect.Effect = Effect.all({ // INIT +/** The route's page code loads alongside the session, so the page can enter once it resolves. */ +const withRoutePageCode = (model: Model, sessionCommand: Command.Command) => + Option.match(unloadedPageOf(model.route), { + onNone: () => ({ model, commands: [sessionCommand] }), + onSome: (pageId) => ({ + model: { ...model, pageCode: PageCode.Loading({ pageId }) }, + commands: [sessionCommand, LoadPageCode({ pageId, preloadHref: null })], + }), + }) + export const init: Runtime.RoutingApplicationInit = (flags, url: Url) => { const route = urlToAppRoute(url) const base = { @@ -61,6 +72,7 @@ export const init: Runtime.RoutingApplicationInit Object.assign(globalThis, { window: new EventTarget() })) +import * as ChatSheet from "./page/chat/sheet" +import { toLoadFailure } from "./data/load-failure" +import { Message } from "./message" +import { init } from "./main" +import { LoadPageCode } from "./command" +import { ClerkPhase, type Model, PageCode, Session } from "./model" +import { initCustomer } from "./page/onboarding/plan-gate" +import * as Preload from "./page/preload" +import { PageLoadError } from "./page/registry" +import * as SignIn from "./page/sign-in" +import { AppRoute } from "./route" +import * as Shell from "./shell" +import { MeasureSidebarOverflow } from "./shell/mounts" +import { Message as ShellMessage } from "./shell/message" +import { subscriptions } from "./subscription" +import * as Toast from "./ui/toast" +import { settle, update } from "./update" +import { view } from "./view" + +// No page code is loaded up front: each test file gets its own registry. + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const NOW = Date.parse("2026-10-01T12:00:00Z") + +const signedIn = (route: AppRoute): Model => ({ + route, + session: Session.SignedIn({ maybeOrgId: Option.some(orgId) }), + clerk: ClerkPhase.Unused(), + signIn: SignIn.init(), + shell: Shell.init({ isMobile: false, theme: "light", pinnedNamespaces: {}, dashboardFavorites: [] }), + toasts: Toast.init("toasts"), + toastActions: {}, + toastRootActions: {}, + chatSheet: ChatSheet.init(), + page: null, + pageCode: PageCode.Ready(), + pageInstances: 0, + parkedPages: [], + onboarding: null, + clerkSnapshot: null, + customer: initCustomer(), + knownToRenderApp: {}, + preload: Preload.init(), + sessionTimeRange: { orgId, value: { timePreset: "7d" }, nowMs: NOW, isLoaded: true }, + timeZone: "UTC", +}) + +type Step = ReturnType +const commandsNamed = (step: Step, name: string) => + (step.commands ?? []).filter((command) => command.name === name) +const loadArgsOf = (step: Step) => commandsNamed(step, "LoadPageCode").map((command) => command.args) +const LoadArgs = Schema.Struct({ pageId: Schema.String, preloadHref: Schema.NullOr(Schema.String) }) +/** Runs the `LoadPageCode` the step asked for, as the runtime would. */ +const runLoad = (step: Step) => + Effect.runPromise( + Effect.flatMap( + Schema.decodeUnknownEffect(LoadArgs)(loadArgsOf(step)[0]), + (args) => LoadPageCode(args).effect, + ), + ) +const body = (model: Model, h: HtmlBuilder) => view(model, h).body + +describe("page code", () => { + test("the first load asks for the route's page code beside the session check", () => { + const shell = { + isMobile: false, + theme: "light" as const, + pinnedNamespaces: {}, + dashboardFavorites: [], + } + const started = init( + { hasStoredSession: true, timeZone: "UTC", knownToRenderApp: {}, shell }, + Option.getOrThrow(urlFromString("http://localhost/releases")), + ) + expect((started.commands ?? []).map(({ name, args }) => ({ name, args }))).toEqual([ + { name: "CheckSession", args: undefined }, + { name: "LoadPageCode", args: { pageId: "ReleasesList", preloadHref: null } }, + ]) + expect(started.model.pageCode).toEqual(PageCode.Loading({ pageId: "ReleasesList" })) + }) + + test("a route whose page code is not loaded shows the skeleton, loads it once, then enters", async () => { + const waiting = settle(signedIn(AppRoute.Services({ search: {} }))) + expect(waiting.model.page).toBeNull() + expect(waiting.model.pageCode).toEqual(PageCode.Loading({ pageId: "ServicesList" })) + expect(loadArgsOf(waiting)).toEqual([{ pageId: "ServicesList", preloadHref: null }]) + // Clerk and the customer settle again while it loads: no second load. + expect(commandsNamed(settle(waiting.model), "LoadPageCode")).toEqual([]) + + const entered = update(waiting.model, await runLoad(waiting)) + expect(entered.model.page?._tag).toBe("ServicesList") + expect(entered.model.pageCode).toEqual(PageCode.Ready()) + }) + + test("leaving for an unloaded page leaves the open one and enters the new one when its code arrives", async () => { + const first = settle(signedIn(AppRoute.Home({ search: {} }))) + const overview = update(first.model, await runLoad(first)).model + expect(overview.page?._tag).toBe("Overview") + + const leaving = settle({ ...overview, route: AppRoute.Logs({ search: {} }) }) + expect(leaving.model.page).toBeNull() + expect(leaving.model.pageCode).toEqual(PageCode.Loading({ pageId: "LogsList" })) + const logs = update(leaving.model, await runLoad(leaving)) + expect(logs.model.page?._tag).toBe("LogsList") + }) + + test("a link to a page whose code is not loaded loads it first, then runs the page's preload", async () => { + const href = `http://localhost/traces/${"a".repeat(32)}` + const intended = update( + signedIn(AppRoute.Logs({ search: {} })), + Message.IntendedLink({ href, isImmediate: true }), + ) + const [wait] = commandsNamed(intended, "WaitPreloadDelay") + const version = Number(wait?.args?.["version"]) + const elapsed = update(intended.model, Message.ElapsedPreloadDelay({ href, version, nowMs: NOW })) + expect(loadArgsOf(elapsed)).toEqual([{ pageId: "TraceDetail", preloadHref: href }]) + + const preloaded = update(elapsed.model, await runLoad(elapsed)) + expect((preloaded.commands ?? []).map((command) => command.name)).toContain("FetchSpanHierarchy") + expect(preloaded.model.page).toBeNull() + }) + + test("code that fails to load shows the failure and Reload loads the route again", () => { + const waiting = settle(signedIn(AppRoute.ServiceMap({ search: {} }))).model + const cause = new TypeError( + "Failed to fetch dynamically imported module: /assets/implementation-x.js", + ) + const failure = toLoadFailure( + new PageLoadError({ message: "The ServiceMap page failed to load", pageId: "ServiceMap", cause }), + ) + const failed = update(waiting, Message.FailedLoadPageCode({ pageId: "ServiceMap", failure })).model + expect(failed.pageCode).toEqual(PageCode.Failed({ pageId: "ServiceMap", failure })) + expect(commandsNamed(settle(failed), "LoadPageCode")).toEqual([]) + expect( + (update(failed, Message.ClickedReloadPage()).commands ?? []).map(({ name, args }) => ({ + name, + args, + })), + ).toEqual([{ name: "LoadExternal", args: { href: "/service-map" } }]) + scene( + { update, view: body }, + given(failed), + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()), + expectView(text("Couldn't load this page")).toExist(), + expectView(role("main", { name: "Loading" })).not.toExist(), + ) + }) + + test("the open page's Subscriptions run in slots that restart with a new instance", async () => { + const first = settle(signedIn(AppRoute.AlertsCreate({ search: {} }))) + const editor = update(first.model, await runLoad(first)).model + // The editor's ninth Subscription, in the ninth slot. + const slot = subscriptions["page.8"] + const dependencies = slot?.modelToDependencies(editor) + expect(dependencies).toEqual({ + maybeDependencies: Option.some({ + maybeInstance: Option.some(expect.objectContaining({ page: "AlertEditor" })), + }), + }) + expect(slot?.keepAliveEquivalence?.(dependencies, slot.modelToDependencies(editor))).toBe(true) + const rebuilt = { ...editor, pageInstances: editor.pageInstances + 1 } + const reentered = settle({ ...rebuilt, page: null }) + expect(slot?.keepAliveEquivalence?.(dependencies, slot.modelToDependencies(reentered.model))).toBe( + false, + ) + // A page with fewer Subscriptions leaves the slot idle. + const logs = settle({ ...editor, route: AppRoute.Logs({ search: {} }) }).model + expect(slot?.modelToDependencies(logs)).toEqual({ + maybeDependencies: Option.some({ maybeInstance: Option.none() }), + }) + }) +}) diff --git a/apps/web/src/page/agent-sessions/list/implementation.ts b/apps/web/src/page/agent-sessions/list/implementation.ts new file mode 100644 index 0000000000..5d3283c48c --- /dev/null +++ b/apps/web/src/page/agent-sessions/list/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Agent Sessions", isPageHeading: true }], +}) diff --git a/apps/web/src/page/agent-sessions/list/index.ts b/apps/web/src/page/agent-sessions/list/index.ts index cb3ad10363..6e6e34bbf9 100644 --- a/apps/web/src/page/agent-sessions/list/index.ts +++ b/apps/web/src/page/agent-sessions/list/index.ts @@ -1,20 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/agent-sessions`: the sessions list. Search changes arrive as `routeChanged` and keep the window. */ -export const page = definePage( - "AgentSessionsList", - { Model, Message }, - { - routes: ["AgentSessions"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Agent Sessions", isPageHeading: true }], - }, -) +export const entry = definePage("AgentSessionsList", { Model, Message }, { routes: ["AgentSessions"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/agent-sessions/session/implementation.ts b/apps/web/src/page/agent-sessions/session/implementation.ts new file mode 100644 index 0000000000..92acffd393 --- /dev/null +++ b/apps/web/src/page/agent-sessions/session/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/agent-sessions/session/index.ts b/apps/web/src/page/agent-sessions/session/index.ts index e454214aa9..aaf244e93f 100644 --- a/apps/web/src/page/agent-sessions/session/index.ts +++ b/apps/web/src/page/agent-sessions/session/index.ts @@ -1,23 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" /** `/agent-sessions/$sessionId`: one session's four views. One instance per session. */ -export const page = definePage( +export const entry = definePage( "AgentSession", { Model, Message }, - { - routes: ["AgentSession"], - key: (route) => `AgentSession:${route.sessionId}`, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["AgentSession"], key: (route) => `AgentSession:${route.sessionId}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/agent-sessions/tool/implementation.ts b/apps/web/src/page/agent-sessions/tool/implementation.ts new file mode 100644 index 0000000000..f8bca8e1f5 --- /dev/null +++ b/apps/web/src/page/agent-sessions/tool/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { subscriptions } from "./subscription" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: (model) => [ + { label: "Agent Sessions", href: "/agent-sessions" }, + { label: "Tools", href: "/agent-sessions/tools" }, + { label: model.toolName }, + ], +}) diff --git a/apps/web/src/page/agent-sessions/tool/index.ts b/apps/web/src/page/agent-sessions/tool/index.ts index f75cf3a658..dac98521ff 100644 --- a/apps/web/src/page/agent-sessions/tool/index.ts +++ b/apps/web/src/page/agent-sessions/tool/index.ts @@ -1,27 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { subscriptions } from "./subscription" -import { view } from "./view" /** `/agent-sessions/tools/$toolName`: one tool's charts, failures and sessions. One instance per tool. */ -export const page = definePage( +export const entry = definePage( "AgentSessionTool", { Model, Message }, - { - routes: ["AgentSessionTool"], - key: (route) => `AgentSessionTool:${route.toolName}`, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: (model) => [ - { label: "Agent Sessions", href: "/agent-sessions" }, - { label: "Tools", href: "/agent-sessions/tools" }, - { label: model.toolName }, - ], - }, + { routes: ["AgentSessionTool"], key: (route) => `AgentSessionTool:${route.toolName}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/agent-sessions/tools/implementation.ts b/apps/web/src/page/agent-sessions/tools/implementation.ts new file mode 100644 index 0000000000..701c1da9cb --- /dev/null +++ b/apps/web/src/page/agent-sessions/tools/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Agent Sessions", href: "/agent-sessions" }, { label: "Tools" }], +}) diff --git a/apps/web/src/page/agent-sessions/tools/index.ts b/apps/web/src/page/agent-sessions/tools/index.ts index e14b971f23..03bf61c5dd 100644 --- a/apps/web/src/page/agent-sessions/tools/index.ts +++ b/apps/web/src/page/agent-sessions/tools/index.ts @@ -1,20 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/agent-sessions/tools`: every tool the window's agents called. Search changes arrive as `routeChanged`. */ -export const page = definePage( - "AgentSessionTools", - { Model, Message }, - { - routes: ["AgentSessionTools"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Agent Sessions", href: "/agent-sessions" }, { label: "Tools" }], - }, -) +export const entry = definePage("AgentSessionTools", { Model, Message }, { routes: ["AgentSessionTools"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/alerts/editor/implementation.ts b/apps/web/src/page/alerts/editor/implementation.ts new file mode 100644 index 0000000000..61e480ff38 --- /dev/null +++ b/apps/web/src/page/alerts/editor/implementation.ts @@ -0,0 +1,113 @@ +import { Option, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { documentStream } from "../../dashboards/kit/document-stream" +import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { collectionLoadFailed } from "../shared/collection-load" +import * as ChatConnectStream from "../shared/chat-connect-stream" +import * as DestinationDialog from "../destination/dialog" +import { decodeChartParam } from "./draft" +import { init } from "./init" +import { Message } from "./message" +import { Model } from "./model" +import { update } from "./update" +import { breadcrumbsOf, view } from "./view" + +const byOrg = { orgId: Schema.String } +const watching = { orgId: Schema.String, isWatching: Schema.Boolean } + +/** The dashboard to look the widget up in: both ids, no decodable `chart` snapshot, not an edit. */ +const lookupDashboardId = (model: Model): string | null => { + const { ruleId, dashboardId, widgetId, chart } = model.search + if (ruleId !== undefined || dashboardId === undefined || widgetId === undefined) return null + return chart !== undefined && Option.isSome(decodeChartParam(chart)) ? null : dashboardId +} + +/** `useAlertRulesList`, `useAlertIncidentsList` and `useAlertDestinationsList`. */ +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + rules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), + }), + states: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + }), + incidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), + /** The Add destination menu's `useChatWorkspaceConnect`, outside the menu so closing it keeps watching. */ + menuChatConnect: entry(ChatConnectStream.Dependencies.fields, { + modelToDependencies: ({ model }) => ChatConnectStream.dependenciesOf(model.chatConnect), + dependenciesToStream: (dependencies) => + Stream.map(ChatConnectStream.streamOf(dependencies), (message) => + Message.GotChatConnectMessage({ message }), + ), + }), + destinationChatConnect: entry(ChatConnectStream.Dependencies.fields, { + modelToDependencies: ({ model }) => + DestinationDialog.chatConnectDependencies(model.destinationDialog), + dependenciesToStream: (dependencies) => + Stream.map(DestinationDialog.chatConnectStream(dependencies), (message) => + Message.GotDestinationDialogMessage({ message }), + ), + }), + /** `useCollectionLoadFailed(rulesCollection.id, pending)` while an edit waits on the rules. */ + rulesLoad: entry(watching, { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWatching: model.search.ruleId !== undefined && model.rules === null && !model.isRulesLoadFailed, + }), + dependenciesToStream: ({ orgId, isWatching }) => + isWatching + ? collectionLoadFailed(`alert_rules:${orgId}`, () => Message.FailedLoadRules({ orgId })) + : Stream.empty, + }), + /** `useDashboardsRead`, only for the id-lookup fallback (a widget link without its snapshot). */ + sourceDashboard: entry( + { orgId: Schema.String, dashboardId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + dashboardId: lookupDashboardId(model), + }), + dependenciesToStream: ({ orgId, dashboardId }) => + dashboardId === null + ? Stream.empty + : documentStream(orgId, dashboardId, (document) => + Message.UpdatedSourceDashboard({ orgId, dashboards: Option.toArray(document) }), + ), + }, + ), + dashboardsLoad: entry(watching, { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWatching: + lookupDashboardId(model) !== null && + model.sourceDashboards === null && + !model.isDashboardsLoadFailed, + }), + dependenciesToStream: ({ orgId, isWatching }) => + isWatching + ? collectionLoadFailed(`dashboards:${orgId}`, () => Message.FailedLoadDashboards({ orgId })) + : Stream.empty, + }), +})) + +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/alerts/editor/index.ts b/apps/web/src/page/alerts/editor/index.ts index f36fef468e..6376a3f2ac 100644 --- a/apps/web/src/page/alerts/editor/index.ts +++ b/apps/web/src/page/alerts/editor/index.ts @@ -1,109 +1,16 @@ -import { Option, Schema, Stream } from "effect" -import { Subscription } from "foldkit" -import type { Resources } from "../../../platform/resources" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { documentStream } from "../../dashboards/kit/document-stream" -import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../shared/collections" -import { collectionLoadFailed } from "../shared/collection-load" -import * as ChatConnectStream from "../shared/chat-connect-stream" -import * as DestinationDialog from "../destination/dialog" -import { decodeChartParam } from "./draft" -import { init } from "./init" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { update } from "./update" -import { breadcrumbsOf, view } from "./view" - -const byOrg = { orgId: Schema.String } -const watching = { orgId: Schema.String, isWatching: Schema.Boolean } - -/** The dashboard to look the widget up in: both ids, no decodable `chart` snapshot, not an edit. */ -const lookupDashboardId = (model: Model): string | null => { - const { ruleId, dashboardId, widgetId, chart } = model.search - if (ruleId !== undefined || dashboardId === undefined || widgetId === undefined) return null - return chart !== undefined && Option.isSome(decodeChartParam(chart)) ? null : dashboardId -} - -/** `useAlertRulesList`, `useAlertIncidentsList` and `useAlertDestinationsList`. */ -const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ - rules: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), - }), - states: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), - }), - incidents: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), - }), - destinations: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => - destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), - }), - /** The Add destination menu's `useChatWorkspaceConnect`, outside the menu so closing it keeps watching. */ - menuChatConnect: entry(ChatConnectStream.Dependencies.fields, { - modelToDependencies: ({ model }) => ChatConnectStream.dependenciesOf(model.chatConnect), - dependenciesToStream: (dependencies) => - Stream.map(ChatConnectStream.streamOf(dependencies), (message) => Message.GotChatConnectMessage({ message })), - }), - destinationChatConnect: entry(ChatConnectStream.Dependencies.fields, { - modelToDependencies: ({ model }) => DestinationDialog.chatConnectDependencies(model.destinationDialog), - dependenciesToStream: (dependencies) => - Stream.map(DestinationDialog.chatConnectStream(dependencies), (message) => - Message.GotDestinationDialogMessage({ message }), - ), - }), - /** `useCollectionLoadFailed(rulesCollection.id, pending)` while an edit waits on the rules. */ - rulesLoad: entry(watching, { - modelToDependencies: ({ model, context }) => ({ - orgId: context.orgId, - isWatching: model.search.ruleId !== undefined && model.rules === null && !model.isRulesLoadFailed, - }), - dependenciesToStream: ({ orgId, isWatching }) => - isWatching - ? collectionLoadFailed(`alert_rules:${orgId}`, () => Message.FailedLoadRules({ orgId })) - : Stream.empty, - }), - /** `useDashboardsRead`, only for the id-lookup fallback (a widget link without its snapshot). */ - sourceDashboard: entry( - { orgId: Schema.String, dashboardId: Schema.NullOr(Schema.String) }, - { - modelToDependencies: ({ model, context }) => ({ orgId: context.orgId, dashboardId: lookupDashboardId(model) }), - dependenciesToStream: ({ orgId, dashboardId }) => - dashboardId === null - ? Stream.empty - : documentStream(orgId, dashboardId, (document) => - Message.UpdatedSourceDashboard({ orgId, dashboards: Option.toArray(document) }), - ), - }, - ), - dashboardsLoad: entry(watching, { - modelToDependencies: ({ model, context }) => ({ - orgId: context.orgId, - isWatching: lookupDashboardId(model) !== null && model.sourceDashboards === null && !model.isDashboardsLoadFailed, - }), - dependenciesToStream: ({ orgId, isWatching }) => - isWatching - ? collectionLoadFailed(`dashboards:${orgId}`, () => Message.FailedLoadDashboards({ orgId })) - : Stream.empty, - }), -})) /** `/alerts/create`: a new rule, a template, a chart's prefill, or `?ruleId=` to edit one. */ -export const page = definePage( +export const entry = definePage( "AlertEditor", { Model, Message }, { routes: ["AlertsCreate"], // A different rule or prefill source is a different draft (`RuleDraft.key` remounts the surface). key: (route) => JSON.stringify(route.search), - init, - update, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/alerts/hub/implementation.ts b/apps/web/src/page/alerts/hub/implementation.ts new file mode 100644 index 0000000000..5a905fdf7c --- /dev/null +++ b/apps/web/src/page/alerts/hub/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Alerts", isPageHeading: true }], +}) diff --git a/apps/web/src/page/alerts/hub/index.ts b/apps/web/src/page/alerts/hub/index.ts index 7aa9a2a678..78773cf835 100644 --- a/apps/web/src/page/alerts/hub/index.ts +++ b/apps/web/src/page/alerts/hub/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { view } from "./view" /** `/alerts`: the incident-first hub (Inbox, Rules, Routing). One instance per org; tabs are search. */ -export const page = definePage( - "AlertsHub", - { Model, Message }, - { - routes: ["Alerts"], - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Alerts", isPageHeading: true }], - }, -) +export const entry = definePage("AlertsHub", { Model, Message }, { routes: ["Alerts"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/alerts/incident/implementation.ts b/apps/web/src/page/alerts/incident/implementation.ts new file mode 100644 index 0000000000..d36f02ffe8 --- /dev/null +++ b/apps/web/src/page/alerts/incident/implementation.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +const byOrg = { orgId: Schema.String } + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + incidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), + rules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), + }), + states: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + }), +})) + +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/alerts/incident/index.ts b/apps/web/src/page/alerts/incident/index.ts index 0413e147d6..54c7ee2440 100644 --- a/apps/web/src/page/alerts/incident/index.ts +++ b/apps/web/src/page/alerts/incident/index.ts @@ -1,41 +1,12 @@ -import { Schema } from "effect" -import { Subscription } from "foldkit" -import type { Resources } from "../../../platform/resources" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" - -const byOrg = { orgId: Schema.String } - -const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ - incidents: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), - }), - rules: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), - }), - states: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), - }), -})) /** `/alerts/incidents/$incidentId`: one instance per incident. */ -export const page = definePage( +export const entry = definePage( "AlertIncident", { Model, Message }, - { - routes: ["AlertIncident"], - key: (route) => route.incidentId, - init, - update, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["AlertIncident"], key: (route) => route.incidentId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/alerts/rule/implementation.ts b/apps/web/src/page/alerts/rule/implementation.ts new file mode 100644 index 0000000000..b3d1d21b2b --- /dev/null +++ b/apps/web/src/page/alerts/rule/implementation.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { Message } from "./message" +import { Model } from "./model" +import { init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +const byOrg = { orgId: Schema.String } + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + rules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), + }), + incidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), + states: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + }), + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), +})) + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 04e1d0107e..3291e8fa5b 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -1,47 +1,12 @@ -import { Schema } from "effect" -import { Subscription } from "foldkit" -import type { Resources } from "../../../platform/resources" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" - -const byOrg = { orgId: Schema.String } - -const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ - rules: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), - }), - incidents: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), - }), - states: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), - }), - destinations: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => - destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), - }), -})) /** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ -export const page = definePage( +export const entry = definePage( "AlertRule", { Model, Message }, - { - routes: ["AlertRule"], - key: (route) => route.ruleId, - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["AlertRule"], key: (route) => route.ruleId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/alerts/uptime-form/implementation.ts b/apps/web/src/page/alerts/uptime-form/implementation.ts new file mode 100644 index 0000000000..6298f176c7 --- /dev/null +++ b/apps/web/src/page/alerts/uptime-form/implementation.ts @@ -0,0 +1,33 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { destinationRows } from "../shared/collections" +import { uptimeTabHref } from "../shared/uptime-commands" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + destinations: entry( + { orgId: Schema.String }, + { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }, + ), +})) + +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: () => [ + { label: "Alerts", href: "/alerts" }, + { label: "Uptime", href: uptimeTabHref() }, + ], +}) diff --git a/apps/web/src/page/alerts/uptime-form/index.ts b/apps/web/src/page/alerts/uptime-form/index.ts index 65c8a68769..06d3628f5f 100644 --- a/apps/web/src/page/alerts/uptime-form/index.ts +++ b/apps/web/src/page/alerts/uptime-form/index.ts @@ -1,39 +1,12 @@ -import { Schema } from "effect" -import { Subscription } from "foldkit" -import type { Resources } from "../../../platform/resources" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { destinationRows } from "../shared/collections" -import { uptimeTabHref } from "../shared/uptime-commands" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { view } from "./view" - -const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ - destinations: entry( - { orgId: Schema.String }, - { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => - destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), - }, - ), -})) /** `/alerts/uptime/new`: one instance per monitor being edited, or the new one. */ -export const page = definePage( +export const entry = definePage( "AlertsUptimeNew", { Model, Message }, - { - routes: ["AlertsUptimeNew"], - key: (route) => route.search.monitorId ?? "new", - init, - update, - view, - subscriptions, - breadcrumbs: () => [ - { label: "Alerts", href: "/alerts" }, - { label: "Uptime", href: uptimeTabHref() }, - ], - }, + { routes: ["AlertsUptimeNew"], key: (route) => route.search.monitorId ?? "new" }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/alerts/uptime-monitor/implementation.ts b/apps/web/src/page/alerts/uptime-monitor/implementation.ts new file mode 100644 index 0000000000..a8c56a6f54 --- /dev/null +++ b/apps/web/src/page/alerts/uptime-monitor/implementation.ts @@ -0,0 +1,33 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { destinationRows, incidentRows } from "../shared/collections" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +const byOrg = { orgId: Schema.String } + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), + incidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), +})) + +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/alerts/uptime-monitor/index.ts b/apps/web/src/page/alerts/uptime-monitor/index.ts index 6a83971d91..d90ea13013 100644 --- a/apps/web/src/page/alerts/uptime-monitor/index.ts +++ b/apps/web/src/page/alerts/uptime-monitor/index.ts @@ -1,39 +1,12 @@ -import { Schema } from "effect" -import { Subscription } from "foldkit" -import type { Resources } from "../../../platform/resources" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { destinationRows, incidentRows } from "../shared/collections" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" - -const byOrg = { orgId: Schema.String } - -const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ - destinations: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => - destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), - }), - incidents: entry(byOrg, { - modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => - incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), - }), -})) /** `/alerts/uptime/$monitorId`: one instance per monitor. */ -export const page = definePage( +export const entry = definePage( "AlertsUptimeMonitor", { Model, Message }, - { - routes: ["AlertsUptimeMonitor"], - key: (route) => route.monitorId, - init, - update, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["AlertsUptimeMonitor"], key: (route) => route.monitorId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/analytics/implementation.ts b/apps/web/src/page/analytics/implementation.ts new file mode 100644 index 0000000000..06e5448db4 --- /dev/null +++ b/apps/web/src/page/analytics/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Web Analytics", isPageHeading: true }], +}) diff --git a/apps/web/src/page/analytics/index.ts b/apps/web/src/page/analytics/index.ts index 5cf439bd32..5c27396743 100644 --- a/apps/web/src/page/analytics/index.ts +++ b/apps/web/src/page/analytics/index.ts @@ -1,22 +1,8 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/analytics`: web analytics. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "Analytics", - { Model, Message }, - { - routes: ["Analytics"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Web Analytics", isPageHeading: true }], - }, -) +export const entry = definePage("Analytics", { Model, Message }, { routes: ["Analytics"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/anomalies/detail/implementation.ts b/apps/web/src/page/anomalies/detail/implementation.ts new file mode 100644 index 0000000000..da8a93f130 --- /dev/null +++ b/apps/web/src/page/anomalies/detail/implementation.ts @@ -0,0 +1,42 @@ +import { Clock, Duration, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { incidentOf } from "./derive" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" +import { pollTicks } from "../../../data/poll" + +/** `useIntervalRefresh`: an open incident and its series re-read every 15 s (paused while hidden or offline). */ +const subscriptions = Subscription.make, Message>()((entry) => ({ + live: entry( + { isOpen: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + isOpen: incidentOf(model).pipe( + (incident) => incident._tag === "Some" && incident.value.status === "open", + ), + }), + dependenciesToStream: ({ isOpen }) => + isOpen + ? pollTicks(Duration.seconds(15)).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => + Message.TickedLiveRefresh({ nowMs }), + ), + ), + ) + : Stream.empty, + }, + ), +})) + +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/anomalies/detail/index.ts b/apps/web/src/page/anomalies/detail/index.ts index 0cc75a2f01..d03139f354 100644 --- a/apps/web/src/page/anomalies/detail/index.ts +++ b/apps/web/src/page/anomalies/detail/index.ts @@ -1,44 +1,12 @@ -import { Clock, Duration, Effect, Schema, Stream } from "effect" -import { Subscription } from "foldkit" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { incidentOf } from "./derive" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" -import { pollTicks } from "../../../data/poll" - -/** `useIntervalRefresh`: an open incident and its series re-read every 15 s (paused while hidden or offline). */ -const subscriptions = Subscription.make, Message>()((entry) => ({ - live: entry( - { isOpen: Schema.Boolean }, - { - modelToDependencies: ({ model }) => ({ - isOpen: incidentOf(model).pipe((incident) => incident._tag === "Some" && incident.value.status === "open"), - }), - dependenciesToStream: ({ isOpen }) => - isOpen - ? pollTicks(Duration.seconds(15)).pipe( - Stream.mapEffect(() => - Effect.map(Clock.currentTimeMillis, (nowMs) => Message.TickedLiveRefresh({ nowMs })), - ), - ) - : Stream.empty, - }, - ), -})) /** `/anomalies/$incidentId`: one instance per incident. */ -export const page = definePage( +export const entry = definePage( "Anomaly", { Model, Message }, - { - routes: ["Anomaly"], - key: (route) => route.incidentId, - init, - update, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["Anomaly"], key: (route) => route.incidentId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/anomalies/list/implementation.ts b/apps/web/src/page/anomalies/list/implementation.ts new file mode 100644 index 0000000000..1b44d7def2 --- /dev/null +++ b/apps/web/src/page/anomalies/list/implementation.ts @@ -0,0 +1,79 @@ +import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" +import { Clock, Duration, Effect, Option, Schema, Stream } from "effect" +import { AsyncData, Dom, Subscription } from "foldkit" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { isLiveOf } from "./derive" +import { Message } from "./message" +import { Model } from "./model" +import { init, routeChanged, update } from "./update" +import { view } from "./view" +import { pollTicks } from "../../../data/poll" + +const LIVE_REFRESH_INTERVAL = Duration.seconds(15) + +const LIST_KEYS = new Map([ + ["j", "Next"], + ["J", "Next"], + ["ArrowDown", "Next"], + ["k", "Previous"], + ["K", "Previous"], + ["ArrowUp", "Previous"], + ["Enter", "Open"], + ["Escape", "Escape"], +]) + +const isInteractiveTarget = (target: EventTarget | null): boolean => + target instanceof HTMLElement && + target.closest("button, a, [role='button'], [role='menuitem'], [role='option']") !== null + +/** `useListNavigation`'s hotkeys; suspended while a dialog owns the keyboard or a field has focus. */ +const listKeys: Stream.Stream = Dom.streamFromEventFilterMap({ + target: () => document, + type: "keydown", + filterMapEvent: (event) => { + const key = LIST_KEYS.get(event.key) + if (key === undefined || event.metaKey || event.ctrlKey || event.altKey) return Option.none() + if ((key === "Next" || key === "Previous") && event.shiftKey) return Option.none() + if (isEditableTarget(event.target) || isDialogOpen()) return Option.none() + if (key === "Open" && isInteractiveTarget(event.target)) return Option.none() + if (key === "Next" || key === "Previous") event.preventDefault() + return Option.some(Message.PressedListKey({ key })) + }, +}) + +/** `useIntervalRefresh`: the first page again every 15 s while Live (paused while hidden or offline). */ +const subscriptions = Subscription.make, Message>()((entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ hasRows: AsyncData.hasData(model.firstPage.data) }), + dependenciesToStream: ({ hasRows }) => (hasRows ? listKeys : Stream.empty), + }, + ), + live: entry( + { isLive: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ isLive: isLiveOf(model.search) }), + dependenciesToStream: ({ isLive }) => + isLive + ? pollTicks(LIVE_REFRESH_INTERVAL).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => + Message.TickedLiveRefresh({ nowMs }), + ), + ), + ) + : Stream.empty, + }, + ), +})) + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Anomalies", isPageHeading: true }], +}) diff --git a/apps/web/src/page/anomalies/list/index.ts b/apps/web/src/page/anomalies/list/index.ts index a6d5a4b624..09c825ff5a 100644 --- a/apps/web/src/page/anomalies/list/index.ts +++ b/apps/web/src/page/anomalies/list/index.ts @@ -1,82 +1,8 @@ -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" -import { Clock, Duration, Effect, Option, Schema, Stream } from "effect" -import { AsyncData, Dom, Subscription } from "foldkit" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { isLiveOf } from "./derive" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view } from "./view" -import { pollTicks } from "../../../data/poll" - -const LIVE_REFRESH_INTERVAL = Duration.seconds(15) - -const LIST_KEYS = new Map([ - ["j", "Next"], - ["J", "Next"], - ["ArrowDown", "Next"], - ["k", "Previous"], - ["K", "Previous"], - ["ArrowUp", "Previous"], - ["Enter", "Open"], - ["Escape", "Escape"], -]) - -const isInteractiveTarget = (target: EventTarget | null): boolean => - target instanceof HTMLElement && - target.closest("button, a, [role='button'], [role='menuitem'], [role='option']") !== null - -/** `useListNavigation`'s hotkeys; suspended while a dialog owns the keyboard or a field has focus. */ -const listKeys: Stream.Stream = Dom.streamFromEventFilterMap({ - target: () => document, - type: "keydown", - filterMapEvent: (event) => { - const key = LIST_KEYS.get(event.key) - if (key === undefined || event.metaKey || event.ctrlKey || event.altKey) return Option.none() - if ((key === "Next" || key === "Previous") && event.shiftKey) return Option.none() - if (isEditableTarget(event.target) || isDialogOpen()) return Option.none() - if (key === "Open" && isInteractiveTarget(event.target)) return Option.none() - if (key === "Next" || key === "Previous") event.preventDefault() - return Option.some(Message.PressedListKey({ key })) - }, -}) - -/** `useIntervalRefresh`: the first page again every 15 s while Live (paused while hidden or offline). */ -const subscriptions = Subscription.make, Message>()((entry) => ({ - listKeys: entry( - { hasRows: Schema.Boolean }, - { - modelToDependencies: ({ model }) => ({ hasRows: AsyncData.hasData(model.firstPage.data) }), - dependenciesToStream: ({ hasRows }) => (hasRows ? listKeys : Stream.empty), - }, - ), - live: entry( - { isLive: Schema.Boolean }, - { - modelToDependencies: ({ model }) => ({ isLive: isLiveOf(model.search) }), - dependenciesToStream: ({ isLive }) => - isLive - ? pollTicks(LIVE_REFRESH_INTERVAL).pipe( - Stream.mapEffect(() => - Effect.map(Clock.currentTimeMillis, (nowMs) => Message.TickedLiveRefresh({ nowMs })), - ), - ) - : Stream.empty, - }, - ), -})) /** `/anomalies`: one instance; a search change arrives as `routeChanged`. */ -export const page = definePage( - "AnomaliesList", - { Model, Message }, - { - routes: ["Anomalies"], - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Anomalies", isPageHeading: true }], - }, -) +export const entry = definePage("AnomaliesList", { Model, Message }, { routes: ["Anomalies"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/chrome.ts b/apps/web/src/page/chrome.ts index 7854ec6960..7fd82792e8 100644 --- a/apps/web/src/page/chrome.ts +++ b/apps/web/src/page/chrome.ts @@ -1,9 +1,11 @@ import { pageContentStyles } from "@maple/ui/styles/ui/page-layout" import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" +import type { LoadFailure } from "../data/load-failure" import { AppRoute, isPublicRoute } from "../route" import type { BreadcrumbEntry } from "../shell" import { linkButton } from "../ui/button" +import { errorState } from "../ui/error-state" import { icon } from "../ui/icon" import { skeleton, skeletonList } from "../ui/skeleton" import { dashboardPage, pageBody } from "./kit/dashboard-page" @@ -55,6 +57,14 @@ export const placeholderContent = (h: HtmlBuilder): Html => ), ]) +/** The route's page code did not load: a dropped connection, or a deploy that replaced it. */ +export const pageCodeFailedContent = ( + h: HtmlBuilder, + failure: LoadFailure, + onReload: Message, +): Html => + dashboardPage(h, {}, [errorState(h, { failure, title: "Couldn't load this page", onRetry: onReload })]) + /** An unknown path inside the shell: a dashed card at the top under a title, like a missing log. */ export const notFoundContent = (h: HtmlBuilder): Html => dashboardPage(h, { title: "Page not found", gap: "sm" }, [ diff --git a/apps/web/src/page/code-review/implementation.ts b/apps/web/src/page/code-review/implementation.ts new file mode 100644 index 0000000000..ea17b4fcbf --- /dev/null +++ b/apps/web/src/page/code-review/implementation.ts @@ -0,0 +1,77 @@ +import { Option } from "effect" +import { Submodel, Subscription } from "foldkit" +import type { Resources } from "../../platform/resources" +import { implementPage, type PageSubscriptionInput, type PageViewInputs } from "../contract" +import { entry } from "./index" +import { view as analyticsView } from "./analytics/view" +import { view as ciView } from "./ci/view" +import { view as issuesView } from "./issues/view" +import { subscriptions as pullRequestsSubscriptions } from "./pull-requests/subscription" +import { view as pullRequestsView } from "./pull-requests/view" +import { view as settingsView } from "./settings/view" +import { Message, Model } from "./tabs" +import { breadcrumbs, contextChanged, init, routeChanged, update } from "./tabs-update" + +/** The active tab's view, under a boundary per tab. */ +const view = Submodel.defineView((model, inputs, h) => { + const { tab } = model + switch (tab._tag) { + case "Analytics": + return h.submodel({ + slotId: "code-review-analytics", + model: tab.model, + view: analyticsView, + viewInputs: inputs, + toParentMessage: (message) => Message.GotAnalyticsMessage({ message }), + }) + case "PullRequests": + return h.submodel({ + slotId: "code-review-pull-requests", + model: tab.model, + view: pullRequestsView, + viewInputs: inputs, + toParentMessage: (message) => Message.GotPullRequestsMessage({ message }), + }) + case "Ci": + return h.submodel({ + slotId: "code-review-ci", + model: tab.model, + view: ciView, + viewInputs: inputs, + toParentMessage: (message) => Message.GotCiMessage({ message }), + }) + case "Issues": + return h.submodel({ + slotId: "code-review-issues", + model: tab.model, + view: issuesView, + viewInputs: inputs, + toParentMessage: (message) => Message.GotIssuesMessage({ message }), + }) + case "Settings": + return h.submodel({ + slotId: "code-review-settings", + model: tab.model, + view: settingsView, + viewInputs: inputs, + toParentMessage: (message) => Message.GotSettingsMessage({ message }), + }) + } +}) + +/** The Pull requests tab's polling, while it is the active tab. */ +const subscriptions = Subscription.lift(pullRequestsSubscriptions), Message>({ + read: ({ model, context }) => + model.tab._tag === "PullRequests" ? Option.some({ model: model.tab.model, context }) : Option.none(), + toParentMessage: (message) => Message.GotPullRequestsMessage({ message }), +}) satisfies Subscription.Subscriptions, Message, Resources> + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs, +}) diff --git a/apps/web/src/page/code-review/index.ts b/apps/web/src/page/code-review/index.ts index 2e456f5531..9ae923aae5 100644 --- a/apps/web/src/page/code-review/index.ts +++ b/apps/web/src/page/code-review/index.ts @@ -1,76 +1,13 @@ -import { Option } from "effect" -import { Submodel, Subscription } from "foldkit" -import type { Resources } from "../../platform/resources" -import { definePage, type PageSubscriptionInput, type PageViewInputs } from "../contract" -import { view as analyticsView } from "./analytics/view" -import { view as ciView } from "./ci/view" -import { view as issuesView } from "./issues/view" -import { subscriptions as pullRequestsSubscriptions } from "./pull-requests/subscription" -import { view as pullRequestsView } from "./pull-requests/view" -import { view as settingsView } from "./settings/view" -import { Message, Model } from "./tabs" -import { breadcrumbs, contextChanged, init, routeChanged, update } from "./tabs-update" - -/** The active tab's view, under a boundary per tab. */ -const view = Submodel.defineView((model, inputs, h) => { - const { tab } = model - switch (tab._tag) { - case "Analytics": - return h.submodel({ - slotId: "code-review-analytics", - model: tab.model, - view: analyticsView, - viewInputs: inputs, - toParentMessage: (message) => Message.GotAnalyticsMessage({ message }), - }) - case "PullRequests": - return h.submodel({ - slotId: "code-review-pull-requests", - model: tab.model, - view: pullRequestsView, - viewInputs: inputs, - toParentMessage: (message) => Message.GotPullRequestsMessage({ message }), - }) - case "Ci": - return h.submodel({ - slotId: "code-review-ci", - model: tab.model, - view: ciView, - viewInputs: inputs, - toParentMessage: (message) => Message.GotCiMessage({ message }), - }) - case "Issues": - return h.submodel({ - slotId: "code-review-issues", - model: tab.model, - view: issuesView, - viewInputs: inputs, - toParentMessage: (message) => Message.GotIssuesMessage({ message }), - }) - case "Settings": - return h.submodel({ - slotId: "code-review-settings", - model: tab.model, - view: settingsView, - viewInputs: inputs, - toParentMessage: (message) => Message.GotSettingsMessage({ message }), - }) - } -}) - -/** The Pull requests tab's polling, while it is the active tab. */ -const subscriptions = Subscription.lift(pullRequestsSubscriptions), Message>({ - read: ({ model, context }) => - model.tab._tag === "PullRequests" ? Option.some({ model: model.tab.model, context }) : Option.none(), - toParentMessage: (message) => Message.GotPullRequestsMessage({ message }), -}) satisfies Subscription.Subscriptions, Message, Resources> +import { definePage } from "../contract" +import { Message } from "./tabs" +import { Model } from "./tabs" /** * `/code-review` and its tabs (Analytics, Pull requests, CI, Issues, Settings) as one instance per * org: a tab change keeps the instance, so what one tab read serves the next. Filters and windows * are search params; each tab's own update handles them. */ -export const page = definePage( +export const entry = definePage( "CodeReview", { Model, Message }, { @@ -82,12 +19,7 @@ export const page = definePage( "CodeReviewSettings", ], key: () => "code-review", - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs, }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/contract.ts b/apps/web/src/page/contract.ts index 41317e6e4f..d6f453ba99 100644 --- a/apps/web/src/page/contract.ts +++ b/apps/web/src/page/contract.ts @@ -2,7 +2,8 @@ import { DashboardWidgetSchema } from "@maple/domain/http" import type { OrgId } from "@maple/domain" import type { InfraSurfaceLiteral } from "@maple/domain/http" import { Array, Option, Schema, Stream } from "effect" -import { Command, Submodel, Subscription, type Update } from "foldkit" +import { Command, Submodel, type Subscription, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import type { IntegrationFlags } from "../data/infra-surfaces" import type { Resources } from "../platform/resources" @@ -136,11 +137,16 @@ type RouteOf = Extract // SPEC (what a page module provides) -export interface PageSpec { +/** What the registry knows of a page from the start (`index.ts`); the rest loads on first use. */ +export interface PageEntrySpec { /** The routes this page renders. */ readonly routes: ReadonlyArray /** The instance survives a navigation while this stays equal (default: the route tag). */ readonly key?: (route: RouteOf) => string +} + +/** The page's code (`implementation.ts`), imported on first entry or link intent. */ +export interface PageSpec { readonly init: (route: RouteOf, context: PageContext) => PageReturn /** * The requests warmed on link intent: the same fetch Commands @@ -221,6 +227,100 @@ export interface PageStep { readonly outMessage: Option.Option } +/** A page's slot in the root Model. */ +export interface PageSlotOf extends PageSlotBase { + readonly _tag: Id + readonly model: Model +} + +/** A page's Message, stamped with its instance. */ +export interface PageMessageOf extends PageMessageBase { + readonly _tag: Id + readonly message: Message +} + +/** A page Subscription's dependencies: its `entry` fields' values, carried by a registry slot. */ +export const PageSubscriptionDependencies = Schema.Record(Schema.String, Schema.Unknown) +export type PageSubscriptionDependencies = typeof PageSubscriptionDependencies.Type + +/** One of a page's Subscriptions, as the registry's slot of the same index runs it. */ +export interface PageSubscription { + /** `None` when `slot` is another page's. */ + readonly dependenciesOf: ( + slot: PageSlotBase, + context: PageContext, + ) => Option.Option + readonly isSameDependencies: ( + left: PageSubscriptionDependencies, + right: PageSubscriptionDependencies, + ) => boolean + /** Its stream for one instance; the Messages are stamped with it. */ + readonly streamOf: ( + dependencies: PageSubscriptionDependencies, + instance: Pick, + ) => Stream.Stream +} + +export type PageEmbed = ( + h: HtmlBuilder, + slotId: string, + slot: PageSlotBase, + inputs: PageViewInputs, + toParentMessage: (message: Wrapped) => ParentMessage, +) => Html + +/** A page's code, erased to what the registry and root consume. */ +export interface PageImplementation { + readonly init: ( + route: AppRoute, + context: PageContext, + instance: number, + ) => Option.Option> + readonly preload: ( + route: AppRoute, + context: PageContext, + nowMs: number, + ) => Option.Option>> + readonly update: ( + slot: PageSlotBase, + message: PageMessageBase, + context: PageContext, + ) => Option.Option> + readonly routeChanged: ( + slot: PageSlotBase, + route: AppRoute, + context: PageContext, + ) => Option.Option> + readonly isRevivable: boolean + readonly revive: ( + slot: PageSlotBase, + route: AppRoute, + context: PageContext, + instance: number, + ) => Option.Option> + readonly blockedNavigation: ( + slot: PageSlotBase, + url: string, + context: PageContext, + ) => Option.Option> + readonly contextChanged: ( + slot: PageSlotBase, + context: PageContext, + ) => Option.Option> + readonly infraSurfacesSettled: ( + slot: PageSlotBase, + context: PageContext, + ) => Option.Option> + readonly breadcrumbs: (slot: PageSlotBase) => ReadonlyArray + readonly hasSource: boolean + readonly source: (slot: PageSlotBase) => PageSourceRef | null + /** The page's view, embedded by the root under `slotId` (its boundary for this instance). */ + readonly embedView: PageEmbed + /** The page's top-bar actions, embedded by the shell's top bar; nothing for most pages. */ + readonly embedTopbarActions: PageEmbed + readonly subscriptions: ReadonlyArray> +} + /** * The page lives in the root Model as `{ _tag: id, key, orgId, model }`, its Messages as * `{ _tag: id, key, orgId, message }`: an older instance's late Messages are dropped centrally. @@ -233,10 +333,10 @@ export const definePage = < >( id: Id, schemas: { readonly Model: ModelSchema; readonly Message: MessageSchema }, - spec: PageSpec, + spec: PageEntrySpec, ) => { - type Model = ModelSchema["Type"] - type Message = MessageSchema["Type"] + type SlotType = PageSlotOf + type WrappedType = PageMessageOf const Slot = Schema.TaggedStruct(id, { key: Schema.String, orgId: Schema.String, @@ -249,20 +349,69 @@ export const definePage = < instance: Schema.Number, message: schemas.Message, }) - type SlotType = { - readonly _tag: Id - readonly key: string - readonly orgId: string - readonly instance: number - readonly model: Model - } - type WrappedType = { - readonly _tag: Id - readonly key: string - readonly orgId: string - readonly instance: number - readonly message: Message + const ownsRoute = (route: AppRoute): route is RouteOf => + Array.some(spec.routes, (tag) => tag === route._tag) + const routeKey = (route: RouteOf) => (spec.key ? spec.key(route) : route._tag) + const keyOf = (route: AppRoute): Option.Option => + ownsRoute(route) ? Option.some(routeKey(route)) : Option.none() + return { + id, + schemas, + Slot, + Wrapped, + ownsRoute, + routeKey, + keyOf, + /** The registered page: this entry, its code behind `load` (`() => import("./implementation")`). */ + lazy: ( + load: () => Promise<{ readonly implementation: PageImplementation }>, + ): LazyPage => ({ + id, + Slot, + Wrapped, + keyOf, + load: () => load().then((module) => module.implementation), + }), } +} + +/** A page as `index.ts` declares it: schemas, routes and key, but none of its code. */ +export interface PageEntry< + Id extends string, + Tags extends RouteTag, + ModelSchema extends Schema.Top, + MessageSchema extends Schema.Top, +> { + readonly id: Id + readonly schemas: { readonly Model: ModelSchema; readonly Message: MessageSchema } + readonly ownsRoute: (route: AppRoute) => route is RouteOf + readonly routeKey: (route: RouteOf) => string +} + +/** What the registry holds for every page from the start; `load` brings its code. */ +export interface LazyPage { + readonly id: Id + readonly Slot: SlotSchema + readonly Wrapped: WrappedSchema + /** The instance key for `route`, or `None` when this page does not render it. */ + readonly keyOf: (route: AppRoute) => Option.Option + readonly load: () => Promise> +} + +/** The page's code (`implementation.ts`): `implementPage(entry, { init, update, view, ... })`. */ +export const implementPage = < + const Id extends string, + Tags extends RouteTag, + ModelSchema extends Schema.Top, + MessageSchema extends Schema.Top, +>( + entry: PageEntry, + spec: PageSpec, +): PageImplementation, PageMessageOf> => { + type Message = MessageSchema["Type"] + type SlotType = PageSlotOf + type WrappedType = PageMessageOf + const { id, ownsRoute, routeKey: keyOf } = entry // Ids are unique across the registry, so a matching tag is this page's slot or Message. const isOwnSlot = (slot: PageSlotBase): slot is SlotType => slot._tag === id @@ -270,14 +419,11 @@ export const definePage = < /** The Message was produced by this very instance, not an older one of the same page. */ const isForInstance = (slot: PageSlotBase, message: PageMessageBase) => message.key === slot.key && message.orgId === slot.orgId && message.instance === slot.instance - const ownsRoute = (route: AppRoute): route is RouteOf => - Array.some(spec.routes, (tag) => tag === route._tag) const wrapFor = (key: string, orgId: string, instance: number) => (message: Message): WrappedType => ({ _tag: id, key, orgId, instance, message }) - const keyOf = (route: RouteOf) => (spec.key ? spec.key(route) : route._tag) - const isOwnPageMessage = Schema.is(schemas.Message) + const isOwnPageMessage = Schema.is(entry.schemas.Message) /** A toast's action is the page's own Message; it travels wrapped, like every page Message. */ const wrapOut = (wrap: (message: Message) => WrappedType) => @@ -293,7 +439,7 @@ export const definePage = < key: string, instance: number, context: PageContext, - result: PageReturn, + result: PageReturn, ): PageStep => { const wrap = wrapFor(key, context.orgId, instance) return { @@ -303,61 +449,60 @@ export const definePage = < } } - /** - * Each Subscription restarts per instance and stamps its Messages with that instance's key and - * org, so a Message queued by an older instance's stream is dropped like a late Command result. - */ - const stamped = Subscription.make()((entry) => - Object.fromEntries( - Object.entries(spec.subscriptions ?? {}).map(([name, inner]) => [ - name, - entry( - { - maybeInstance: Schema.Option( - Schema.Struct({ - key: Schema.String, - orgId: Schema.String, - instance: Schema.Number, - inner: inner.dependenciesSchema, - }), - ), - }, - { - modelToDependencies: (host) => ({ - maybeInstance: - host.page !== null && host.context !== null && isOwnSlot(host.page) - ? Option.some({ - key: host.page.key, - orgId: host.page.orgId, - instance: host.page.instance, - inner: inner.modelToDependencies({ - model: host.page.model, - context: host.context, - }), - }) - : Option.none(), - }), - dependenciesToStream: ({ maybeInstance }) => - Option.match(maybeInstance, { - onNone: () => Stream.empty, - onSome: ({ key, orgId, instance, inner: dependencies }) => - Stream.map( - inner.dependenciesToStream(dependencies, () => dependencies), - wrapFor(key, orgId, instance), - ), - }), - }, + /** Each runs in the registry's subscription slot of the same index while this page is open. */ + const subscriptions = Object.values(spec.subscriptions ?? {}).map( + (inner): PageSubscription => ({ + dependenciesOf: (slot, context) => + isOwnSlot(slot) + ? Option.some(inner.modelToDependencies({ model: slot.model, context })) + : Option.none(), + isSameDependencies: inner.keepAliveEquivalence ?? Schema.toEquivalence(inner.dependenciesSchema), + streamOf: (dependencies, { key, orgId, instance }) => + Stream.map( + inner.dependenciesToStream(dependencies, () => dependencies), + wrapFor(key, orgId, instance), ), - ]), - ), + }), + ) + + /** The page's own view under its own boundary. */ + const hostView = Submodel.defineView((slot, inputs, h) => + isOwnSlot(slot) + ? h.submodel({ + slotId: id, + model: slot.model, + view: spec.view, + viewInputs: { + context: inputs.context, + isMobile: inputs.isMobile ?? false, + isBelowSm: inputs.isBelowSm ?? false, + isBelowLg: inputs.isBelowLg ?? false, + }, + toParentMessage: wrapFor(slot.key, slot.orgId, slot.instance), + }) + : null, + ) + const hostTopbarActions = Submodel.defineView( + (slot, inputs, h) => { + const actions = spec.topbarActions + return isOwnSlot(slot) && actions !== undefined + ? h.submodel({ + slotId: `${id}:topbar`, + model: slot.model, + view: actions, + viewInputs: { + context: inputs.context, + isMobile: inputs.isMobile ?? false, + isBelowSm: inputs.isBelowSm ?? false, + isBelowLg: inputs.isBelowLg ?? false, + }, + toParentMessage: wrapFor(slot.key, slot.orgId, slot.instance), + }) + : null + }, ) return { - id, - Slot, - Wrapped, - keyOf: (route: AppRoute): Option.Option => - ownsRoute(route) ? Option.some(keyOf(route)) : Option.none(), init: (route: AppRoute, context: PageContext, instance: number) => ownsRoute(route) ? Option.some(step(keyOf(route), instance, context, spec.init(route, context))) @@ -437,46 +582,10 @@ export const definePage = < hasSource: spec.source !== undefined, source: (slot: PageSlotBase): PageSourceRef | null => isOwnSlot(slot) && spec.source !== undefined ? spec.source(slot.model) : null, - /** The root embeds this; it renders the page's own view under its own boundary. */ - hostView: Submodel.defineView((slot, inputs, h) => - isOwnSlot(slot) - ? h.submodel({ - slotId: id, - model: slot.model, - view: spec.view, - viewInputs: { - context: inputs.context, - isMobile: inputs.isMobile ?? false, - isBelowSm: inputs.isBelowSm ?? false, - isBelowLg: inputs.isBelowLg ?? false, - }, - toParentMessage: wrapFor(slot.key, slot.orgId, slot.instance), - }) - : null, - ), - /** The page's top-bar actions, embedded by the shell's top bar; nothing for most pages. */ - hostTopbarActions: Submodel.defineView( - (slot, inputs, h) => { - const actions = spec.topbarActions - return isOwnSlot(slot) && actions !== undefined - ? h.submodel({ - slotId: `${id}:topbar`, - model: slot.model, - view: actions, - viewInputs: { - context: inputs.context, - isMobile: inputs.isMobile ?? false, - isBelowSm: inputs.isBelowSm ?? false, - isBelowLg: inputs.isBelowLg ?? false, - }, - toParentMessage: wrapFor(slot.key, slot.orgId, slot.instance), - }) - : null - }, - ), - /** Keys are prefixed with the page id: Subscription keys share one namespace. */ - subscriptions: Object.fromEntries( - Object.entries(stamped).map(([key, entry]) => [`${id}.${key}`, entry]), - ), + embedView: (h, slotId, slot, inputs, toParentMessage) => + h.submodel({ slotId, model: slot, view: hostView, viewInputs: inputs, toParentMessage }), + embedTopbarActions: (h, slotId, slot, inputs, toParentMessage) => + h.submodel({ slotId, model: slot, view: hostTopbarActions, viewInputs: inputs, toParentMessage }), + subscriptions, } } diff --git a/apps/web/src/page/dashboards/list/implementation.ts b/apps/web/src/page/dashboards/list/implementation.ts new file mode 100644 index 0000000000..ea546cc9c7 --- /dev/null +++ b/apps/web/src/page/dashboards/list/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Dashboards", isPageHeading: true }], +}) diff --git a/apps/web/src/page/dashboards/list/index.ts b/apps/web/src/page/dashboards/list/index.ts index 29f90800ab..46964271d5 100644 --- a/apps/web/src/page/dashboards/list/index.ts +++ b/apps/web/src/page/dashboards/list/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { view } from "./view" /** `/dashboards`: the org's dashboards. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "DashboardsList", - { Model, Message }, - { - routes: ["Dashboards"], - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Dashboards", isPageHeading: true }], - }, -) +export const entry = definePage("DashboardsList", { Model, Message }, { routes: ["Dashboards"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/dashboards/templates/implementation.ts b/apps/web/src/page/dashboards/templates/implementation.ts new file mode 100644 index 0000000000..9202a4089c --- /dev/null +++ b/apps/web/src/page/dashboards/templates/implementation.ts @@ -0,0 +1,12 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + breadcrumbs: () => [{ label: "Dashboards", href: "/dashboards" }, { label: "Templates" }], +}) diff --git a/apps/web/src/page/dashboards/templates/index.ts b/apps/web/src/page/dashboards/templates/index.ts index 6eb0fd90e4..4be8fc6d9b 100644 --- a/apps/web/src/page/dashboards/templates/index.ts +++ b/apps/web/src/page/dashboards/templates/index.ts @@ -1,19 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view } from "./view" /** `/dashboards/templates`: the gallery; the selected template rides in `?template=`. */ -export const page = definePage( - "DashboardTemplates", - { Model, Message }, - { - routes: ["DashboardsTemplates"], - init, - update, - routeChanged, - view, - breadcrumbs: () => [{ label: "Dashboards", href: "/dashboards" }, { label: "Templates" }], - }, -) +export const entry = definePage("DashboardTemplates", { Model, Message }, { routes: ["DashboardsTemplates"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/dashboards/view/implementation.ts b/apps/web/src/page/dashboards/view/implementation.ts new file mode 100644 index 0000000000..eff6f7eb69 --- /dev/null +++ b/apps/web/src/page/dashboards/view/implementation.ts @@ -0,0 +1,24 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: (model) => [ + { label: "Dashboards", href: "/dashboards" }, + { + label: + model.status === "Loading" + ? "Loading…" + : model.document === null + ? "Not found" + : model.document.name, + }, + ], +}) diff --git a/apps/web/src/page/dashboards/view/index.ts b/apps/web/src/page/dashboards/view/index.ts index 82670977c6..242a50e2ca 100644 --- a/apps/web/src/page/dashboards/view/index.ts +++ b/apps/web/src/page/dashboards/view/index.ts @@ -1,32 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { view } from "./view" /** `/dashboards/$dashboardId`: one instance per dashboard; `mode=edit` and view params are search. */ -export const page = definePage( +export const entry = definePage( "DashboardView", { Model, Message }, - { - routes: ["Dashboard"], - key: (route) => route.dashboardId, - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: (model) => [ - { label: "Dashboards", href: "/dashboards" }, - { - label: - model.status === "Loading" - ? "Loading…" - : model.document === null - ? "Not found" - : model.document.name, - }, - ], - }, + { routes: ["Dashboard"], key: (route) => route.dashboardId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/dashboards/widget/implementation.ts b/apps/web/src/page/dashboards/widget/implementation.ts new file mode 100644 index 0000000000..c1cd599b21 --- /dev/null +++ b/apps/web/src/page/dashboards/widget/implementation.ts @@ -0,0 +1,28 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { update } from "./page-update" +import { subscriptions } from "./subscription" +import { askToLeave, init, isBlockingNavigation, routeChanged, widgetOf } from "./update" +import { topbarActions, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + topbarActions, + subscriptions, + isBlockingNavigation, + blockedNavigation: (model, url) => askToLeave(model, url), + breadcrumbs: (model) => + model.document !== null && widgetOf(model) !== null + ? [ + { label: "Dashboards", href: "/dashboards" }, + { label: model.document.name, href: `/dashboards/${model.dashboardId}` }, + { label: "Configure widget" }, + ] + : [ + { label: "Dashboards", href: "/dashboards" }, + { label: model.status === "Loading" ? "Loading…" : "Not found" }, + ], +}) diff --git a/apps/web/src/page/dashboards/widget/index.ts b/apps/web/src/page/dashboards/widget/index.ts index 5aecb5f6c4..153afc5dcd 100644 --- a/apps/web/src/page/dashboards/widget/index.ts +++ b/apps/web/src/page/dashboards/widget/index.ts @@ -1,36 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { update } from "./page-update" -import { subscriptions } from "./subscription" -import { askToLeave, init, isBlockingNavigation, routeChanged, widgetOf } from "./update" -import { topbarActions, view } from "./view" /** `/dashboards/$dashboardId/widgets/$widgetId`: one instance per widget. */ -export const page = definePage( +export const entry = definePage( "DashboardWidget", { Model, Message }, - { - routes: ["DashboardWidget"], - key: (route) => `${route.dashboardId}/${route.widgetId}`, - init, - update, - routeChanged, - view, - topbarActions, - subscriptions, - isBlockingNavigation, - blockedNavigation: (model, url) => askToLeave(model, url), - breadcrumbs: (model) => - model.document !== null && widgetOf(model) !== null - ? [ - { label: "Dashboards", href: "/dashboards" }, - { label: model.document.name, href: `/dashboards/${model.dashboardId}` }, - { label: "Configure widget" }, - ] - : [ - { label: "Dashboards", href: "/dashboards" }, - { label: model.status === "Loading" ? "Loading…" : "Not found" }, - ], - }, + { routes: ["DashboardWidget"], key: (route) => `${route.dashboardId}/${route.widgetId}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/dashboards/widget/widget-leave.scene.test.ts b/apps/web/src/page/dashboards/widget/widget-leave.scene.test.ts index 604c6dd57e..a86541e134 100644 --- a/apps/web/src/page/dashboards/widget/widget-leave.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/widget-leave.scene.test.ts @@ -1,16 +1,19 @@ import { OrgId } from "@maple/domain" import { DashboardDocument } from "@maple/domain/http" -import { Option, Schema } from "effect" -import { describe, expect, test } from "vitest" +import { Effect, Option, Schema } from "effect" +import { beforeAll, describe, expect, test } from "vitest" import { AppRoute } from "../../../route" import { PageOutMessage, type PageContext } from "../../contract" -import { blockNavigation, type PageSlot } from "../../registry" +import { blockNavigation, type PageSlot, loadEveryPage } from "../../registry" import { SaveWidget } from "./command" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" import { init } from "./update" +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) + // `useBlocker`: a link, Back or the breadcrumb away from unsaved edits asks first. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -42,7 +45,13 @@ const opened = () => Message.UpdatedDashboard({ orgId, maybeDocument: Option.some(document) }), ]) -const slotOf = (model: Model): PageSlot => ({ _tag: "DashboardWidget", key: "dash_leave/note", orgId, instance: 1, model }) +const slotOf = (model: Model): PageSlot => ({ + _tag: "DashboardWidget", + key: "dash_leave/note", + orgId, + instance: 1, + model, +}) const away = AppRoute.Services({ search: {} }) const isHeld = (model: Model, route: AppRoute = away) => Option.isSome(blockNavigation(slotOf(model), route, "/services", context)) diff --git a/apps/web/src/page/errors/hub/implementation.ts b/apps/web/src/page/errors/hub/implementation.ts new file mode 100644 index 0000000000..61dcbac472 --- /dev/null +++ b/apps/web/src/page/errors/hub/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { subscriptions } from "./subscription" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Errors", isPageHeading: true }], +}) diff --git a/apps/web/src/page/errors/hub/index.ts b/apps/web/src/page/errors/hub/index.ts index 82ce205605..0ad4e9b852 100644 --- a/apps/web/src/page/errors/hub/index.ts +++ b/apps/web/src/page/errors/hub/index.ts @@ -1,22 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { subscriptions } from "./subscription" -import { view } from "./view" /** `/errors`: the issues list. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "ErrorsHub", - { Model, Message }, - { - routes: ["Errors"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Errors", isPageHeading: true }], - }, -) +export const entry = definePage("ErrorsHub", { Model, Message }, { routes: ["Errors"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/errors/issue/implementation.ts b/apps/web/src/page/errors/issue/implementation.ts new file mode 100644 index 0000000000..c7226c740b --- /dev/null +++ b/apps/web/src/page/errors/issue/implementation.ts @@ -0,0 +1,28 @@ +import { AsyncData } from "foldkit" +import { implementPage } from "../../contract" +import { entry } from "./index" +import { Model } from "./model" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +const ERRORS_CRUMB = { label: "Errors", href: "/errors" } as const + +/** The crumb after "Errors": the issue's type once loaded, else what the page is showing. */ +const lastCrumb = (model: Model): string => + AsyncData.matchData(model.detail.data, { + onEmpty: () => "…", + onFailure: (failure) => + failure._tag === "@maple/http/errors/ErrorIssueNotFoundError" ? "Not found" : "Error", + onData: (detail) => detail.exception_type || detail.error_label || "Unlabelled error", + }) + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: (model) => [ERRORS_CRUMB, { label: lastCrumb(model) }], +}) diff --git a/apps/web/src/page/errors/issue/index.ts b/apps/web/src/page/errors/issue/index.ts index f0fb16715d..29b859e878 100644 --- a/apps/web/src/page/errors/issue/index.ts +++ b/apps/web/src/page/errors/issue/index.ts @@ -1,35 +1,12 @@ -import { AsyncData } from "foldkit" import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" - -const ERRORS_CRUMB = { label: "Errors", href: "/errors" } as const - -/** The crumb after "Errors": the issue's type once loaded, else what the page is showing. */ -const lastCrumb = (model: Model): string => - AsyncData.matchData(model.detail.data, { - onEmpty: () => "…", - onFailure: (failure) => - failure._tag === "@maple/http/errors/ErrorIssueNotFoundError" ? "Not found" : "Error", - onData: (detail) => detail.exception_type || detail.error_label || "Unlabelled error", - }) /** `/errors/issues/$issueId`: one instance per issue; tab and window changes arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "ErrorIssue", { Model, Message }, - { - routes: ["ErrorIssue"], - key: (route) => route.issueId, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: (model) => [ERRORS_CRUMB, { label: lastCrumb(model) }], - }, + { routes: ["ErrorIssue"], key: (route) => route.issueId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/aws/list/implementation.ts b/apps/web/src/page/infra-integrations/aws/list/implementation.ts new file mode 100644 index 0000000000..82b5108e00 --- /dev/null +++ b/apps/web/src/page/infra-integrations/aws/list/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("aws"), + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "AWS" }], +}) diff --git a/apps/web/src/page/infra-integrations/aws/list/index.ts b/apps/web/src/page/infra-integrations/aws/list/index.ts index 3ec4f9b3d4..0cc14d2fcd 100644 --- a/apps/web/src/page/infra-integrations/aws/list/index.ts +++ b/apps/web/src/page/infra-integrations/aws/list/index.ts @@ -1,22 +1,8 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" /** `/infra/aws`: the connected accounts' streamed resources, or the on-ramp while none sends any. */ -export const page = definePage( - "InfraAws", - { Model, Message }, - { - routes: ["InfraAws"], - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("aws"), - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "AWS" }], - }, -) +export const entry = definePage("InfraAws", { Model, Message }, { routes: ["InfraAws"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/aws/resource/implementation.ts b/apps/web/src/page/infra-integrations/aws/resource/implementation.ts new file mode 100644 index 0000000000..b5cd48ee15 --- /dev/null +++ b/apps/web/src/page/infra-integrations/aws/resource/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { titleOf, view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("aws"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "AWS", href: "/infra/aws" }, + { label: titleOf(model) }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/aws/resource/index.ts b/apps/web/src/page/infra-integrations/aws/resource/index.ts index c1e784afeb..b819c9370a 100644 --- a/apps/web/src/page/infra-integrations/aws/resource/index.ts +++ b/apps/web/src/page/infra-integrations/aws/resource/index.ts @@ -1,28 +1,16 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { titleOf, view } from "./view" -import { platformSource } from "../../../../shell/source-link" /** `/infra/aws/$kind/$name`: one resource's charts; another resource is a new instance. */ -export const page = definePage( +export const entry = definePage( "InfraAwsResource", { Model, Message }, { routes: ["InfraAwsResource"], key: (route) => `InfraAwsResource:${route.kind}:${route.name}:${route.search.cluster ?? ""}:${route.search.region ?? ""}:${route.search.account ?? ""}`, - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("aws"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "AWS", href: "/infra/aws" }, - { label: titleOf(model) }, - ], }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/cloudflare/list/implementation.ts b/apps/web/src/page/infra-integrations/cloudflare/list/implementation.ts new file mode 100644 index 0000000000..5d13744c86 --- /dev/null +++ b/apps/web/src/page/infra-integrations/cloudflare/list/implementation.ts @@ -0,0 +1,17 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + source: () => platformSource("cloudflare"), + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Cloudflare" }], +}) diff --git a/apps/web/src/page/infra-integrations/cloudflare/list/index.ts b/apps/web/src/page/infra-integrations/cloudflare/list/index.ts index 9e29a3c0de..e6bd1f0595 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/list/index.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/list/index.ts @@ -1,23 +1,8 @@ import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/cloudflare`: zones, Workers and queues, or why there are none yet. */ -export const page = definePage( - "InfraCloudflare", - { Model, Message }, - { - routes: ["InfraCloudflare"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - source: () => platformSource("cloudflare"), - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Cloudflare" }], - }, -) +export const entry = definePage("InfraCloudflare", { Model, Message }, { routes: ["InfraCloudflare"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/cloudflare/worker/implementation.ts b/apps/web/src/page/infra-integrations/cloudflare/worker/implementation.ts new file mode 100644 index 0000000000..dc03db9c08 --- /dev/null +++ b/apps/web/src/page/infra-integrations/cloudflare/worker/implementation.ts @@ -0,0 +1,21 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + source: () => platformSource("cloudflare"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "Cloudflare", href: "/infra/cloudflare?view=workers" }, + { label: model.scriptName }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts b/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts index f5de638f5a..9a73f01709 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts @@ -1,28 +1,12 @@ import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/cloudflare/workers/$scriptName`: one Worker; another script is another instance. */ -export const page = definePage( +export const entry = definePage( "InfraCloudflareWorker", { Model, Message }, - { - routes: ["InfraCloudflareWorker"], - key: (route) => `InfraCloudflareWorker:${route.scriptName}`, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - source: () => platformSource("cloudflare"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "Cloudflare", href: "/infra/cloudflare?view=workers" }, - { label: model.scriptName }, - ], - }, + { routes: ["InfraCloudflareWorker"], key: (route) => `InfraCloudflareWorker:${route.scriptName}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/cloudflare/zone/implementation.ts b/apps/web/src/page/infra-integrations/cloudflare/zone/implementation.ts new file mode 100644 index 0000000000..52c646b436 --- /dev/null +++ b/apps/web/src/page/infra-integrations/cloudflare/zone/implementation.ts @@ -0,0 +1,21 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + source: () => platformSource("cloudflare"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "Cloudflare", href: "/infra/cloudflare" }, + { label: model.zoneName }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts b/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts index 51deed9c1b..f83a31692a 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts @@ -1,28 +1,12 @@ import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/cloudflare/$zoneName`: one zone's traffic, DNS and security; another zone is another instance. */ -export const page = definePage( +export const entry = definePage( "InfraCloudflareZone", { Model, Message }, - { - routes: ["InfraCloudflareZone"], - key: (route) => `InfraCloudflareZone:${route.zoneName}`, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - source: () => platformSource("cloudflare"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "Cloudflare", href: "/infra/cloudflare" }, - { label: model.zoneName }, - ], - }, + { routes: ["InfraCloudflareZone"], key: (route) => `InfraCloudflareZone:${route.zoneName}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/gcp/list/implementation.ts b/apps/web/src/page/infra-integrations/gcp/list/implementation.ts new file mode 100644 index 0000000000..73c97fa25a --- /dev/null +++ b/apps/web/src/page/infra-integrations/gcp/list/implementation.ts @@ -0,0 +1,17 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + source: () => platformSource("gcp"), + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Google Cloud" }], +}) diff --git a/apps/web/src/page/infra-integrations/gcp/list/index.ts b/apps/web/src/page/infra-integrations/gcp/list/index.ts index 717695c955..c052291f45 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/index.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/index.ts @@ -1,23 +1,8 @@ import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/gcp`: the Google Cloud workloads by view, and the resource inventory. */ -export const page = definePage( - "InfraGcp", - { Model, Message }, - { - routes: ["InfraGcp"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - source: () => platformSource("gcp"), - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Google Cloud" }], - }, -) +export const entry = definePage("InfraGcp", { Model, Message }, { routes: ["InfraGcp"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/gcp/workload/implementation.ts b/apps/web/src/page/infra-integrations/gcp/workload/implementation.ts new file mode 100644 index 0000000000..15b6d1aa57 --- /dev/null +++ b/apps/web/src/page/infra-integrations/gcp/workload/implementation.ts @@ -0,0 +1,21 @@ +import { gcpWorkloadName } from "../../../../components/infra/gcp/tabs" +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, keysOf, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("gcp"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + // Back to the tab the workload is listed on. + { label: "Google Cloud", href: `/infra/gcp?tab=${model.service}` }, + { label: gcpWorkloadName(model.service, keysOf(model)) }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/gcp/workload/index.ts b/apps/web/src/page/infra-integrations/gcp/workload/index.ts index 8108b17401..9268c0cc7d 100644 --- a/apps/web/src/page/infra-integrations/gcp/workload/index.ts +++ b/apps/web/src/page/infra-integrations/gcp/workload/index.ts @@ -1,28 +1,12 @@ -import { gcpWorkloadName } from "../../../../components/infra/gcp/tabs" import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { contextChanged, init, keysOf, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/gcp/$service/$name`: one workload, or a Cloud SQL instance; each is its own instance. */ -export const page = definePage( +export const entry = definePage( "InfraGcpResource", { Model, Message }, - { - routes: ["InfraGcpResource"], - key: (route) => `InfraGcpResource:${route.service}:${route.name}`, - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("gcp"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - // Back to the tab the workload is listed on. - { label: "Google Cloud", href: `/infra/gcp?tab=${model.service}` }, - { label: gcpWorkloadName(model.service, keysOf(model)) }, - ], - }, + { routes: ["InfraGcpResource"], key: (route) => `InfraGcpResource:${route.service}:${route.name}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/planetscale/db/implementation.ts b/apps/web/src/page/infra-integrations/planetscale/db/implementation.ts new file mode 100644 index 0000000000..d9c05e3789 --- /dev/null +++ b/apps/web/src/page/infra-integrations/planetscale/db/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("planetscale"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "PlanetScale", href: "/infra/planetscale" }, + { label: model.dbName }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/planetscale/db/index.ts b/apps/web/src/page/infra-integrations/planetscale/db/index.ts index e13cdd2872..dbb58431d9 100644 --- a/apps/web/src/page/infra-integrations/planetscale/db/index.ts +++ b/apps/web/src/page/infra-integrations/planetscale/db/index.ts @@ -1,26 +1,12 @@ import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/planetscale/$dbName`: one database's branches; a new database is a new instance. */ -export const page = definePage( +export const entry = definePage( "InfraPlanetscaleDatabase", { Model, Message }, - { - routes: ["InfraPlanetscaleDatabase"], - key: (route) => `InfraPlanetscaleDatabase:${route.dbName}`, - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("planetscale"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "PlanetScale", href: "/infra/planetscale" }, - { label: model.dbName }, - ], - }, + { routes: ["InfraPlanetscaleDatabase"], key: (route) => `InfraPlanetscaleDatabase:${route.dbName}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/planetscale/list/implementation.ts b/apps/web/src/page/infra-integrations/planetscale/list/implementation.ts new file mode 100644 index 0000000000..2a09f5b745 --- /dev/null +++ b/apps/web/src/page/infra-integrations/planetscale/list/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("planetscale"), + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "PlanetScale" }], +}) diff --git a/apps/web/src/page/infra-integrations/planetscale/list/index.ts b/apps/web/src/page/infra-integrations/planetscale/list/index.ts index 37028ac898..d689d9d7f8 100644 --- a/apps/web/src/page/infra-integrations/planetscale/list/index.ts +++ b/apps/web/src/page/infra-integrations/planetscale/list/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../../../contract" -import { Message, Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" +import { Message } from "./model" +import { Model } from "./model" /** `/infra/planetscale`: the database fleet, or the connect prompt. */ -export const page = definePage( - "InfraPlanetscale", - { Model, Message }, - { - routes: ["InfraPlanetscale"], - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("planetscale"), - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "PlanetScale" }], - }, -) +export const entry = definePage("InfraPlanetscale", { Model, Message }, { routes: ["InfraPlanetscale"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/railway/list/implementation.ts b/apps/web/src/page/infra-integrations/railway/list/implementation.ts new file mode 100644 index 0000000000..68b3b2f477 --- /dev/null +++ b/apps/web/src/page/infra-integrations/railway/list/implementation.ts @@ -0,0 +1,17 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + source: () => platformSource("railway"), + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Railway" }], +}) diff --git a/apps/web/src/page/infra-integrations/railway/list/index.ts b/apps/web/src/page/infra-integrations/railway/list/index.ts index d486582d58..42271898f8 100644 --- a/apps/web/src/page/infra-integrations/railway/list/index.ts +++ b/apps/web/src/page/infra-integrations/railway/list/index.ts @@ -1,24 +1,8 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" /** `/infra/railway`: Railway services, or the connect card while no token is saved. */ -export const page = definePage( - "InfraRailway", - { Model, Message }, - { - routes: ["InfraRailway"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - source: () => platformSource("railway"), - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Railway" }], - }, -) +export const entry = definePage("InfraRailway", { Model, Message }, { routes: ["InfraRailway"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/railway/service/implementation.ts b/apps/web/src/page/infra-integrations/railway/service/implementation.ts new file mode 100644 index 0000000000..6415ba78c3 --- /dev/null +++ b/apps/web/src/page/infra-integrations/railway/service/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { titleOf, view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("railway"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "Railway", href: "/infra/railway" }, + { label: titleOf(model) }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/railway/service/index.ts b/apps/web/src/page/infra-integrations/railway/service/index.ts index 89b3e71aac..26c784ecbe 100644 --- a/apps/web/src/page/infra-integrations/railway/service/index.ts +++ b/apps/web/src/page/infra-integrations/railway/service/index.ts @@ -1,27 +1,12 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { titleOf, view } from "./view" -import { platformSource } from "../../../../shell/source-link" /** `/infra/railway/$serviceId`: one service's usage; a new service id is a new instance. */ -export const page = definePage( +export const entry = definePage( "InfraRailwayService", { Model, Message }, - { - routes: ["InfraRailwayService"], - key: (route) => `InfraRailwayService:${route.serviceId}`, - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("railway"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "Railway", href: "/infra/railway" }, - { label: titleOf(model) }, - ], - }, + { routes: ["InfraRailwayService"], key: (route) => `InfraRailwayService:${route.serviceId}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/supabase/list/implementation.ts b/apps/web/src/page/infra-integrations/supabase/list/implementation.ts new file mode 100644 index 0000000000..894fbdd77c --- /dev/null +++ b/apps/web/src/page/infra-integrations/supabase/list/implementation.ts @@ -0,0 +1,17 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + source: () => platformSource("supabase"), + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Supabase" }], +}) diff --git a/apps/web/src/page/infra-integrations/supabase/list/index.ts b/apps/web/src/page/infra-integrations/supabase/list/index.ts index 44d82ea5d6..9113b50536 100644 --- a/apps/web/src/page/infra-integrations/supabase/list/index.ts +++ b/apps/web/src/page/infra-integrations/supabase/list/index.ts @@ -1,24 +1,8 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { platformSource } from "../../../../shell/source-link" /** `/infra/supabase`: the organization's projects, or the connect on-ramp while none is connected. */ -export const page = definePage( - "InfraSupabase", - { Model, Message }, - { - routes: ["InfraSupabase"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - source: () => platformSource("supabase"), - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Supabase" }], - }, -) +export const entry = definePage("InfraSupabase", { Model, Message }, { routes: ["InfraSupabase"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra-integrations/supabase/project/implementation.ts b/apps/web/src/page/infra-integrations/supabase/project/implementation.ts new file mode 100644 index 0000000000..c6d50e210b --- /dev/null +++ b/apps/web/src/page/infra-integrations/supabase/project/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { titleOf, view } from "./view" +import { platformSource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: () => platformSource("supabase"), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "Supabase", href: "/infra/supabase" }, + { label: titleOf(model) }, + ], +}) diff --git a/apps/web/src/page/infra-integrations/supabase/project/index.ts b/apps/web/src/page/infra-integrations/supabase/project/index.ts index c8f93e238e..08d566968c 100644 --- a/apps/web/src/page/infra-integrations/supabase/project/index.ts +++ b/apps/web/src/page/infra-integrations/supabase/project/index.ts @@ -1,27 +1,12 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { titleOf, view } from "./view" -import { platformSource } from "../../../../shell/source-link" /** `/infra/supabase/$projectRef`: one project's charts; a new ref is a new instance. */ -export const page = definePage( +export const entry = definePage( "InfraSupabaseProject", { Model, Message }, - { - routes: ["InfraSupabaseProject"], - key: (route) => `InfraSupabaseProject:${route.projectRef}`, - init, - update, - routeChanged, - contextChanged, - view, - source: () => platformSource("supabase"), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "Supabase", href: "/infra/supabase" }, - { label: titleOf(model) }, - ], - }, + { routes: ["InfraSupabaseProject"], key: (route) => `InfraSupabaseProject:${route.projectRef}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/containers/detail/implementation.ts b/apps/web/src/page/infra/containers/detail/implementation.ts new file mode 100644 index 0000000000..154144d726 --- /dev/null +++ b/apps/web/src/page/infra/containers/detail/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { summarySource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: (model) => summarySource(model.summary.data), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "Containers", href: "/infra/containers" }, + { label: model.containerName }, + ], +}) diff --git a/apps/web/src/page/infra/containers/detail/index.ts b/apps/web/src/page/infra/containers/detail/index.ts index 61ce356e49..fd1cc7aa97 100644 --- a/apps/web/src/page/infra/containers/detail/index.ts +++ b/apps/web/src/page/infra/containers/detail/index.ts @@ -1,27 +1,12 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { summarySource } from "../../../../shell/source-link" /** `/infra/containers/$containerName`: one instance per container name; a window change arrives as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraContainer", { Model, Message }, - { - routes: ["InfraContainer"], - key: (route) => route.containerName, - init, - update, - routeChanged, - contextChanged, - view, - source: (model) => summarySource(model.summary.data), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "Containers", href: "/infra/containers" }, - { label: model.containerName }, - ], - }, + { routes: ["InfraContainer"], key: (route) => route.containerName }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/containers/list/implementation.ts b/apps/web/src/page/infra/containers/list/implementation.ts new file mode 100644 index 0000000000..ca570dfcd0 --- /dev/null +++ b/apps/web/src/page/infra/containers/list/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Containers" }], +}) diff --git a/apps/web/src/page/infra/containers/list/index.ts b/apps/web/src/page/infra/containers/list/index.ts index 33dab48277..6aafdb1de7 100644 --- a/apps/web/src/page/infra/containers/list/index.ts +++ b/apps/web/src/page/infra/containers/list/index.ts @@ -1,20 +1,8 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/infra/containers`: the container fleet. Search changes arrive as `routeChanged`. */ -export const page = definePage( - "InfraContainers", - { Model, Message }, - { - routes: ["InfraContainers"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Containers" }], - }, -) +export const entry = definePage("InfraContainers", { Model, Message }, { routes: ["InfraContainers"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/hosts/detail/implementation.ts b/apps/web/src/page/infra/hosts/detail/implementation.ts new file mode 100644 index 0000000000..ed0707e9bb --- /dev/null +++ b/apps/web/src/page/infra/hosts/detail/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { summarySource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: (model) => summarySource(model.summary.data), + breadcrumbs: (model) => [ + { label: "Infrastructure", href: "/infra" }, + { label: "Hosts", href: "/infra/hosts" }, + { label: model.hostName }, + ], +}) diff --git a/apps/web/src/page/infra/hosts/detail/index.ts b/apps/web/src/page/infra/hosts/detail/index.ts index 05a28d3f1c..644c1a4fdc 100644 --- a/apps/web/src/page/infra/hosts/detail/index.ts +++ b/apps/web/src/page/infra/hosts/detail/index.ts @@ -1,27 +1,12 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { summarySource } from "../../../../shell/source-link" /** `/infra/hosts/$hostName`: one instance per host; a window change arrives as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraHost", { Model, Message }, - { - routes: ["InfraHost"], - key: (route) => route.hostName, - init, - update, - routeChanged, - contextChanged, - view, - source: (model) => summarySource(model.summary.data), - breadcrumbs: (model) => [ - { label: "Infrastructure", href: "/infra" }, - { label: "Hosts", href: "/infra/hosts" }, - { label: model.hostName }, - ], - }, + { routes: ["InfraHost"], key: (route) => route.hostName }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/hosts/list/implementation.ts b/apps/web/src/page/infra/hosts/list/implementation.ts new file mode 100644 index 0000000000..eea9f9484f --- /dev/null +++ b/apps/web/src/page/infra/hosts/list/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Hosts" }], +}) diff --git a/apps/web/src/page/infra/hosts/list/index.ts b/apps/web/src/page/infra/hosts/list/index.ts index a4ad8c05f7..eafe147d86 100644 --- a/apps/web/src/page/infra/hosts/list/index.ts +++ b/apps/web/src/page/infra/hosts/list/index.ts @@ -1,20 +1,8 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/infra/hosts`: the host fleet. Search changes arrive as `routeChanged`. */ -export const page = definePage( - "InfraHosts", - { Model, Message }, - { - routes: ["InfraHosts"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Infrastructure", href: "/infra" }, { label: "Hosts" }], - }, -) +export const entry = definePage("InfraHosts", { Model, Message }, { routes: ["InfraHosts"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/node/implementation.ts b/apps/web/src/page/infra/kubernetes/node/implementation.ts new file mode 100644 index 0000000000..cfcf65c1e7 --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/node/implementation.ts @@ -0,0 +1,16 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { summarySource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: (model) => summarySource(model.summary.data), + breadcrumbs: (model) => kubernetesBreadcrumbs("nodes", model.search, [{ label: model.nodeName }]), +}) diff --git a/apps/web/src/page/infra/kubernetes/node/index.ts b/apps/web/src/page/infra/kubernetes/node/index.ts index 5646a30616..85e1968e8a 100644 --- a/apps/web/src/page/infra/kubernetes/node/index.ts +++ b/apps/web/src/page/infra/kubernetes/node/index.ts @@ -1,24 +1,12 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { summarySource } from "../../../../shell/source-link" /** `/infra/kubernetes/nodes/$nodeName`: one instance per node; a window change arrives as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesNode", { Model, Message }, - { - routes: ["InfraKubernetesNode"], - key: (route) => route.nodeName, - init, - update, - routeChanged, - contextChanged, - view, - source: (model) => summarySource(model.summary.data), - breadcrumbs: (model) => kubernetesBreadcrumbs("nodes", model.search, [{ label: model.nodeName }]), - }, + { routes: ["InfraKubernetesNode"], key: (route) => route.nodeName }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/nodes/implementation.ts b/apps/web/src/page/infra/kubernetes/nodes/implementation.ts new file mode 100644 index 0000000000..0d8865a692 --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/nodes/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: (model) => kubernetesBreadcrumbs("nodes", model.search), +}) diff --git a/apps/web/src/page/infra/kubernetes/nodes/index.ts b/apps/web/src/page/infra/kubernetes/nodes/index.ts index 633c52f48d..a1b91f3c8e 100644 --- a/apps/web/src/page/infra/kubernetes/nodes/index.ts +++ b/apps/web/src/page/infra/kubernetes/nodes/index.ts @@ -1,21 +1,12 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/infra/kubernetes/nodes`: the node fleet. Search changes arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesNodes", { Model, Message }, - { - routes: ["InfraKubernetesNodes"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: (model) => kubernetesBreadcrumbs("nodes", model.search), - }, + { routes: ["InfraKubernetesNodes"] }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/pod/implementation.ts b/apps/web/src/page/infra/kubernetes/pod/implementation.ts new file mode 100644 index 0000000000..22701dda5a --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/pod/implementation.ts @@ -0,0 +1,16 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { summarySource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: (model) => summarySource(model.summary.data), + breadcrumbs: (model) => kubernetesBreadcrumbs("pods", model.search, [{ label: model.podName }]), +}) diff --git a/apps/web/src/page/infra/kubernetes/pod/index.ts b/apps/web/src/page/infra/kubernetes/pod/index.ts index 94d7939258..ee3004be03 100644 --- a/apps/web/src/page/infra/kubernetes/pod/index.ts +++ b/apps/web/src/page/infra/kubernetes/pod/index.ts @@ -1,24 +1,12 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { summarySource } from "../../../../shell/source-link" /** `/infra/kubernetes/pods/$podName`: one instance per pod name; the window and namespace arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesPod", { Model, Message }, - { - routes: ["InfraKubernetesPod"], - key: (route) => route.podName, - init, - update, - routeChanged, - contextChanged, - view, - source: (model) => summarySource(model.summary.data), - breadcrumbs: (model) => kubernetesBreadcrumbs("pods", model.search, [{ label: model.podName }]), - }, + { routes: ["InfraKubernetesPod"], key: (route) => route.podName }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/pods/implementation.ts b/apps/web/src/page/infra/kubernetes/pods/implementation.ts new file mode 100644 index 0000000000..b21c34e67e --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/pods/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: (model) => kubernetesBreadcrumbs("pods", model.search), +}) diff --git a/apps/web/src/page/infra/kubernetes/pods/index.ts b/apps/web/src/page/infra/kubernetes/pods/index.ts index 66deb7c102..2de14a52c6 100644 --- a/apps/web/src/page/infra/kubernetes/pods/index.ts +++ b/apps/web/src/page/infra/kubernetes/pods/index.ts @@ -1,21 +1,12 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/infra/kubernetes/pods`: the pod fleet. Search changes (and the peek) arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesPods", { Model, Message }, - { - routes: ["InfraKubernetesPods"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: (model) => kubernetesBreadcrumbs("pods", model.search), - }, + { routes: ["InfraKubernetesPods"] }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/service/implementation.ts b/apps/web/src/page/infra/kubernetes/service/implementation.ts new file mode 100644 index 0000000000..5791194074 --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/service/implementation.ts @@ -0,0 +1,19 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: (model) => + kubernetesBreadcrumbs( + "services", + model.search, + model.serviceName === null ? [] : [{ label: model.serviceName }], + ), +}) diff --git a/apps/web/src/page/infra/kubernetes/service/index.ts b/apps/web/src/page/infra/kubernetes/service/index.ts index 8135e1ca06..0296596f19 100644 --- a/apps/web/src/page/infra/kubernetes/service/index.ts +++ b/apps/web/src/page/infra/kubernetes/service/index.ts @@ -1,26 +1,15 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** * `/infra/kubernetes/services` and `/infra/kubernetes/services/$serviceName`: the service lens. One * instance spans both, so picking a service keeps the rail. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesServiceLens", { Model, Message }, - { - routes: ["InfraKubernetesServices", "InfraKubernetesService"], - key: () => "lens", - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: (model) => - kubernetesBreadcrumbs("services", model.search, model.serviceName === null ? [] : [{ label: model.serviceName }]), - }, + { routes: ["InfraKubernetesServices", "InfraKubernetesService"], key: () => "lens" }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/workload/implementation.ts b/apps/web/src/page/infra/kubernetes/workload/implementation.ts new file mode 100644 index 0000000000..9a15f4fad2 --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/workload/implementation.ts @@ -0,0 +1,16 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" +import { summarySource } from "../../../../shell/source-link" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + source: (model) => summarySource(model.summary.data), + breadcrumbs: (model) => kubernetesBreadcrumbs("workloads", model.search, [{ label: model.workloadName }]), +}) diff --git a/apps/web/src/page/infra/kubernetes/workload/index.ts b/apps/web/src/page/infra/kubernetes/workload/index.ts index aeb75463c4..ecc1eb5661 100644 --- a/apps/web/src/page/infra/kubernetes/workload/index.ts +++ b/apps/web/src/page/infra/kubernetes/workload/index.ts @@ -1,25 +1,12 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" -import { summarySource } from "../../../../shell/source-link" /** `/infra/kubernetes/workloads/$kind/$workloadName`: one instance per workload; the window arrives as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesWorkload", { Model, Message }, - { - routes: ["InfraKubernetesWorkload"], - key: (route) => `${route.kind}/${route.workloadName}`, - init, - update, - routeChanged, - contextChanged, - view, - source: (model) => summarySource(model.summary.data), - breadcrumbs: (model) => - kubernetesBreadcrumbs("workloads", model.search, [{ label: model.workloadName }]), - }, + { routes: ["InfraKubernetesWorkload"], key: (route) => `${route.kind}/${route.workloadName}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/kubernetes/workloads/implementation.ts b/apps/web/src/page/infra/kubernetes/workloads/implementation.ts new file mode 100644 index 0000000000..c9bc783ba0 --- /dev/null +++ b/apps/web/src/page/infra/kubernetes/workloads/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../../contract" +import { entry } from "./index" +import { kubernetesBreadcrumbs } from "../../kit/view-tabs" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: (model) => kubernetesBreadcrumbs("workloads", model.search), +}) diff --git a/apps/web/src/page/infra/kubernetes/workloads/index.ts b/apps/web/src/page/infra/kubernetes/workloads/index.ts index 1df320697b..1ba75ba801 100644 --- a/apps/web/src/page/infra/kubernetes/workloads/index.ts +++ b/apps/web/src/page/infra/kubernetes/workloads/index.ts @@ -1,21 +1,12 @@ import { definePage } from "../../../contract" -import { kubernetesBreadcrumbs } from "../../kit/view-tabs" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/infra/kubernetes/workloads`: the workloads of one kind. Search changes arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "InfraKubernetesWorkloads", { Model, Message }, - { - routes: ["InfraKubernetesWorkloads"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: (model) => kubernetesBreadcrumbs("workloads", model.search), - }, + { routes: ["InfraKubernetesWorkloads"] }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/infra/overview/implementation.ts b/apps/web/src/page/infra/overview/implementation.ts new file mode 100644 index 0000000000..715d5b5858 --- /dev/null +++ b/apps/web/src/page/infra/overview/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, infraSurfacesSettled, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + infraSurfacesSettled, + view, + breadcrumbs: () => [{ label: "Infrastructure", isPageHeading: true }], +}) diff --git a/apps/web/src/page/infra/overview/index.ts b/apps/web/src/page/infra/overview/index.ts index 8c38136082..4552ec1c20 100644 --- a/apps/web/src/page/infra/overview/index.ts +++ b/apps/web/src/page/infra/overview/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, infraSurfacesSettled, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/infra`: every source's health, what needs a look first. */ -export const page = definePage( - "InfraOverview", - { Model, Message }, - { - routes: ["Infra"], - init, - update, - routeChanged, - contextChanged, - infraSurfacesSettled, - view, - breadcrumbs: () => [{ label: "Infrastructure", isPageHeading: true }], - }, -) +export const entry = definePage("InfraOverview", { Model, Message }, { routes: ["Infra"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/instance.scene.test.ts b/apps/web/src/page/instance.scene.test.ts index 31ae2b8c9d..aa702b34ad 100644 --- a/apps/web/src/page/instance.scene.test.ts +++ b/apps/web/src/page/instance.scene.test.ts @@ -1,11 +1,14 @@ import { DashboardId, OrgId } from "@maple/domain" import { DashboardDocument } from "@maple/domain/http" -import { Option, Result, Schema } from "effect" -import { describe, expect, test } from "vitest" +import { Effect, Option, Result, Schema } from "effect" +import { beforeAll, describe, expect, test } from "vitest" import { AppRoute } from "../route" import type { PageContext } from "./contract" import { Message } from "./dashboards/view/message" -import { enterRoute, PageMessage, type PageSlot, updatePage } from "./registry" +import { enterRoute, PageMessage, type PageSlot, updatePage, loadEveryPage } from "./registry" + +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) const orgId = Schema.decodeSync(OrgId)("org_acme") const otherOrgId = Schema.decodeSync(OrgId)("org_other") @@ -32,7 +35,10 @@ const documentOf = (id: string) => }) const routeOf = (id: string, isEdit: boolean) => - AppRoute.Dashboard({ dashboardId: Schema.decodeSync(DashboardId)(id), search: isEdit ? { mode: "edit" } : {} }) + AppRoute.Dashboard({ + dashboardId: Schema.decodeSync(DashboardId)(id), + search: isEdit ? { mode: "edit" } : {}, + }) const stamp = (key: string, instance: number, message: Message, org: string = orgId): PageMessage => ({ _tag: "DashboardView", @@ -46,9 +52,15 @@ const send = (slot: PageSlot | null, message: PageMessage) => updatePage(slot, m /** Dashboard `id` entered in edit mode as build `instance`, clocked and holding its document. */ const opened = (current: PageSlot | null, id: string, instance: number): PageSlot | null => - [Message.CompletedReadClock({ nowMs: NOW }), Message.UpdatedDashboard({ orgId, maybeDocument: Option.some(documentOf(id)) })] + [ + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDashboard({ orgId, maybeDocument: Option.some(documentOf(id)) }), + ] .map((message) => stamp(id, instance, message)) - .reduce((slot, message) => send(slot, message).slot, enterRoute(current, routeOf(id, true), context, instance).slot) + .reduce( + (slot, message) => send(slot, message).slot, + enterRoute(current, routeOf(id, true), context, instance).slot, + ) const deleteTile = (slot: PageSlot | null, id: string, instance: number) => send( @@ -86,13 +98,19 @@ describe("page instances", () => { test("dashboard A's late failed write does not make B read-only", () => { const onB = opened(opened(null, "dash_a", 1), "dash_b", 2) - const late = send(onB, stamp("dash_a", 1, Message.CompletedWrite({ result: Result.fail("Write failed") }))) + const late = send( + onB, + stamp("dash_a", 1, Message.CompletedWrite({ result: Result.fail("Write failed") })), + ) expect(late.slot?._tag === "DashboardView" ? late.slot.model.persistenceError : "missing").toBeNull() }) test("a Message stamped for another org's instance of the same dashboard is dropped", () => { const onA = opened(null, "dash_a", 1) - const late = send(onA, stamp("dash_a", 1, Message.CompletedWrite({ result: Result.fail("Write failed") }), otherOrgId)) + const late = send( + onA, + stamp("dash_a", 1, Message.CompletedWrite({ result: Result.fail("Write failed") }), otherOrgId), + ) expect(late.slot).toBe(onA) }) diff --git a/apps/web/src/page/integrations/implementation.ts b/apps/web/src/page/integrations/implementation.ts new file mode 100644 index 0000000000..1327fa9cb1 --- /dev/null +++ b/apps/web/src/page/integrations/implementation.ts @@ -0,0 +1,25 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { catalogEntry, integrationOfSearch } from "./catalog" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: (model) => { + const integration = integrationOfSearch(model.search.integration) + return integration === null + ? [{ label: "Settings", href: "/settings" }, { label: "Integrations" }] + : [ + { label: "Settings", href: "/settings" }, + { label: "Integrations", href: "/integrations" }, + { label: catalogEntry(integration).name }, + ] + }, +}) diff --git a/apps/web/src/page/integrations/index.ts b/apps/web/src/page/integrations/index.ts index 4ac4428275..86663533b8 100644 --- a/apps/web/src/page/integrations/index.ts +++ b/apps/web/src/page/integrations/index.ts @@ -1,32 +1,8 @@ import { definePage } from "../contract" -import { catalogEntry, integrationOfSearch } from "./catalog" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/integrations`: the hub, or one integration's page (`?integration=`). One instance for both. */ -export const page = definePage( - "Integrations", - { Model, Message }, - { - routes: ["Integrations"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: (model) => { - const integration = integrationOfSearch(model.search.integration) - return integration === null - ? [{ label: "Settings", href: "/settings" }, { label: "Integrations" }] - : [ - { label: "Settings", href: "/settings" }, - { label: "Integrations", href: "/integrations" }, - { label: catalogEntry(integration).name }, - ] - }, - }, -) +export const entry = definePage("Integrations", { Model, Message }, { routes: ["Integrations"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/investigations/detail/implementation.ts b/apps/web/src/page/investigations/detail/implementation.ts new file mode 100644 index 0000000000..92acffd393 --- /dev/null +++ b/apps/web/src/page/investigations/detail/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/investigations/detail/index.ts b/apps/web/src/page/investigations/detail/index.ts index e169e94fee..14e61f9141 100644 --- a/apps/web/src/page/investigations/detail/index.ts +++ b/apps/web/src/page/investigations/detail/index.ts @@ -1,23 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" /** `/investigations/$id`: one instance per investigation; the tab and open action are search state. */ -export const page = definePage( +export const entry = definePage( "Investigation", { Model, Message }, - { - routes: ["Investigation"], - key: (route) => route.id, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["Investigation"], key: (route) => route.id }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/investigations/list/implementation.ts b/apps/web/src/page/investigations/list/implementation.ts new file mode 100644 index 0000000000..b4907e558a --- /dev/null +++ b/apps/web/src/page/investigations/list/implementation.ts @@ -0,0 +1,12 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + breadcrumbs: () => [{ label: "Investigations", isPageHeading: true }], +}) diff --git a/apps/web/src/page/investigations/list/index.ts b/apps/web/src/page/investigations/list/index.ts index 0deb1e72de..2bb44e5ead 100644 --- a/apps/web/src/page/investigations/list/index.ts +++ b/apps/web/src/page/investigations/list/index.ts @@ -1,19 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view } from "./view" /** `/investigations`: one instance; a search change arrives as `routeChanged`. */ -export const page = definePage( - "InvestigationsList", - { Model, Message }, - { - routes: ["Investigations"], - init, - update, - routeChanged, - view, - breadcrumbs: () => [{ label: "Investigations", isPageHeading: true }], - }, -) +export const entry = definePage("InvestigationsList", { Model, Message }, { routes: ["Investigations"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/logs/list/implementation.ts b/apps/web/src/page/logs/list/implementation.ts new file mode 100644 index 0000000000..9f91a9a06a --- /dev/null +++ b/apps/web/src/page/logs/list/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Logs", isPageHeading: true }], +}) diff --git a/apps/web/src/page/logs/list/index.ts b/apps/web/src/page/logs/list/index.ts index d61bdef14f..cafeb98812 100644 --- a/apps/web/src/page/logs/list/index.ts +++ b/apps/web/src/page/logs/list/index.ts @@ -1,22 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/logs`: the logs stream. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "LogsList", - { Model, Message }, - { - routes: ["Logs"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Logs", isPageHeading: true }], - }, -) +export const entry = definePage("LogsList", { Model, Message }, { routes: ["Logs"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/logs/log/implementation.ts b/apps/web/src/page/logs/log/implementation.ts new file mode 100644 index 0000000000..2eb4547250 --- /dev/null +++ b/apps/web/src/page/logs/log/implementation.ts @@ -0,0 +1,11 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + view, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/logs/log/index.ts b/apps/web/src/page/logs/log/index.ts index be2a84027f..e346ea79f0 100644 --- a/apps/web/src/page/logs/log/index.ts +++ b/apps/web/src/page/logs/log/index.ts @@ -1,18 +1,8 @@ import { definePage } from "../../contract" -import { Message, Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" +import { Message } from "./model" +import { Model } from "./model" /** `/logs/$logId`: one instance per log key, so picking another log mounts a fresh page. */ -export const page = definePage( - "Log", - { Model, Message }, - { - routes: ["Log"], - key: (route) => route.logId, - init, - update, - view, - breadcrumbs: breadcrumbsOf, - }, -) +export const entry = definePage("Log", { Model, Message }, { routes: ["Log"], key: (route) => route.logId }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/metrics/browse/implementation.ts b/apps/web/src/page/metrics/browse/implementation.ts new file mode 100644 index 0000000000..fef3b0b8d8 --- /dev/null +++ b/apps/web/src/page/metrics/browse/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Metrics", isPageHeading: true }], +}) diff --git a/apps/web/src/page/metrics/browse/index.ts b/apps/web/src/page/metrics/browse/index.ts index ed3bd2e361..7a807f5438 100644 --- a/apps/web/src/page/metrics/browse/index.ts +++ b/apps/web/src/page/metrics/browse/index.ts @@ -1,20 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/metrics`: the metric explorer. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "MetricsBrowse", - { Model, Message }, - { - routes: ["Metrics"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Metrics", isPageHeading: true }], - }, -) +export const entry = definePage("MetricsBrowse", { Model, Message }, { routes: ["Metrics"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/metrics/detail/implementation.ts b/apps/web/src/page/metrics/detail/implementation.ts new file mode 100644 index 0000000000..601f5a6e3c --- /dev/null +++ b/apps/web/src/page/metrics/detail/implementation.ts @@ -0,0 +1,18 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { backToMetricsHref, contextChanged, init, routeChanged, update } from "./update" +import { subscriptions } from "./subscription" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: (model) => [ + { label: "Metrics", href: backToMetricsHref(model.search) }, + { label: model.metricName }, + ], +}) diff --git a/apps/web/src/page/metrics/detail/index.ts b/apps/web/src/page/metrics/detail/index.ts index a78f277b89..f6aaa7e8a8 100644 --- a/apps/web/src/page/metrics/detail/index.ts +++ b/apps/web/src/page/metrics/detail/index.ts @@ -1,26 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { backToMetricsHref, contextChanged, init, routeChanged, update } from "./update" -import { subscriptions } from "./subscription" -import { view } from "./view" /** `/metrics/$metricName`: one instance per metric; query changes arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "MetricDetail", { Model, Message }, - { - routes: ["Metric"], - key: (route) => `Metric:${route.metricName}`, - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: (model) => [ - { label: "Metrics", href: backToMetricsHref(model.search) }, - { label: model.metricName }, - ], - }, + { routes: ["Metric"], key: (route) => `Metric:${route.metricName}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/overview/implementation.ts b/apps/web/src/page/overview/implementation.ts new file mode 100644 index 0000000000..1705d5e53d --- /dev/null +++ b/apps/web/src/page/overview/implementation.ts @@ -0,0 +1,16 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, revive, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + revive, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Overview", isPageHeading: true }], +}) diff --git a/apps/web/src/page/overview/index.ts b/apps/web/src/page/overview/index.ts index 4f26f48957..ce5d0cfe94 100644 --- a/apps/web/src/page/overview/index.ts +++ b/apps/web/src/page/overview/index.ts @@ -1,23 +1,8 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, revive, routeChanged, update } from "./update" -import { view } from "./view" /** `/`: the Overview home page. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "Overview", - { Model, Message }, - { - routes: ["Home"], - init, - update, - routeChanged, - revive, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Overview", isPageHeading: true }], - }, -) +export const entry = definePage("Overview", { Model, Message }, { routes: ["Home"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/preload.test.ts b/apps/web/src/page/preload.test.ts index 5f3f23fa99..e11e232806 100644 --- a/apps/web/src/page/preload.test.ts +++ b/apps/web/src/page/preload.test.ts @@ -1,11 +1,14 @@ import { OrgId } from "@maple/domain" -import { Option, Schema } from "effect" -import { describe, expect, test } from "vitest" +import { Effect, Option, Schema } from "effect" +import { beforeAll, describe, expect, test } from "vitest" import type { PageContext } from "./contract" import * as Preload from "./preload" -import { pageIdentity, pageSlotId, preloadRoute } from "./registry" +import { pageIdentity, pageSlotId, preloadRoute, loadEveryPage } from "./registry" import { AppRoute } from "../route" +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) + const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } const TRACE = "6843a8853182b32339b2dd5120d423f2" @@ -43,7 +46,9 @@ describe("intent preload", () => { const nextWindow = preloadRoute(AppRoute.Trace({ traceId: "a".repeat(32), search: {} }), context, 0) const nextKey = Option.getOrThrow(Preload.keyOfCommand(nextWindow[0]!)) const entered = Preload.held(Preload.init(), holder, preloaded(), () => [keyOf()]) - const moved = Preload.held(entered.model, holder, nextWindow, () => Preload.liveKeysOf({ slot: { key: nextKey, data: { _tag: "Loading" } } })) + const moved = Preload.held(entered.model, holder, nextWindow, () => + Preload.liveKeysOf({ slot: { key: nextKey, data: { _tag: "Loading" } } }), + ) expect(moved.released).toEqual([keyOf()]) expect(moved.model.heldKeys).toEqual([nextKey]) const stillShown = Preload.held(entered.model, holder, nextWindow, () => [keyOf(), nextKey]) diff --git a/apps/web/src/page/recommendations/implementation.ts b/apps/web/src/page/recommendations/implementation.ts new file mode 100644 index 0000000000..ad5fabc255 --- /dev/null +++ b/apps/web/src/page/recommendations/implementation.ts @@ -0,0 +1,11 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + view, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/recommendations/index.ts b/apps/web/src/page/recommendations/index.ts index 8ff22fa8d5..dcc8630535 100644 --- a/apps/web/src/page/recommendations/index.ts +++ b/apps/web/src/page/recommendations/index.ts @@ -1,19 +1,12 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" /** `/recommendations/$recommendationKey`: one instance per recommendation. */ -export const page = definePage( +export const entry = definePage( "Recommendation", { Model, Message }, - { - routes: ["Recommendation"], - key: (route) => route.recommendationKey, - init, - update, - view, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["Recommendation"], key: (route) => route.recommendationKey }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 905ede7194..309b75782d 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -1,4 +1,4 @@ -import { Array, Option, Predicate, Schema } from "effect" +import { Array, Effect, Option, Predicate, Schema, Stream } from "effect" import { Subscription } from "foldkit" import type { Command } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -6,14 +6,16 @@ import type { Resources } from "../platform/resources" import type { AppRoute } from "../route" import type { BreadcrumbEntry } from "../shell" import type { PageSourceRef } from "../shell/source-link" -import type { - PageContext, - PageHost, - PageMessageBase, - PageOutMessage, - PageSlotBase, - PageStep, - PageViewInputs, +import { + PageSubscriptionDependencies, + type PageContext, + type PageHost, + type PageImplementation, + type PageMessageBase, + type PageOutMessage, + type PageSlotBase, + type PageStep, + type PageViewInputs, } from "./contract" import * as AgentSessionsList from "./agent-sessions/list" import * as AgentSession from "./agent-sessions/session" @@ -93,6 +95,7 @@ import * as TracesList from "./traces/list" /** * Every page, registered once. Adding a page: import its module and append `X.page` below; * that is the only shared file a page touches. Shell routes without a page render the placeholder. + * Only each page's schemas, routes and key load with the app; its code loads on first use. */ export const pages = [ ServicesList.page, @@ -182,6 +185,70 @@ export type PageSlot = typeof PageSlot.Type export const PageMessage = Schema.Union(pages.map((page) => page.Wrapped)) export type PageMessage = typeof PageMessage.Type +type LoadedPage = PageImplementation + +/** + * Page code loaded so far, by id. `loadPageCode` fills it before its Message is dispatched, and + * `update` and `view` read it: a page's slot exists only once its code is here (s2 "Lazy view registry"). + */ +const loadedPages = new Map() + +const loadedPage = (id: string): Option.Option => Option.fromNullishOr(loadedPages.get(id)) + +const pageOfRoute = (route: AppRoute) => Array.findFirst(pages, (page) => Option.isSome(page.keyOf(route))) + +/** The page rendering `route` while its code is not loaded: the root loads it, then enters. */ +export const unloadedPageOf = (route: AppRoute): Option.Option => + Option.filter( + Option.map(pageOfRoute(route), (page) => page.id), + (id) => !loadedPages.has(id), + ) + +export class PageLoadError extends Schema.TaggedError()("@maple/web/PageLoadError", { + message: Schema.String, + pageId: Schema.String, + cause: Schema.Defect(), +}) {} + +/** More than any page declares: a page's i-th Subscription runs in slot i while it is open. */ +const PAGE_SUBSCRIPTION_SLOTS = 24 + +/** Imports a page's code once; a loaded page answers at once. */ +export const loadPageCode = (id: string): Effect.Effect => + Option.match( + Array.findFirst(pages, (page) => page.id === id && !loadedPages.has(id)), + { + onNone: () => Effect.void, + onSome: (page) => + Effect.tryPromise({ + try: (): Promise => page.load(), + catch: (cause) => + new PageLoadError({ message: `The ${id} page failed to load`, pageId: id, cause }), + }).pipe( + Effect.flatMap((implementation) => + implementation.subscriptions.length > PAGE_SUBSCRIPTION_SLOTS + ? Effect.fail( + new PageLoadError({ + message: `The ${id} page has more Subscriptions than PAGE_SUBSCRIPTION_SLOTS`, + pageId: id, + cause: undefined, + }), + ) + : Effect.sync(() => { + loadedPages.set(id, implementation) + }), + ), + ), + }, + ) + +/** Every page's code: Scene tests and a dev reload that restores a Model with a page in it. */ +export const loadEveryPage: Effect.Effect = Effect.forEach( + pages, + (page) => loadPageCode(page.id), + { concurrency: "unbounded", discard: true }, +) + /** The page slot after a transition, with the Commands and OutMessage that came with it. */ export interface PageTransition { readonly slot: PageSlot | null @@ -198,8 +265,6 @@ const unchanged = (slot: PageSlot | null): PageTransition => ({ const toTransition = (step: Option.Option>, fallback: PageSlot | null) => Option.match(step, { onNone: () => unchanged(fallback), onSome: (found): PageTransition => found }) -const pageById = (id: string) => Array.findFirst(pages, (page) => page.id === id) - /** * Route entry: keep the instance when its key and org are unchanged (it gets `routeChanged`), * otherwise revive a parked instance of the route's page or build it, as instance `nextInstance`. @@ -212,20 +277,22 @@ export const enterRoute = ( parked: ReadonlyArray = [], ): PageTransition => Option.match( - Array.findFirst(pages, (page) => Option.isSome(page.keyOf(route))), + Option.flatMap(pageOfRoute(route), (entry) => + Option.map(loadedPage(entry.id), (page) => ({ entry, page })), + ), { onNone: () => unchanged(null), - onSome: (page) => { + onSome: ({ entry, page }) => { const isSameInstance = current !== null && - current._tag === page.id && + current._tag === entry.id && current.orgId === context.orgId && - Option.contains(page.keyOf(route), current.key) + Option.contains(entry.keyOf(route), current.key) if (isSameInstance) return toTransition(page.routeChanged(current, route, context), current) const revived = Option.flatMap( Array.findFirst( parked, - (slot) => slot._tag === page.id && Option.contains(page.keyOf(route), slot.key), + (slot) => slot._tag === entry.id && Option.contains(entry.keyOf(route), slot.key), ), (slot): Option.Option> => page.revive(slot, route, context, nextInstance), @@ -269,7 +336,7 @@ export const parkPages = ( const isLeft = left !== null && pageIdentity(left) !== pageIdentity(entered) && - Array.some(pages, (page) => page.id === left._tag && page.isRevivable) && + Option.exists(loadedPage(left._tag), (page) => page.isRevivable) && !hasLoadInFlight(left.model) const sameSlot = (a: PageSlotBase, b: PageSlotBase) => a._tag === b._tag && a.key === b.key return [...(isLeft ? [left] : []), ...parked] @@ -285,7 +352,7 @@ export const preloadRoute = ( nowMs: number, ): ReadonlyArray> => Option.getOrElse( - Array.findFirst(pages, (page) => Option.isSome(page.keyOf(route))).pipe( + Option.flatMap(pageOfRoute(route), (entry) => loadedPage(entry.id)).pipe( Option.flatMap( (page): Option.Option>> => page.preload(route, context, nowMs), @@ -305,7 +372,7 @@ export const updatePage = ( ): PageTransition => current === null ? unchanged(current) - : Option.match(pageById(message._tag), { + : Option.match(loadedPage(message._tag), { onNone: () => unchanged(current), onSome: (page) => toTransition(page.update(current, message, context), current), }) @@ -328,7 +395,7 @@ export const blockNavigation = ( (page) => page.id === current._tag && Option.contains(page.keyOf(route), current.key), ) if (isSameInstance) return Option.none() - return Option.flatMap(pageById(current._tag), (page) => + return Option.flatMap(loadedPage(current._tag), (page) => toHeld(page.blockedNavigation(current, url, context)), ) } @@ -336,7 +403,7 @@ export const blockNavigation = ( export const changeContext = (current: PageSlot | null, context: PageContext): PageTransition => current === null ? unchanged(current) - : Option.match(pageById(current._tag), { + : Option.match(loadedPage(current._tag), { onNone: () => unchanged(current), onSome: (page) => toTransition(page.contextChanged(current, context), current), }) @@ -344,24 +411,91 @@ export const changeContext = (current: PageSlot | null, context: PageContext): P export const settleInfraSurfaces = (current: PageSlot | null, context: PageContext): PageTransition => current === null ? unchanged(current) - : Option.match(pageById(current._tag), { + : Option.match(loadedPage(current._tag), { onNone: () => unchanged(current), onSome: (page) => toTransition(page.infraSurfacesSettled(current, context), current), }) export const pageBreadcrumbs = (slot: PageSlotBase): ReadonlyArray => - Option.match(pageById(slot._tag), { onNone: () => [], onSome: (page) => page.breadcrumbs(slot) }) + Option.match(loadedPage(slot._tag), { onNone: () => [], onSome: (page) => page.breadcrumbs(slot) }) /** The source the page's data comes from, for the top bar's link. */ export const pageSource = (slot: PageSlotBase): PageSourceRef | null => - Option.match(pageById(slot._tag), { onNone: () => null, onSome: (page) => page.source(slot) }) + Option.match(loadedPage(slot._tag), { onNone: () => null, onSome: (page) => page.source(slot) }) /** The page links to its source, so entering it reads the org's sources. */ export const pageHasSource = (slot: PageSlotBase): boolean => - Option.match(pageById(slot._tag), { onNone: () => false, onSome: (page) => page.hasSource }) + Option.match(loadedPage(slot._tag), { onNone: () => false, onSome: (page) => page.hasSource }) + +const SlotInstance = Schema.Struct({ + page: Schema.String, + key: Schema.String, + orgId: Schema.String, + instance: Schema.Number, + inner: PageSubscriptionDependencies, +}) +type SlotInstance = typeof SlotInstance.Type +const SlotDependencies = { maybeInstance: Schema.Option(SlotInstance) } +type SlotDependencies = Schema.Struct.Type + +const subscriptionAt = (id: string, index: number) => + Option.flatMap(loadedPage(id), (page) => Option.fromNullishOr(page.subscriptions[index])) + +const slotInstanceOf = (host: PageHost, index: number): Option.Option => { + const { page: slot, context } = host + if (slot === null || context === null) return Option.none() + return Option.flatMap(subscriptionAt(slot._tag, index), (subscription) => + Option.map(subscription.dependenciesOf(slot, context), (inner) => ({ + page: slot._tag, + key: slot.key, + orgId: slot.orgId, + instance: slot.instance, + inner, + })), + ) +} + +const isSameSlotInstance = (index: number) => + Option.makeEquivalence( + (left, right) => + left.page === right.page && + left.key === right.key && + left.orgId === right.orgId && + left.instance === right.instance && + Option.exists(subscriptionAt(left.page, index), (subscription) => + subscription.isSameDependencies(left.inner, right.inner), + ), + ) -export const pageSubscriptions = Subscription.aggregate()( - ...pages.map((page) => page.subscriptions), +/** + * The open page's Subscriptions, one per slot. A slot restarts on a new instance or when its + * Subscription's own dependencies change, and stamps its Messages with the instance. + */ +export const pageSubscriptions = Subscription.make()((entry) => + Object.fromEntries( + Array.makeBy(PAGE_SUBSCRIPTION_SLOTS, (index) => { + const isSame = isSameSlotInstance(index) + return [ + `page.${index}`, + entry(SlotDependencies, { + modelToDependencies: (host): SlotDependencies => ({ + maybeInstance: slotInstanceOf(host, index), + }), + keepAliveEquivalence: (left: SlotDependencies, right: SlotDependencies) => + isSame(left.maybeInstance, right.maybeInstance), + dependenciesToStream: ({ maybeInstance }: SlotDependencies) => + Option.match( + Option.flatMap(maybeInstance, (instance) => + Option.map(subscriptionAt(instance.page, index), (subscription) => + subscription.streamOf(instance.inner, instance), + ), + ), + { onNone: () => Stream.empty, onSome: (stream) => stream }, + ), + }), + ] + }), + ), ) /** The page's view boundary: a new instance (another org on the same route, too) mounts afresh. */ @@ -374,16 +508,9 @@ export const viewPage = ( inputs: PageViewInputs, toParentMessage: (message: PageMessage) => ParentMessage, ): Html => - Option.match(pageById(slot._tag), { + Option.match(loadedPage(slot._tag), { onNone: () => h.empty, - onSome: (page) => - h.submodel({ - slotId: pageSlotId(slot), - model: slot, - view: page.hostView, - viewInputs: inputs, - toParentMessage, - }), + onSome: (page) => page.embedView(h, pageSlotId(slot), slot, inputs, toParentMessage), }) /** The active page's top-bar actions (`DashboardPage topbarActions`), or nothing. */ @@ -393,14 +520,8 @@ export const viewPageTopbarActions = ( inputs: PageViewInputs, toParentMessage: (message: PageMessage) => ParentMessage, ): Html => - Option.match(pageById(slot._tag), { + Option.match(loadedPage(slot._tag), { onNone: () => h.empty, onSome: (page) => - h.submodel({ - slotId: `page-topbar:${pageIdentity(slot)}`, - model: slot, - view: page.hostTopbarActions, - viewInputs: inputs, - toParentMessage, - }), + page.embedTopbarActions(h, `page-topbar:${pageIdentity(slot)}`, slot, inputs, toParentMessage), }) diff --git a/apps/web/src/page/releases/detail/implementation.ts b/apps/web/src/page/releases/detail/implementation.ts new file mode 100644 index 0000000000..5fc78f8dfc --- /dev/null +++ b/apps/web/src/page/releases/detail/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/releases/detail/index.ts b/apps/web/src/page/releases/detail/index.ts index 6dae4a91f4..957c3316b5 100644 --- a/apps/web/src/page/releases/detail/index.ts +++ b/apps/web/src/page/releases/detail/index.ts @@ -1,21 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" /** `/releases/$commitSha`: one instance per commit; a search change arrives as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "Release", { Model, Message }, - { - routes: ["Release"], - key: (route) => route.commitSha, - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["Release"], key: (route) => route.commitSha }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/releases/list/implementation.ts b/apps/web/src/page/releases/list/implementation.ts new file mode 100644 index 0000000000..cd31caadcb --- /dev/null +++ b/apps/web/src/page/releases/list/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Releases", isPageHeading: true }], +}) diff --git a/apps/web/src/page/releases/list/index.ts b/apps/web/src/page/releases/list/index.ts index 91893fe2d9..1dcd2e367b 100644 --- a/apps/web/src/page/releases/list/index.ts +++ b/apps/web/src/page/releases/list/index.ts @@ -1,20 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/releases`: one instance; a search change arrives as `routeChanged`. */ -export const page = definePage( - "ReleasesList", - { Model, Message }, - { - routes: ["Releases"], - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Releases", isPageHeading: true }], - }, -) +export const entry = definePage("ReleasesList", { Model, Message }, { routes: ["Releases"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/replays/detail/implementation.ts b/apps/web/src/page/replays/detail/implementation.ts new file mode 100644 index 0000000000..a507172de9 --- /dev/null +++ b/apps/web/src/page/replays/detail/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, preload, routeChanged, update } from "./update" +import { breadcrumbs, view } from "./view" + +export const implementation = implementPage(entry, { + init, + preload, + update, + routeChanged, + contextChanged, + view, + breadcrumbs, +}) diff --git a/apps/web/src/page/replays/detail/index.ts b/apps/web/src/page/replays/detail/index.ts index 4db8405e90..bc6d540036 100644 --- a/apps/web/src/page/replays/detail/index.ts +++ b/apps/web/src/page/replays/detail/index.ts @@ -1,22 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, preload, routeChanged, update } from "./update" -import { breadcrumbs, view } from "./view" /** `/replays/$sessionId`: one instance per session; `t` arrives as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "ReplayDetail", { Model, Message }, - { - routes: ["Replay"], - key: (route) => route.sessionId, - init, - preload, - update, - routeChanged, - contextChanged, - view, - breadcrumbs, - }, + { routes: ["Replay"], key: (route) => route.sessionId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/replays/list/implementation.ts b/apps/web/src/page/replays/list/implementation.ts new file mode 100644 index 0000000000..cef7eacb47 --- /dev/null +++ b/apps/web/src/page/replays/list/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, preload, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + preload, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Replays", isPageHeading: true }], +}) diff --git a/apps/web/src/page/replays/list/index.ts b/apps/web/src/page/replays/list/index.ts index 0bb309a3ef..a2a6238a13 100644 --- a/apps/web/src/page/replays/list/index.ts +++ b/apps/web/src/page/replays/list/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, preload, routeChanged, update } from "./update" -import { view } from "./view" /** `/replays`: session replays. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "ReplaysList", - { Model, Message }, - { - routes: ["Replays"], - init, - preload, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: () => [{ label: "Replays", isPageHeading: true }], - }, -) +export const entry = definePage("ReplaysList", { Model, Message }, { routes: ["Replays"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/revive.test.ts b/apps/web/src/page/revive.test.ts index 3ad0908f49..d7f4b40b0f 100644 --- a/apps/web/src/page/revive.test.ts +++ b/apps/web/src/page/revive.test.ts @@ -1,10 +1,10 @@ import { OrgId } from "@maple/domain" -import { Option, Result, Schema } from "effect" +import { Effect, Option, Result, Schema } from "effect" import { AsyncData } from "foldkit" -import { describe, expect, test } from "vitest" +import { beforeAll, describe, expect, test } from "vitest" import { AppRoute } from "../route" import type { PageContext } from "./contract" -import { enterRoute, PageMessage, type PageSlot, parkPages, updatePage } from "./registry" +import { enterRoute, PageMessage, type PageSlot, parkPages, updatePage, loadEveryPage } from "./registry" import { DEFAULT_QUICK_START_STATE } from "./onboarding/quick-start-state" import { IncidentsData, RulesData } from "./overview/model" import * as Setup from "./overview/setup-checklist-update" @@ -13,6 +13,9 @@ import { initWith, revive as reviveOverview } from "./overview/update" import { Message } from "./services/list/message" import type { ServiceRow } from "./services/list/model" +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) + const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } const otherContext: PageContext = { ...context, orgId: Schema.decodeSync(OrgId)("org_other") } diff --git a/apps/web/src/page/security/finding/implementation.ts b/apps/web/src/page/security/finding/implementation.ts new file mode 100644 index 0000000000..2eb4547250 --- /dev/null +++ b/apps/web/src/page/security/finding/implementation.ts @@ -0,0 +1,11 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + view, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/security/finding/index.ts b/apps/web/src/page/security/finding/index.ts index b5e05ed0ac..176f112cb7 100644 --- a/apps/web/src/page/security/finding/index.ts +++ b/apps/web/src/page/security/finding/index.ts @@ -1,18 +1,12 @@ import { definePage } from "../../contract" -import { Message, Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" +import { Message } from "./model" +import { Model } from "./model" /** `/security/findings/$targetId/$fingerprint`: one instance per finding. */ -export const page = definePage( +export const entry = definePage( "SecurityFinding", { Model, Message }, - { - routes: ["SecurityFinding"], - key: (route) => `${route.targetId}/${route.fingerprint}`, - init, - update, - view, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["SecurityFinding"], key: (route) => `${route.targetId}/${route.fingerprint}` }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/security/run/implementation.ts b/apps/web/src/page/security/run/implementation.ts new file mode 100644 index 0000000000..db15793c24 --- /dev/null +++ b/apps/web/src/page/security/run/implementation.ts @@ -0,0 +1,41 @@ +import { Clock, Duration, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import { pollTicks } from "../../../data/poll" +import { implementPage, type PageSubscriptionInput } from "../../contract" +import { entry } from "./index" +import { isLive } from "../kit/format" +import { runOf } from "./derive" +import { Message, Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +/** A live run, its events and its ledger are read again every 5 s; it stops once the run finishes. */ +const POLL = Duration.seconds(5) + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + live: entry( + { isLive: Schema.Boolean }, + { + modelToDependencies: ({ model }) => { + const run = runOf(model) + return { isLive: run !== null && isLive(run) } + }, + dependenciesToStream: ({ isLive: live }) => + live + ? pollTicks(POLL).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.TickedPoll({ nowMs })), + ), + ) + : Stream.empty, + }, + ), +})) + +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/security/run/index.ts b/apps/web/src/page/security/run/index.ts index 95d1023b3f..fe8207fe9f 100644 --- a/apps/web/src/page/security/run/index.ts +++ b/apps/web/src/page/security/run/index.ts @@ -1,47 +1,12 @@ -import { Clock, Duration, Effect, Schema, Stream } from "effect" -import { Subscription } from "foldkit" -import { pollTicks } from "../../../data/poll" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { isLive } from "../kit/format" -import { runOf } from "./derive" -import { Message, Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" - -/** A live run, its events and its ledger are read again every 5 s; it stops once the run finishes. */ -const POLL = Duration.seconds(5) - -export const subscriptions = Subscription.make, Message>()((entry) => ({ - live: entry( - { isLive: Schema.Boolean }, - { - modelToDependencies: ({ model }) => { - const run = runOf(model) - return { isLive: run !== null && isLive(run) } - }, - dependenciesToStream: ({ isLive: live }) => - live - ? pollTicks(POLL).pipe( - Stream.mapEffect(() => - Effect.map(Clock.currentTimeMillis, (nowMs) => Message.TickedPoll({ nowMs })), - ), - ) - : Stream.empty, - }, - ), -})) +import { definePage } from "../../contract" +import { Message } from "./model" +import { Model } from "./model" /** `/security/runs/$runId`: one instance per run. */ -export const page = definePage( +export const entry = definePage( "SecurityRun", { Model, Message }, - { - routes: ["SecurityRun"], - key: (route) => route.runId, - init, - update, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["SecurityRun"], key: (route) => route.runId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/security/run/run.scene.test.ts b/apps/web/src/page/security/run/run.scene.test.ts index bccfa0c878..76b986c8f1 100644 --- a/apps/web/src/page/security/run/run.scene.test.ts +++ b/apps/web/src/page/security/run/run.scene.test.ts @@ -21,7 +21,7 @@ import { import { AppRoute } from "../../../route" import type { PageContext } from "../../contract" import { eventsOf as eventLogOf, openFindingOf, phasesOf } from "./derive" -import { subscriptions } from "./index" +import { subscriptions } from "./implementation" import { Message, type Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" diff --git a/apps/web/src/page/security/tabs/implementation.ts b/apps/web/src/page/security/tabs/implementation.ts new file mode 100644 index 0000000000..c79f1c0d66 --- /dev/null +++ b/apps/web/src/page/security/tabs/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { breadcrumbsOf } from "../kit/layout" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: (model) => breadcrumbsOf(model.tab), +}) diff --git a/apps/web/src/page/security/tabs/index.ts b/apps/web/src/page/security/tabs/index.ts index 32dd4206a8..c09d5e030e 100644 --- a/apps/web/src/page/security/tabs/index.ts +++ b/apps/web/src/page/security/tabs/index.ts @@ -1,25 +1,15 @@ import { definePage } from "../../contract" -import { breadcrumbsOf } from "../kit/layout" -import { Message, Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { view } from "./view" +import { Message } from "./model" +import { Model } from "./model" /** * `/security` and its tabs (Overview, Findings, Runs) as one instance per org: a tab change keeps * the instance, so the targets, runs and findings one tab read serve the next. */ -export const page = definePage( +export const entry = definePage( "Security", { Model, Message }, - { - routes: ["Security", "SecurityFindings", "SecurityRuns"], - key: () => "security", - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: (model) => breadcrumbsOf(model.tab), - }, + { routes: ["Security", "SecurityFindings", "SecurityRuns"], key: () => "security" }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/service-map/implementation.ts b/apps/web/src/page/service-map/implementation.ts new file mode 100644 index 0000000000..73c91d5220 --- /dev/null +++ b/apps/web/src/page/service-map/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Service Map", isPageHeading: true }], +}) diff --git a/apps/web/src/page/service-map/index.ts b/apps/web/src/page/service-map/index.ts index 73e0077afc..f5cc95eac1 100644 --- a/apps/web/src/page/service-map/index.ts +++ b/apps/web/src/page/service-map/index.ts @@ -1,22 +1,8 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/service-map`. One instance per org; search changes (window, environment, focus) arrive as `routeChanged`. */ -export const page = definePage( - "ServiceMap", - { Model, Message }, - { - routes: ["ServiceMap"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Service Map", isPageHeading: true }], - }, -) +export const entry = definePage("ServiceMap", { Model, Message }, { routes: ["ServiceMap"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/services/detail/implementation.ts b/apps/web/src/page/services/detail/implementation.ts new file mode 100644 index 0000000000..58d8fa6ee4 --- /dev/null +++ b/apps/web/src/page/services/detail/implementation.ts @@ -0,0 +1,13 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: (model) => [{ label: "Services", href: "/services" }, { label: model.serviceName }], +}) diff --git a/apps/web/src/page/services/detail/index.ts b/apps/web/src/page/services/detail/index.ts index 4b5c518e15..7d4d8dd4f5 100644 --- a/apps/web/src/page/services/detail/index.ts +++ b/apps/web/src/page/services/detail/index.ts @@ -1,21 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" /** `/services/$serviceName`: one instance per service; tab, environment and window changes are `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "ServiceDetail", { Model, Message }, - { - routes: ["Service"], - key: (route) => route.serviceName, - init, - update, - routeChanged, - contextChanged, - view, - breadcrumbs: (model) => [{ label: "Services", href: "/services" }, { label: model.serviceName }], - }, + { routes: ["Service"], key: (route) => route.serviceName }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/services/list/implementation.ts b/apps/web/src/page/services/list/implementation.ts new file mode 100644 index 0000000000..062c1cbb0f --- /dev/null +++ b/apps/web/src/page/services/list/implementation.ts @@ -0,0 +1,17 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, preload, revive, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + preload, + update, + routeChanged, + revive, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Services", isPageHeading: true }], +}) diff --git a/apps/web/src/page/services/list/index.ts b/apps/web/src/page/services/list/index.ts index 0ec8ad6e16..9a38cbac94 100644 --- a/apps/web/src/page/services/list/index.ts +++ b/apps/web/src/page/services/list/index.ts @@ -1,24 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, preload, revive, routeChanged, update } from "./update" -import { view } from "./view" /** `/services`: the services list. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "ServicesList", - { Model, Message }, - { - routes: ["Services"], - init, - preload, - update, - routeChanged, - revive, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Services", isPageHeading: true }], - }, -) +export const entry = definePage("ServicesList", { Model, Message }, { routes: ["Services"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/settings/account/implementation.ts b/apps/web/src/page/settings/account/implementation.ts new file mode 100644 index 0000000000..e05f06cc06 --- /dev/null +++ b/apps/web/src/page/settings/account/implementation.ts @@ -0,0 +1,12 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { init, routeChanged, update } from "./update" +import { breadcrumbs, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + breadcrumbs, +}) diff --git a/apps/web/src/page/settings/account/index.ts b/apps/web/src/page/settings/account/index.ts index 56594e0810..093739b33b 100644 --- a/apps/web/src/page/settings/account/index.ts +++ b/apps/web/src/page/settings/account/index.ts @@ -1,18 +1,8 @@ import { definePage } from "../../contract" -import { Message, Model } from "./model" -import { init, routeChanged, update } from "./update" -import { breadcrumbs, view } from "./view" +import { Message } from "./model" +import { Model } from "./model" /** `/account`: one instance; a tab switch is a search change (`routeChanged`). */ -export const page = definePage( - "Account", - { Model, Message }, - { - routes: ["Account"], - init, - update, - routeChanged, - view, - breadcrumbs, - }, -) +export const entry = definePage("Account", { Model, Message }, { routes: ["Account"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/settings/implementation.ts b/apps/web/src/page/settings/implementation.ts new file mode 100644 index 0000000000..430664b334 --- /dev/null +++ b/apps/web/src/page/settings/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { breadcrumbs, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs, +}) diff --git a/apps/web/src/page/settings/index.ts b/apps/web/src/page/settings/index.ts index be914c89a3..d72afabfc3 100644 --- a/apps/web/src/page/settings/index.ts +++ b/apps/web/src/page/settings/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { breadcrumbs, view } from "./view" /** `/settings`: one instance per org; a tab switch is a search change (`routeChanged`). */ -export const page = definePage( - "Settings", - { Model, Message }, - { - routes: ["Settings"], - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs, - }, -) +export const entry = definePage("Settings", { Model, Message }, { routes: ["Settings"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/settings/ingest-rule/implementation.ts b/apps/web/src/page/settings/ingest-rule/implementation.ts new file mode 100644 index 0000000000..2267199bc5 --- /dev/null +++ b/apps/web/src/page/settings/ingest-rule/implementation.ts @@ -0,0 +1,15 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { ingestionHref } from "./hrefs" +import { init, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + view, + breadcrumbs: () => [ + { label: "Settings", href: "/settings" }, + { label: "Ingestion", href: ingestionHref() }, + ], +}) diff --git a/apps/web/src/page/settings/ingest-rule/index.ts b/apps/web/src/page/settings/ingest-rule/index.ts index df1b98669a..4bf3c553cd 100644 --- a/apps/web/src/page/settings/ingest-rule/index.ts +++ b/apps/web/src/page/settings/ingest-rule/index.ts @@ -1,23 +1,15 @@ import { definePage } from "../../contract" -import { ingestionHref } from "./hrefs" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { view } from "./view" /** `/settings/ingestion/rules/new` and `/settings/ingestion/rules/:id`: one instance per rule, or the new one. */ -export const page = definePage( +export const entry = definePage( "IngestRuleEditor", { Model, Message }, { routes: ["IngestRuleNew", "IngestRule"], key: (route) => (route._tag === "IngestRule" ? route.ruleId : `new:${route.search.template ?? ""}`), - init, - update, - view, - breadcrumbs: () => [ - { label: "Settings", href: "/settings" }, - { label: "Ingestion", href: ingestionHref() }, - ], }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/settings/mcp-page.ts b/apps/web/src/page/settings/mcp-page/implementation.ts similarity index 69% rename from apps/web/src/page/settings/mcp-page.ts rename to apps/web/src/page/settings/mcp-page/implementation.ts index d6efe076e7..428b5a0aa4 100644 --- a/apps/web/src/page/settings/mcp-page.ts +++ b/apps/web/src/page/settings/mcp-page/implementation.ts @@ -1,34 +1,22 @@ -import { isClerkAuthEnabled } from "../../lib/services/common/auth-mode" +import { isClerkAuthEnabled } from "../../../lib/services/common/auth-mode" import { Result, Schema, Stream } from "effect" import { Command, Submodel, Subscription } from "foldkit" -import { defineMessageUnion } from "foldkit/message" -import type { Resources } from "../../platform/resources" +import type { Resources } from "../../../platform/resources" import { - definePage, + implementPage, type PageContext, type PageReturn, type PageSubscriptionInput, type PageViewInputs, -} from "../contract" -import { dashboardPage } from "../kit/dashboard-page" -import { FetchSettingsSession, ReadSettingsClock } from "./command" -import { Message as SettingsMessage } from "./message" -import * as McpMessage from "./mcp/message" -import * as McpModel from "./mcp/model" -import * as Mcp from "./mcp/update" -import * as McpView from "./mcp/view" -import { apiKeyRows } from "./subscription" - -// `/mcp`: the same `McpSection` as `/settings?tab=mcp`, standalone. - -const Model = Schema.Struct({ nowMs: Schema.Number, isAdmin: Schema.Boolean, mcp: McpModel.Model }) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ - GotSettingsMessage: { message: SettingsMessage }, - GotMcpMessage: { message: McpMessage.Message }, -}) -type Message = typeof Message.Type +} from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { entry, Message, type Model } from "./index" +import { FetchSettingsSession, ReadSettingsClock } from "../command" +import { Message as SettingsMessage } from "../message" +import * as McpMessage from "../mcp/message" +import * as Mcp from "../mcp/update" +import * as McpView from "../mcp/view" +import { apiKeyRows } from "../subscription" type Return = PageReturn @@ -98,8 +86,10 @@ const subscriptions = Subscription.make, Message, R ), })) -export const page = definePage( - "McpPage", - { Model, Message }, - { routes: ["Mcp"], init, update, view, subscriptions, breadcrumbs: () => [{ label: "MCP" }] }, -) +export const implementation = implementPage(entry, { + init, + update, + view, + subscriptions, + breadcrumbs: () => [{ label: "MCP" }], +}) diff --git a/apps/web/src/page/settings/mcp-page/index.ts b/apps/web/src/page/settings/mcp-page/index.ts new file mode 100644 index 0000000000..30e064ff1e --- /dev/null +++ b/apps/web/src/page/settings/mcp-page/index.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { definePage } from "../../contract" +import { Message as SettingsMessage } from "../message" +import * as McpMessage from "../mcp/message" +import * as McpModel from "../mcp/model" + +export const Model = Schema.Struct({ nowMs: Schema.Number, isAdmin: Schema.Boolean, mcp: McpModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotSettingsMessage: { message: SettingsMessage }, + GotMcpMessage: { message: McpMessage.Message }, +}) +export type Message = typeof Message.Type + +/** `/mcp`: the same `McpSection` as `/settings?tab=mcp`, standalone. */ +export const entry = definePage("McpPage", { Model, Message }, { routes: ["Mcp"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/share/implementation.ts b/apps/web/src/page/share/implementation.ts new file mode 100644 index 0000000000..fccbd82547 --- /dev/null +++ b/apps/web/src/page/share/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init: (route) => init(route), + update: (model, message) => update(model, message), + routeChanged: (model, route) => routeChanged(model, route), + view, + subscriptions, + breadcrumbs: () => [], +}) diff --git a/apps/web/src/page/share/index.ts b/apps/web/src/page/share/index.ts index 2710a47b10..3b5df7d640 100644 --- a/apps/web/src/page/share/index.ts +++ b/apps/web/src/page/share/index.ts @@ -1,25 +1,15 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { view } from "./view" /** * `/share/$token`: a public page outside the shell. The root renders it bare and gives it a * context without a signed-in org (see `pageContextOf`); one instance per token. */ -export const page = definePage( +export const entry = definePage( "Share", { Model, Message }, - { - routes: ["Share"], - key: (route) => route.token, - init: (route) => init(route), - update: (model, message) => update(model, message), - routeChanged: (model, route) => routeChanged(model, route), - view, - subscriptions, - breadcrumbs: () => [], - }, + { routes: ["Share"], key: (route) => route.token }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/sources/detail/implementation.ts b/apps/web/src/page/sources/detail/implementation.ts new file mode 100644 index 0000000000..2e0f55f667 --- /dev/null +++ b/apps/web/src/page/sources/detail/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/sources/detail/index.ts b/apps/web/src/page/sources/detail/index.ts index f832ac7333..4941b0f23d 100644 --- a/apps/web/src/page/sources/detail/index.ts +++ b/apps/web/src/page/sources/detail/index.ts @@ -1,22 +1,12 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" /** `/sources/$sourceId`: one instance per source; a tab link is a `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "SourceDetail", { Model, Message }, - { - routes: ["Source"], - key: (route) => route.sourceId, - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, + { routes: ["Source"], key: (route) => route.sourceId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/sources/list/implementation.ts b/apps/web/src/page/sources/list/implementation.ts new file mode 100644 index 0000000000..961d34284b --- /dev/null +++ b/apps/web/src/page/sources/list/implementation.ts @@ -0,0 +1,11 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { init, update } from "./update" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + view, + breadcrumbs: () => [{ label: "Sources", isPageHeading: true }], +}) diff --git a/apps/web/src/page/sources/list/index.ts b/apps/web/src/page/sources/list/index.ts index 4322738cea..b09fc8afeb 100644 --- a/apps/web/src/page/sources/list/index.ts +++ b/apps/web/src/page/sources/list/index.ts @@ -1,18 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { view } from "./view" /** `/sources`: every source the org sends from. */ -export const page = definePage( - "SourcesList", - { Model, Message }, - { - routes: ["Sources"], - init, - update, - view, - breadcrumbs: () => [{ label: "Sources", isPageHeading: true }], - }, -) +export const entry = definePage("SourcesList", { Model, Message }, { routes: ["Sources"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/sources/new/implementation.ts b/apps/web/src/page/sources/new/implementation.ts new file mode 100644 index 0000000000..2e0f55f667 --- /dev/null +++ b/apps/web/src/page/sources/new/implementation.ts @@ -0,0 +1,14 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { init, routeChanged, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, +}) diff --git a/apps/web/src/page/sources/new/index.ts b/apps/web/src/page/sources/new/index.ts index eed38688a5..9206cd76b9 100644 --- a/apps/web/src/page/sources/new/index.ts +++ b/apps/web/src/page/sources/new/index.ts @@ -1,21 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" /** `/sources/new`: one instance per visit; `?platform=` or `?integration=` moves between the steps. */ -export const page = definePage( - "SourcesNew", - { Model, Message }, - { - routes: ["SourcesNew"], - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: breadcrumbsOf, - }, -) +export const entry = definePage("SourcesNew", { Model, Message }, { routes: ["SourcesNew"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/traces/detail/implementation.ts b/apps/web/src/page/traces/detail/implementation.ts new file mode 100644 index 0000000000..02568488ed --- /dev/null +++ b/apps/web/src/page/traces/detail/implementation.ts @@ -0,0 +1,17 @@ +import { implementPage, pickCommands } from "../../contract" +import { entry } from "./index" +import { subscriptions } from "./subscription" +import { contextChanged, init, routeChanged, update } from "./update" +import { breadcrumbs, view } from "./view" + +export const implementation = implementPage(entry, { + init, + /** `warmAtoms([getSpanHierarchyResultAtom({ traceId, timestamp: t })])` in the route loader. */ + preload: (route, context) => pickCommands(init(route, context), ["FetchSpanHierarchy"]), + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs, +}) diff --git a/apps/web/src/page/traces/detail/index.ts b/apps/web/src/page/traces/detail/index.ts index fb502e1387..c5ebb89a0d 100644 --- a/apps/web/src/page/traces/detail/index.ts +++ b/apps/web/src/page/traces/detail/index.ts @@ -1,25 +1,12 @@ -import { definePage, pickCommands } from "../../contract" +import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { subscriptions } from "./subscription" -import { contextChanged, init, routeChanged, update } from "./update" -import { breadcrumbs, view } from "./view" /** `/traces/$traceId`: one instance per trace; `spanId`, `view` and `t` arrive as `routeChanged`. */ -export const page = definePage( +export const entry = definePage( "TraceDetail", { Model, Message }, - { - routes: ["Trace"], - key: (route) => route.traceId, - init, - /** `warmAtoms([getSpanHierarchyResultAtom({ traceId, timestamp: t })])` in the route loader. */ - preload: (route, context) => pickCommands(init(route, context), ["FetchSpanHierarchy"]), - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs, - }, + { routes: ["Trace"], key: (route) => route.traceId }, ) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/page/traces/list/implementation.ts b/apps/web/src/page/traces/list/implementation.ts new file mode 100644 index 0000000000..f6e7f6da42 --- /dev/null +++ b/apps/web/src/page/traces/list/implementation.ts @@ -0,0 +1,16 @@ +import { implementPage } from "../../contract" +import { entry } from "./index" +import { contextChanged, init, preload, routeChanged, update } from "./update" +import { subscriptions } from "./subscription" +import { view } from "./view" + +export const implementation = implementPage(entry, { + init, + preload, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Traces", isPageHeading: true }], +}) diff --git a/apps/web/src/page/traces/list/index.ts b/apps/web/src/page/traces/list/index.ts index 746f7cf3bc..8c63c440fe 100644 --- a/apps/web/src/page/traces/list/index.ts +++ b/apps/web/src/page/traces/list/index.ts @@ -1,23 +1,8 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, preload, routeChanged, update } from "./update" -import { subscriptions } from "./subscription" -import { view } from "./view" /** `/traces`: the trace list. One instance per org; search changes arrive as `routeChanged`. */ -export const page = definePage( - "TracesList", - { Model, Message }, - { - routes: ["Traces"], - init, - preload, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Traces", isPageHeading: true }], - }, -) +export const entry = definePage("TracesList", { Model, Message }, { routes: ["Traces"] }) + +export const page = entry.lazy(() => import("./implementation")) diff --git a/apps/web/src/platform/telemetry.ts b/apps/web/src/platform/telemetry.ts index c854bc7342..88ce712583 100644 --- a/apps/web/src/platform/telemetry.ts +++ b/apps/web/src/platform/telemetry.ts @@ -36,8 +36,8 @@ const maple = MapleFoldkit.make({ "@maple/web/errors/WarehouseUnreachableError", ], routeName, - // Starts no child span, but it is the boot's longest step before the first data read. - keepCommands: ["LoadClerk"], + // Start no child span, but sit on the boot path before the first data read (LoadPageCode: a page's lazy code). + keepCommands: ["LoadClerk", "LoadPageCode"], // The perf bench builds with VITE_MAPLE_REPLAY=off and VITE_MAPLE_CAPTURE=off. replay: { enabled: import.meta.env.VITE_MAPLE_REPLAY !== "off", diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 47fdddbfb9..c106f4de93 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -52,7 +52,7 @@ export const timeZonePreferenceChanges: Stream.Stream = Stream.merge( Stream.map((timeZone) => Message.ChangedTimeZonePreference({ timeZone })), ) -/** Every page's Subscriptions, lifted onto the root (`page/registry.ts`). */ +/** The open page's Subscriptions, lifted onto the root (`page/registry.ts`). */ const pages = Subscription.lift(pageSubscriptions)({ read: (model) => Option.some({ page: model.page, context: Option.getOrNull(pageContextOf(model)) }), toParentMessage: (message) => Message.GotPageMessage({ message }), diff --git a/apps/web/src/update.customer-grace.test.ts b/apps/web/src/update.customer-grace.test.ts index 1cd15079e2..65da007ded 100644 --- a/apps/web/src/update.customer-grace.test.ts +++ b/apps/web/src/update.customer-grace.test.ts @@ -1,7 +1,7 @@ import { OrgId, UserId } from "@maple/domain" import { BillingCustomer } from "@maple/domain/http" -import { Option, Schema } from "effect" -import { describe, expect, test, vi } from "vitest" +import { Effect, Option, Schema } from "effect" +import { beforeAll, describe, expect, test, vi } from "vitest" // Clerk mode is read at import; the shell's favorites listener targets `window` at import too. vi.hoisted(() => { @@ -9,7 +9,7 @@ vi.hoisted(() => { Object.assign(globalThis, { window: new EventTarget() }) }) import { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { initCustomer } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" import * as SignIn from "./page/sign-in" @@ -18,6 +18,10 @@ import { AppRoute } from "./route" import * as Shell from "./shell" import * as Toast from "./ui/toast" import { planGateOf, update } from "./update" +import { loadEveryPage } from "./page/registry" + +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -31,6 +35,7 @@ const base = (knownToRenderApp: Record): Model => ({ toastActions: {}, toastRootActions: {}, page: null, + pageCode: PageCode.Ready(), pageInstances: 0, parkedPages: [], onboarding: null, diff --git a/apps/web/src/update.pending-task.test.ts b/apps/web/src/update.pending-task.test.ts index f6e1c4296e..4ff583bf0c 100644 --- a/apps/web/src/update.pending-task.test.ts +++ b/apps/web/src/update.pending-task.test.ts @@ -1,6 +1,6 @@ import { OrgId, UserId } from "@maple/domain" -import { Option, Schema } from "effect" -import { describe, expect, test, vi } from "vitest" +import { Effect, Option, Schema } from "effect" +import { beforeAll, describe, expect, test, vi } from "vitest" // Clerk mode is read at import; the shell's favorites listener targets `window` at import too. vi.hoisted(() => { @@ -8,7 +8,7 @@ vi.hoisted(() => { Object.assign(globalThis, { window: new EventTarget() }) }) import { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { initCustomer } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" import * as SignIn from "./page/sign-in" @@ -17,6 +17,10 @@ import { AppRoute } from "./route" import * as Shell from "./shell" import * as Toast from "./ui/toast" import { update } from "./update" +import { loadEveryPage } from "./page/registry" + +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -30,6 +34,7 @@ const base = (route: AppRoute): Model => ({ toastActions: {}, toastRootActions: {}, page: null, + pageCode: PageCode.Ready(), pageInstances: 0, parkedPages: [], onboarding: null, diff --git a/apps/web/src/update.test.ts b/apps/web/src/update.test.ts index 28ec070f08..fbb8bfd0f6 100644 --- a/apps/web/src/update.test.ts +++ b/apps/web/src/update.test.ts @@ -1,13 +1,13 @@ import { OrgId, UserId } from "@maple/domain" -import { Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { UrlRequest } from "foldkit/navigation" import { fromString as urlFromString } from "foldkit/url" -import { describe, expect, test, vi } from "vitest" +import { beforeAll, describe, expect, test, vi } from "vitest" // The shell's favorites listener targets `window` at import; the root update never touches it here. vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) import { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { initCustomer } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" import * as SignIn from "./page/sign-in" @@ -18,6 +18,10 @@ import * as Toast from "./ui/toast" import type { ClerkSnapshot } from "./platform/clerk" import { subscriptions } from "./subscription" import { settle, update } from "./update" +import { loadEveryPage } from "./page/registry" + +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) const orgId = Schema.decodeSync(OrgId)("org_acme") const NOW = Date.parse("2026-10-01T12:00:00Z") @@ -32,6 +36,7 @@ const base = (route: AppRoute): Model => ({ toastActions: {}, toastRootActions: {}, page: null, + pageCode: PageCode.Ready(), pageInstances: 0, parkedPages: [], onboarding: null, diff --git a/apps/web/src/update.ts b/apps/web/src/update.ts index f97c3bda4c..36662a4666 100644 --- a/apps/web/src/update.ts +++ b/apps/web/src/update.ts @@ -10,6 +10,7 @@ import { fromString as urlFromString, toString as urlToString, type Url } from " import { CheckSession, LoadCustomer, + LoadPageCode, WaitCustomerGrace, HoldPreloads, ReleaseReads, @@ -28,7 +29,7 @@ import { WriteSessionTimeRange, } from "./command" import { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { isRedirectOnlyRoute, rendersShell } from "./page/chrome" import * as Onboarding from "./page/onboarding" import { @@ -55,6 +56,7 @@ import { pageSource, preloadRoute, type PageTransition, + unloadedPageOf, updatePage, } from "./page/registry" import * as SignIn from "./page/sign-in" @@ -408,12 +410,53 @@ const loadPage: Step = (model) => : { ...left, model: modifyFields(left.model, { parkedPages: () => [] }) } }, onSome: (context) => - applyPage( - enterRoute(model.page, model.route, context, model.pageInstances + 1, model.parkedPages), - )(model), + Option.match(unloadedPageOf(model.route), { + onSome: (pageId) => awaitPageCode(model, pageId), + onNone: () => + applyPage( + enterRoute( + model.page, + model.route, + context, + model.pageInstances + 1, + model.parkedPages, + ), + )( + model.pageCode._tag === "Ready" + ? model + : modifyFields(model, { pageCode: () => PageCode.Ready() }), + ), + }), }, ) +const isAwaitingPageCode = (model: Model, pageId: string) => + model.pageCode._tag !== "Ready" && model.pageCode.pageId === pageId + +/** The route's page has no code yet: the open page is left and its code loaded, once (`Failed` waits for Reload). */ +const awaitPageCode = (model: Model, pageId: string): UpdateReturn => { + const left = + model.page === null + ? { model } + : writePage({ slot: null, commands: [], outMessage: Option.none() })(model) + return isAwaitingPageCode(model, pageId) + ? left + : { + model: modifyFields(left.model, { pageCode: () => PageCode.Loading({ pageId }) }), + commands: [...(left.commands ?? []), LoadPageCode({ pageId, preloadHref: null })], + } +} + +/** A page's code arrived: the route waiting on it enters, and the link that asked runs its preload. */ +const loadedPageCode = (pageId: string, preloadHref: string | null, nowMs: number): Step => + Update.combine([ + (model: Model) => + model.pageCode._tag === "Loading" && model.pageCode.pageId === pageId + ? loadPage(modifyFields(model, { pageCode: () => PageCode.Ready() })) + : { model }, + (model: Model) => (preloadHref === null ? { model } : preloadLink(model, preloadHref, nowMs)), + ]) + // ONBOARDING /** The session as the onboarding screens read it. */ @@ -917,27 +960,33 @@ const preloadLink = (model: Model, href: string, nowMs: number): UpdateReturn => () => hovered, ) const route = urlToAppRoute(url) - return Option.map(pageContextOf(modifyFields(model, { route: () => route })), (context) => - preloadRoute(route, context, nowMs), - ) + return Option.map(pageContextOf(modifyFields(model, { route: () => route })), (context) => ({ + route, + context, + })) }), { onNone: () => ({ model }), - onSome: (commands) => { - const keys = Preload.keysOf(commands) - return { - model, - commands: [ - ...commands.map( - Command.mapMessage((message) => Message.GotPreloadMessage({ message })), - ), - ...(keys.length === 0 ? [] : [HoldPreloads({ keys })]), - ], - } - }, + onSome: ({ route, context }) => + Option.match(unloadedPageOf(route), { + // The page's code first, as the router loaded a route's chunk before its loader. + onSome: (pageId) => ({ model, commands: [LoadPageCode({ pageId, preloadHref: href })] }), + onNone: () => preloadPageReads(model, preloadRoute(route, context, nowMs)), + }), }, ) +const preloadPageReads = (model: Model, commands: ReturnType): UpdateReturn => { + const keys = Preload.keysOf(commands) + return { + model, + commands: [ + ...commands.map(Command.mapMessage((message) => Message.GotPreloadMessage({ message }))), + ...(keys.length === 0 ? [] : [HoldPreloads({ keys })]), + ], + } +} + /** A new URL: the route and the page it enters. The remembered window joined in `navigateTo`, never here. */ const changedUrlStep = (model: Model, changedUrl: Url): UpdateReturn => { const nextRoute = urlToAppRoute(changedUrl) @@ -996,6 +1045,13 @@ const foldMessage = (model: Model, message: Message) => FailedCheckSession: () => signedOut(model), ChangedSelfHostedSession: () => ({ model, commands: [CheckSession()] }), CompletedSaveSessionToken: () => ({ model, commands: [CheckSession()] }), + CompletedLoadPageCode: ({ pageId, preloadHref, nowMs }) => + loadedPageCode(pageId, preloadHref, nowMs)(model), + FailedLoadPageCode: ({ pageId, failure }) => + model.pageCode._tag === "Loading" && model.pageCode.pageId === pageId + ? { model: modifyFields(model, { pageCode: () => PageCode.Failed({ pageId, failure }) }) } + : { model }, + ClickedReloadPage: () => ({ model, commands: [LoadExternal({ href: hrefOf(model.route) })] }), CompletedLoadClerk: ({ snapshot }) => appliedClerkSnapshot(snapshot, true)(modifyFields(model, { clerk: () => ClerkPhase.Loaded() })), FailedLoadClerk: ({ message }) => diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 7469e2fc6e..ec12e4443c 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import type { HtmlBuilder } from "foldkit/html" import { Lazy } from "foldkit/test" import { fromString } from "foldkit/url" @@ -8,10 +8,10 @@ import { describe, expect, test, vi } from "vitest" // The shell's favorites listener targets `window` at import; nothing here touches it. vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) import type { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { initCustomer } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" -import { pages } from "./page/registry" +import { pages, loadEveryPage } from "./page/registry" import * as SignIn from "./page/sign-in" import { urlToAppRoute } from "./route" import * as Shell from "./shell" @@ -19,6 +19,9 @@ import * as Toast from "./ui/toast" import { settle } from "./update" import { view } from "./view" +// Pages enter only once their code is loaded (`loadPageCode`); the sample models below are built at import. +await Effect.runPromise(loadEveryPage) + const orgId = Schema.decodeSync(OrgId)("org_acme") const NOW = Date.parse("2026-10-01T12:00:00Z") @@ -113,6 +116,7 @@ const entered = (path: string): Model => toastActions: {}, toastRootActions: {}, page: null, + pageCode: PageCode.Ready(), pageInstances: 0, parkedPages: [], onboarding: null, diff --git a/apps/web/src/view.scene.test.ts b/apps/web/src/view.scene.test.ts index 4d959ffdf5..d4d619434a 100644 --- a/apps/web/src/view.scene.test.ts +++ b/apps/web/src/view.scene.test.ts @@ -1,14 +1,14 @@ import * as Animation from "@foldkit/ui/animation" import { OrgId } from "@maple/domain" -import { Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import type { HtmlBuilder } from "foldkit/html" import { click, Command, expect as expectView, given, Mount, role, scene, text } from "foldkit/scene" -import { describe, test, vi } from "vitest" +import { beforeAll, describe, test, vi } from "vitest" // The shell's favorites listener targets `window` at import; the scene never touches it. vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) import type { Message } from "./message" -import { ClerkPhase, type Model, Session } from "./model" +import { ClerkPhase, type Model, PageCode, Session } from "./model" import { initCustomer } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" import * as SignIn from "./page/sign-in" @@ -22,6 +22,10 @@ import { MountSuggestions, MountWhereEditor } from "./page/traces/list/command" import { Message as TracesMessage } from "./page/traces/list/message" import { settle, update } from "./update" import { view } from "./view" +import { loadEveryPage } from "./page/registry" + +// Pages enter only once their code is loaded (`loadPageCode`); these tests drive loaded pages. +beforeAll(() => Effect.runPromise(loadEveryPage)) const orgId = Schema.decodeSync(OrgId)("org_acme") const body = (model: Model, h: HtmlBuilder) => view(model, h).body @@ -36,6 +40,7 @@ const signedIn = (): Model => ({ toastActions: {}, toastRootActions: {}, page: null, + pageCode: PageCode.Ready(), pageInstances: 0, parkedPages: [], onboarding: null, diff --git a/apps/web/src/view.ts b/apps/web/src/view.ts index b941acf491..46d2c9740f 100644 --- a/apps/web/src/view.ts +++ b/apps/web/src/view.ts @@ -7,7 +7,13 @@ import { Message } from "./message" import { type Model, Session } from "./model" import { onboardingInputsOf, pageContextOf, planGateOf, shellClerkOf } from "./update" import * as OnboardingView from "./page/onboarding/view" -import { breadcrumbsOf, notFoundContent, placeholderContent, rendersShell } from "./page/chrome" +import { + breadcrumbsOf, + notFoundContent, + pageCodeFailedContent, + placeholderContent, + rendersShell, +} from "./page/chrome" import type { PageContext, ShellInfraSurfaces } from "./page/contract" import { isBarePageRoute, @@ -122,7 +128,11 @@ const lazyPageView = (model: Model, h: HtmlBuilder, position: PagePosit /** The routed page, the not-found body for an unknown path, or a skeleton while the page loads. */ const pageView = (model: Model, h: HtmlBuilder, position: PagePosition): Html => Option.getOrElse(lazyPageView(model, h, position), () => - model.route._tag === "NotFound" ? notFoundContent(h) : placeholderContent(h), + model.route._tag === "NotFound" + ? notFoundContent(h) + : model.pageCode._tag === "Failed" + ? pageCodeFailedContent(h, model.pageCode.failure, Message.ClickedReloadPage()) + : placeholderContent(h), ) /** The routed page's own top-bar actions, if it has any. */ @@ -243,7 +253,7 @@ const routeView = (model: Model, h: HtmlBuilder): ReadonlyArray = onNone: () => rendersShell(model.route) ? shellRoute(model, h) - : isBarePageRoute(model.route) && model.page !== null + : isBarePageRoute(model.route) ? [pageView(model, h, "bare")] : [placeholder(model, h, [])], }), diff --git a/docs/foldkit-decisions/s8-pages.md b/docs/foldkit-decisions/s8-pages.md index 109bdd9046..d927182331 100644 --- a/docs/foldkit-decisions/s8-pages.md +++ b/docs/foldkit-decisions/s8-pages.md @@ -16,7 +16,8 @@ leave (Hazel). Adding one touches the root in exactly one place: an entry in `pa | `derive.ts` | Pure derivations of the legacy component logic (grouping, health, deploy info) | | `view.ts`, `view-*.ts` | `view = Submodel.defineView` and its parts | | `subscription.ts` | Live queries, keyed on `context` | -| `index.ts` | `export const page = definePage("Id", { Model, Message }, { routes, init, ... })` | +| `index.ts` | `entry = definePage("Id", { Model, Message }, { routes, key? })`, `page = entry.lazy(...)` | +| `implementation.ts` | `implementation = implementPage(entry, { init, update, view, subscriptions, ... })` | | `*.scene.test.ts` | Scene tests of the update logic | Shared pieces live in `page/kit/` (layout, time controls and picker, empty states, presence) and @@ -29,6 +30,10 @@ Shared pieces live in `page/kit/` (layout, time controls and picker, empty state the Model. A new org builds a new instance; a pin or zone change calls `contextChanged`. - **Routes.** `definePage(id, schemas, { routes: ["Services"], key? })`. The instance survives while `key(route)` (default: the tag) is unchanged; a search change arrives as `routeChanged`. +- **Lazy code.** Only `index.ts` (schemas, routes, key) loads with the app. `implementation.ts` loads + on first entry or link intent (`loadPageCode`); until then the root shows the page skeleton. Keep + `model.ts` and `message.ts` free of view and update imports, or they pull the page back into startup. + The open page's Subscriptions run in the registry's slots (`page.0` ... `page.23`), one per entry. - **Out.** The page never touches the URL or shell state. It returns a `PageOutMessage`: `RequestedNavigation({ url, isReplace })` (build the URL with `hrefOf(AppRoute.X({ search }))`, the same TanStack search encoding legacy writes) or `RequestedNamespacePin`. @@ -37,20 +42,20 @@ Shared pieces live in `page/kit/` (layout, time controls and picker, empty state breadcrumbs via the spec's `breadcrumbs(model)`. ```ts -export const page = definePage( - "ServicesList", - { Model, Message }, - { - routes: ["Services"], - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Services" }], - }, -) +// index.ts +export const entry = definePage("ServicesList", { Model, Message }, { routes: ["Services"] }) +export const page = entry.lazy(() => import("./implementation")) + +// implementation.ts +export const implementation = implementPage(entry, { + init, + update, + routeChanged, + contextChanged, + view, + subscriptions, + breadcrumbs: () => [{ label: "Services" }], +}) ``` ## Data (S6 applied) From efa924da3ecccd403576d200043df82b27ef6aa6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 12:02:53 +0100 Subject: [PATCH 2/5] fix(web): scope a failed page load to its route, keep held reads while page code loads --- apps/web/src/page-code.test.ts | 35 ++++++++++++++++++++++++++++++++++ apps/web/src/update.ts | 18 +++++++++++------ apps/web/src/view.ts | 13 ++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page-code.test.ts b/apps/web/src/page-code.test.ts index d73f0852ff..1e3cca8e83 100644 --- a/apps/web/src/page-code.test.ts +++ b/apps/web/src/page-code.test.ts @@ -113,6 +113,32 @@ describe("page code", () => { expect(logs.model.page?._tag).toBe("LogsList") }) + test("A -> B -> A while both load enters A once, and B's late code changes nothing", async () => { + const toA = settle(signedIn(AppRoute.Metrics({ search: {} }))) + const toB = settle({ ...toA.model, route: AppRoute.Errors({ search: {} }) }) + const backToA = settle({ ...toB.model, route: AppRoute.Metrics({ search: {} }) }) + expect(backToA.model.pageCode).toEqual(PageCode.Loading({ pageId: "MetricsBrowse" })) + const entered = update(backToA.model, await runLoad(toA)) + expect(entered.model.page?._tag).toBe("MetricsBrowse") + const again = update(entered.model, await runLoad(backToA)) + expect(again.model.page).toBe(entered.model.page) + const late = update(again.model, await runLoad(toB)) + expect(late.model.page).toBe(entered.model.page) + expect(late.commands ?? []).toEqual([]) + }) + + test("leaving for an unloaded page keeps the reads the open page held until the next one enters", async () => { + const first = settle(signedIn(AppRoute.Traces({ search: {} }))) + const traces = update(first.model, await runLoad(first)).model + const holder = traces.preload.holder + expect(holder).not.toBe("") + const leaving = settle({ ...traces, route: AppRoute.Releases({ search: {} }) }) + expect(leaving.model.preload).toBe(traces.preload) + expect(commandsNamed(leaving, "ReleaseReads")).toEqual([]) + const releases = update(leaving.model, await runLoad(leaving)) + expect(releases.model.preload.holder).not.toBe(holder) + }) + test("a link to a page whose code is not loaded loads it first, then runs the page's preload", async () => { const href = `http://localhost/traces/${"a".repeat(32)}` const intended = update( @@ -140,6 +166,15 @@ describe("page code", () => { const failed = update(waiting, Message.FailedLoadPageCode({ pageId: "ServiceMap", failure })).model expect(failed.pageCode).toEqual(PageCode.Failed({ pageId: "ServiceMap", failure })) expect(commandsNamed(settle(failed), "LoadPageCode")).toEqual([]) + // Another route never shows this page's failure. + scene( + { update, view: body }, + given({ ...failed, route: AppRoute.Logs({ search: {} }) }), + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()), + expectView(text("Couldn't load this page")).not.toExist(), + ) expect( (update(failed, Message.ClickedReloadPage()).commands ?? []).map(({ name, args }) => ({ name, diff --git a/apps/web/src/update.ts b/apps/web/src/update.ts index 36662a4666..5e0bbf292f 100644 --- a/apps/web/src/update.ts +++ b/apps/web/src/update.ts @@ -433,17 +433,23 @@ const loadPage: Step = (model) => const isAwaitingPageCode = (model: Model, pageId: string) => model.pageCode._tag !== "Ready" && model.pageCode.pageId === pageId -/** The route's page has no code yet: the open page is left and its code loaded, once (`Failed` waits for Reload). */ +/** + * The route's page has no code yet: the open page is left and its code loaded, once (`Failed` waits + * for Reload). Its held reads stay held until the next page enters and releases what it does not read. + */ const awaitPageCode = (model: Model, pageId: string): UpdateReturn => { const left = model.page === null - ? { model } - : writePage({ slot: null, commands: [], outMessage: Option.none() })(model) + ? model + : modifyFields(model, { + page: () => null, + parkedPages: (parked) => parkPages(parked, model.page, null), + }) return isAwaitingPageCode(model, pageId) - ? left + ? { model: left } : { - model: modifyFields(left.model, { pageCode: () => PageCode.Loading({ pageId }) }), - commands: [...(left.commands ?? []), LoadPageCode({ pageId, preloadHref: null })], + model: modifyFields(left, { pageCode: () => PageCode.Loading({ pageId }) }), + commands: [LoadPageCode({ pageId, preloadHref: null })], } } diff --git a/apps/web/src/view.ts b/apps/web/src/view.ts index 46d2c9740f..0ac75834b0 100644 --- a/apps/web/src/view.ts +++ b/apps/web/src/view.ts @@ -20,6 +20,7 @@ import { type PageMessage, pageBreadcrumbs, pageSource, + unloadedPageOf, viewPage, viewPageTopbarActions, } from "./page/registry" @@ -125,14 +126,20 @@ const lazyPageView = (model: Model, h: HtmlBuilder, position: PagePosit ), ) +/** The route's page whose code failed to load, with why; a stale failure of another page is none. */ +const failedPageCodeView = (model: Model, h: HtmlBuilder): Option.Option => { + const { pageCode } = model + return pageCode._tag === "Failed" && Option.contains(unloadedPageOf(model.route), pageCode.pageId) + ? Option.some(pageCodeFailedContent(h, pageCode.failure, Message.ClickedReloadPage())) + : Option.none() +} + /** The routed page, the not-found body for an unknown path, or a skeleton while the page loads. */ const pageView = (model: Model, h: HtmlBuilder, position: PagePosition): Html => Option.getOrElse(lazyPageView(model, h, position), () => model.route._tag === "NotFound" ? notFoundContent(h) - : model.pageCode._tag === "Failed" - ? pageCodeFailedContent(h, model.pageCode.failure, Message.ClickedReloadPage()) - : placeholderContent(h), + : Option.getOrElse(failedPageCodeView(model, h), () => placeholderContent(h)), ) /** The routed page's own top-bar actions, if it has any. */ From 956547f78c4173237979f6c21ae921356a1f4de1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 08:54:02 +0100 Subject: [PATCH 3/5] test(web): drop the removed chat sheet from the page-code fixture --- apps/web/src/page-code.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/page-code.test.ts b/apps/web/src/page-code.test.ts index 1e3cca8e83..be370d30c6 100644 --- a/apps/web/src/page-code.test.ts +++ b/apps/web/src/page-code.test.ts @@ -7,7 +7,6 @@ import { describe, expect, test, vi } from "vitest" // The shell's favorites listener targets `window` at import; nothing here touches it. vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) -import * as ChatSheet from "./page/chat/sheet" import { toLoadFailure } from "./data/load-failure" import { Message } from "./message" import { init } from "./main" @@ -40,7 +39,6 @@ const signedIn = (route: AppRoute): Model => ({ toasts: Toast.init("toasts"), toastActions: {}, toastRootActions: {}, - chatSheet: ChatSheet.init(), page: null, pageCode: PageCode.Ready(), pageInstances: 0, From 021c2bb7e5874c8a5d58793257ff32ba7d013f6f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 09:02:03 +0100 Subject: [PATCH 4/5] perf(web): start the route's page code from boot.ts, beside the app bundle The page chunk only started downloading after init, so on a real network a cold load showed the skeleton for its round trip. boot.ts now imports the route's page code right after Clerk starts. Every page entry loads through the same shared import in page/code.ts, so the registry's loadPageCode picks up the started load instead of fetching again. --- apps/web/src/boot.ts | 2 + .../web/src/page/agent-sessions/list/index.ts | 3 +- .../src/page/agent-sessions/session/index.ts | 3 +- .../web/src/page/agent-sessions/tool/index.ts | 3 +- .../src/page/agent-sessions/tools/index.ts | 3 +- apps/web/src/page/alerts/editor/index.ts | 3 +- apps/web/src/page/alerts/hub/index.ts | 3 +- apps/web/src/page/alerts/incident/index.ts | 3 +- apps/web/src/page/alerts/rule/index.ts | 3 +- apps/web/src/page/alerts/uptime-form/index.ts | 3 +- .../src/page/alerts/uptime-monitor/index.ts | 3 +- apps/web/src/page/analytics/index.ts | 3 +- apps/web/src/page/anomalies/detail/index.ts | 3 +- apps/web/src/page/anomalies/list/index.ts | 3 +- apps/web/src/page/code-review/index.ts | 3 +- apps/web/src/page/code.test.ts | 135 +++++++++++ apps/web/src/page/code.ts | 225 ++++++++++++++++++ apps/web/src/page/contract.ts | 5 +- apps/web/src/page/dashboards/list/index.ts | 3 +- .../src/page/dashboards/templates/index.ts | 3 +- apps/web/src/page/dashboards/view/index.ts | 3 +- apps/web/src/page/dashboards/widget/index.ts | 3 +- apps/web/src/page/errors/hub/index.ts | 3 +- apps/web/src/page/errors/issue/index.ts | 3 +- .../page/infra-integrations/aws/list/index.ts | 3 +- .../infra-integrations/aws/resource/index.ts | 3 +- .../cloudflare/list/index.ts | 3 +- .../cloudflare/worker/index.ts | 3 +- .../cloudflare/zone/index.ts | 3 +- .../page/infra-integrations/gcp/list/index.ts | 3 +- .../infra-integrations/gcp/workload/index.ts | 3 +- .../planetscale/db/index.ts | 3 +- .../planetscale/list/index.ts | 3 +- .../infra-integrations/railway/list/index.ts | 3 +- .../railway/service/index.ts | 3 +- .../infra-integrations/supabase/list/index.ts | 3 +- .../supabase/project/index.ts | 3 +- .../src/page/infra/containers/detail/index.ts | 3 +- .../src/page/infra/containers/list/index.ts | 3 +- apps/web/src/page/infra/hosts/detail/index.ts | 3 +- apps/web/src/page/infra/hosts/list/index.ts | 3 +- .../src/page/infra/kubernetes/node/index.ts | 3 +- .../src/page/infra/kubernetes/nodes/index.ts | 3 +- .../src/page/infra/kubernetes/pod/index.ts | 3 +- .../src/page/infra/kubernetes/pods/index.ts | 3 +- .../page/infra/kubernetes/service/index.ts | 3 +- .../page/infra/kubernetes/workload/index.ts | 3 +- .../page/infra/kubernetes/workloads/index.ts | 3 +- apps/web/src/page/infra/overview/index.ts | 3 +- apps/web/src/page/integrations/index.ts | 3 +- .../src/page/investigations/detail/index.ts | 3 +- .../web/src/page/investigations/list/index.ts | 3 +- apps/web/src/page/logs/list/index.ts | 3 +- apps/web/src/page/logs/log/index.ts | 3 +- apps/web/src/page/metrics/browse/index.ts | 3 +- apps/web/src/page/metrics/detail/index.ts | 3 +- apps/web/src/page/overview/index.ts | 3 +- apps/web/src/page/recommendations/index.ts | 3 +- apps/web/src/page/registry.ts | 2 +- apps/web/src/page/releases/detail/index.ts | 3 +- apps/web/src/page/releases/list/index.ts | 3 +- apps/web/src/page/replays/detail/index.ts | 3 +- apps/web/src/page/replays/list/index.ts | 3 +- apps/web/src/page/security/finding/index.ts | 3 +- apps/web/src/page/security/run/index.ts | 3 +- apps/web/src/page/security/tabs/index.ts | 3 +- apps/web/src/page/service-map/index.ts | 3 +- apps/web/src/page/services/detail/index.ts | 3 +- apps/web/src/page/services/list/index.ts | 3 +- apps/web/src/page/settings/account/index.ts | 3 +- apps/web/src/page/settings/index.ts | 3 +- .../src/page/settings/ingest-rule/index.ts | 3 +- apps/web/src/page/settings/mcp-page/index.ts | 3 +- apps/web/src/page/share/index.ts | 3 +- apps/web/src/page/sources/detail/index.ts | 3 +- apps/web/src/page/sources/list/index.ts | 3 +- apps/web/src/page/sources/new/index.ts | 3 +- apps/web/src/page/traces/detail/index.ts | 3 +- apps/web/src/page/traces/list/index.ts | 3 +- 79 files changed, 514 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/page/code.test.ts create mode 100644 apps/web/src/page/code.ts diff --git a/apps/web/src/boot.ts b/apps/web/src/boot.ts index 1ab0e7f2f6..7a45b67046 100644 --- a/apps/web/src/boot.ts +++ b/apps/web/src/boot.ts @@ -4,3 +4,5 @@ import "./platform/oauth-return-boot" import { startClerkBoot } from "./platform/clerk-boot" startClerkBoot() +// Not a static import: the page loaders carry every page's preload list, Clerk's start won't wait for it. +void import("./page/code").then(({ startRoutePageCode }) => startRoutePageCode(location.pathname)) diff --git a/apps/web/src/page/agent-sessions/list/index.ts b/apps/web/src/page/agent-sessions/list/index.ts index 6e6e34bbf9..f49d52dce6 100644 --- a/apps/web/src/page/agent-sessions/list/index.ts +++ b/apps/web/src/page/agent-sessions/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/agent-sessions`: the sessions list. Search changes arrive as `routeChanged` and keep the window. */ export const entry = definePage("AgentSessionsList", { Model, Message }, { routes: ["AgentSessions"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AgentSessionsList) diff --git a/apps/web/src/page/agent-sessions/session/index.ts b/apps/web/src/page/agent-sessions/session/index.ts index aaf244e93f..857ed6015d 100644 --- a/apps/web/src/page/agent-sessions/session/index.ts +++ b/apps/web/src/page/agent-sessions/session/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/agent-sessions/$sessionId`: one session's four views. One instance per session. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["AgentSession"], key: (route) => `AgentSession:${route.sessionId}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AgentSession) diff --git a/apps/web/src/page/agent-sessions/tool/index.ts b/apps/web/src/page/agent-sessions/tool/index.ts index dac98521ff..ba17626174 100644 --- a/apps/web/src/page/agent-sessions/tool/index.ts +++ b/apps/web/src/page/agent-sessions/tool/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/agent-sessions/tools/$toolName`: one tool's charts, failures and sessions. One instance per tool. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["AgentSessionTool"], key: (route) => `AgentSessionTool:${route.toolName}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AgentSessionTool) diff --git a/apps/web/src/page/agent-sessions/tools/index.ts b/apps/web/src/page/agent-sessions/tools/index.ts index 03bf61c5dd..fe27855d11 100644 --- a/apps/web/src/page/agent-sessions/tools/index.ts +++ b/apps/web/src/page/agent-sessions/tools/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/agent-sessions/tools`: every tool the window's agents called. Search changes arrive as `routeChanged`. */ export const entry = definePage("AgentSessionTools", { Model, Message }, { routes: ["AgentSessionTools"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AgentSessionTools) diff --git a/apps/web/src/page/alerts/editor/index.ts b/apps/web/src/page/alerts/editor/index.ts index 6376a3f2ac..585ed7f3ee 100644 --- a/apps/web/src/page/alerts/editor/index.ts +++ b/apps/web/src/page/alerts/editor/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/alerts/create`: a new rule, a template, a chart's prefill, or `?ruleId=` to edit one. */ export const entry = definePage( @@ -13,4 +14,4 @@ export const entry = definePage( }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AlertEditor) diff --git a/apps/web/src/page/alerts/hub/index.ts b/apps/web/src/page/alerts/hub/index.ts index 78773cf835..050aaa139c 100644 --- a/apps/web/src/page/alerts/hub/index.ts +++ b/apps/web/src/page/alerts/hub/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/alerts`: the incident-first hub (Inbox, Rules, Routing). One instance per org; tabs are search. */ export const entry = definePage("AlertsHub", { Model, Message }, { routes: ["Alerts"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AlertsHub) diff --git a/apps/web/src/page/alerts/incident/index.ts b/apps/web/src/page/alerts/incident/index.ts index 54c7ee2440..6578f5549a 100644 --- a/apps/web/src/page/alerts/incident/index.ts +++ b/apps/web/src/page/alerts/incident/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/alerts/incidents/$incidentId`: one instance per incident. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["AlertIncident"], key: (route) => route.incidentId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AlertIncident) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 3291e8fa5b..5f2e3c9243 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["AlertRule"], key: (route) => route.ruleId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AlertRule) diff --git a/apps/web/src/page/alerts/uptime-form/index.ts b/apps/web/src/page/alerts/uptime-form/index.ts index 06d3628f5f..a2eef29d7c 100644 --- a/apps/web/src/page/alerts/uptime-form/index.ts +++ b/apps/web/src/page/alerts/uptime-form/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/alerts/uptime/new`: one instance per monitor being edited, or the new one. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["AlertsUptimeNew"], key: (route) => route.search.monitorId ?? "new" }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AlertsUptimeNew) diff --git a/apps/web/src/page/alerts/uptime-monitor/index.ts b/apps/web/src/page/alerts/uptime-monitor/index.ts index d90ea13013..6d01dda812 100644 --- a/apps/web/src/page/alerts/uptime-monitor/index.ts +++ b/apps/web/src/page/alerts/uptime-monitor/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/alerts/uptime/$monitorId`: one instance per monitor. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["AlertsUptimeMonitor"], key: (route) => route.monitorId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AlertsUptimeMonitor) diff --git a/apps/web/src/page/analytics/index.ts b/apps/web/src/page/analytics/index.ts index 5c27396743..cbe7edc859 100644 --- a/apps/web/src/page/analytics/index.ts +++ b/apps/web/src/page/analytics/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** `/analytics`: web analytics. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("Analytics", { Model, Message }, { routes: ["Analytics"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Analytics) diff --git a/apps/web/src/page/anomalies/detail/index.ts b/apps/web/src/page/anomalies/detail/index.ts index d03139f354..4b21290520 100644 --- a/apps/web/src/page/anomalies/detail/index.ts +++ b/apps/web/src/page/anomalies/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/anomalies/$incidentId`: one instance per incident. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Anomaly"], key: (route) => route.incidentId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Anomaly) diff --git a/apps/web/src/page/anomalies/list/index.ts b/apps/web/src/page/anomalies/list/index.ts index 09c825ff5a..ec974adf0a 100644 --- a/apps/web/src/page/anomalies/list/index.ts +++ b/apps/web/src/page/anomalies/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/anomalies`: one instance; a search change arrives as `routeChanged`. */ export const entry = definePage("AnomaliesList", { Model, Message }, { routes: ["Anomalies"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.AnomaliesList) diff --git a/apps/web/src/page/code-review/index.ts b/apps/web/src/page/code-review/index.ts index 9ae923aae5..26ee5a3716 100644 --- a/apps/web/src/page/code-review/index.ts +++ b/apps/web/src/page/code-review/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../contract" import { Message } from "./tabs" import { Model } from "./tabs" +import { pageCode } from "../code" /** * `/code-review` and its tabs (Analytics, Pull requests, CI, Issues, Settings) as one instance per @@ -22,4 +23,4 @@ export const entry = definePage( }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.CodeReview) diff --git a/apps/web/src/page/code.test.ts b/apps/web/src/page/code.test.ts new file mode 100644 index 0000000000..187f83dd88 --- /dev/null +++ b/apps/web/src/page/code.test.ts @@ -0,0 +1,135 @@ +import { Effect, Option } from "effect" +import { fromString } from "foldkit/url" +import { describe, expect, test, vi } from "vitest" + +// The shell's favorites listener targets `window` at import; nothing here touches it. +vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) +import { AppRoute, urlToAppRoute } from "../route" +import { pageCode, pageCodeIdOf, startRoutePageCode } from "./code" +import { loadPageCode, pages, unloadedPageOf } from "./registry" + +const routeOf = (href: string): AppRoute => + urlToAppRoute(Option.getOrThrow(fromString(`http://maple.test${href}`))) + +const pathOf = (href: string) => href.split("?")[0] ?? href + +/** One URL per page route, with the route tag the app's parser gives it. */ +const samples: ReadonlyArray = [ + ["/", "Home"], + ["/account", "Account"], + ["/integrations", "Integrations"], + ["/mcp", "Mcp"], + ["/service-map", "ServiceMap"], + ["/settings", "Settings"], + ["/sources", "Sources"], + ["/sources/new", "SourcesNew"], + ["/sources/x-1", "Source"], + ["/agent-sessions/x-1", "AgentSession"], + ["/alerts/0b1e6a1c-8a5a-4c3e-9a51-1d2e3f4a5b6c", "AlertRule"], + ["/alerts/create", "AlertsCreate"], + ["/alerts/uptime/new", "AlertsUptimeNew"], + ["/alerts/uptime/x-1", "AlertsUptimeMonitor"], + ["/settings/ingestion/rules/new", "IngestRuleNew"], + ["/settings/ingestion/rules/rule-1", "IngestRule"], + ["/anomalies/0b1e6a1c-8a5a-4c3e-9a51-1d2e3f4a5b6c", "Anomaly"], + ["/code-review/ci", "CodeReviewCi"], + ["/code-review/issues", "CodeReviewIssues"], + ["/code-review/pull-requests", "CodeReviewPullRequests"], + ["/code-review/settings", "CodeReviewSettings"], + ["/security", "Security"], + ["/security/findings", "SecurityFindings"], + ["/security/runs", "SecurityRuns"], + ["/security/runs/run-1", "SecurityRun"], + ["/security/findings/0b1e6a1c-8a5a-4c3e-9a51-1d2e3f4a5b6c/x-1", "SecurityFinding"], + ["/dashboards/0b1e6a1c-8a5a-4c3e-9a51-1d2e3f4a5b6c", "Dashboard"], + ["/dashboards/templates", "DashboardsTemplates"], + ["/investigations/x-1", "Investigation"], + ["/logs/x-1", "Log"], + ["/metrics/x-1", "Metric"], + ["/recommendations/x-1", "Recommendation"], + ["/releases/x-1", "Release"], + ["/replays/x-1", "Replay"], + ["/services/x-1", "Service"], + ["/share/x-1", "Share"], + ["/traces/x-1", "Trace"], + ["/agent-sessions", "AgentSessions"], + ["/alerts", "Alerts"], + ["/analytics", "Analytics"], + ["/anomalies", "Anomalies"], + ["/code-review", "CodeReview"], + ["/dashboards", "Dashboards"], + ["/errors", "Errors"], + ["/infra", "Infra"], + ["/investigations", "Investigations"], + ["/logs", "Logs"], + ["/metrics", "Metrics"], + ["/releases", "Releases"], + ["/replays", "Replays"], + ["/services", "Services"], + ["/traces", "Traces"], + ["/agent-sessions/tools/x-1", "AgentSessionTool"], + ["/alerts/incidents/inc-1", "AlertIncident"], + ["/errors/issues/0b1e6a1c-8a5a-4c3e-9a51-1d2e3f4a5b6c", "ErrorIssue"], + ["/infra/cloudflare/x-1", "InfraCloudflareZone"], + ["/infra/containers/x-1", "InfraContainer"], + ["/infra/hosts/x-1", "InfraHost"], + ["/infra/planetscale/x-1", "InfraPlanetscaleDatabase"], + ["/infra/railway/x-1?environmentId=%22env-1%22", "InfraRailwayService"], + ["/infra/supabase/x-1", "InfraSupabaseProject"], + ["/agent-sessions/tools", "AgentSessionTools"], + ["/infra/cloudflare", "InfraCloudflare"], + ["/infra/containers", "InfraContainers"], + ["/infra/aws", "InfraAws"], + ["/infra/gcp", "InfraGcp"], + ["/infra/hosts", "InfraHosts"], + ["/infra/planetscale", "InfraPlanetscale"], + ["/infra/railway", "InfraRailway"], + ["/infra/supabase", "InfraSupabase"], + ["/dashboards/0b1e6a1c-8a5a-4c3e-9a51-1d2e3f4a5b6c/widgets/x-1", "DashboardWidget"], + ["/infra/cloudflare/workers/x-1", "InfraCloudflareWorker"], + ["/infra/aws/deployment/x-1/y-2", "InfraAwsResource"], + ["/infra/gcp/x-1/x-1", "InfraGcpResource"], + ["/infra/kubernetes/nodes/x-1", "InfraKubernetesNode"], + ["/infra/kubernetes/pods/x-1", "InfraKubernetesPod"], + ["/infra/kubernetes/services/x-1", "InfraKubernetesService"], + ["/infra/kubernetes/nodes", "InfraKubernetesNodes"], + ["/infra/kubernetes/pods", "InfraKubernetesPods"], + ["/infra/kubernetes/services", "InfraKubernetesServices"], + ["/infra/kubernetes/workloads", "InfraKubernetesWorkloads"], + ["/infra/kubernetes/workloads/deployment/x-1", "InfraKubernetesWorkload"], +] + +describe("boot page code", () => { + test("each page route's path names the page the registry renders it with", () => { + samples.forEach(([href, tag]) => { + const route = routeOf(href) + expect(route._tag, href).toBe(tag) + expect(pageCodeIdOf(pathOf(href)), href).toBe(Option.getOrUndefined(unloadedPageOf(route))) + }) + }) + + test("every page has a boot path", () => { + const covered = new Set(samples.map(([href]) => pageCodeIdOf(pathOf(href)))) + expect(Object.keys(pageCode).filter((id) => !covered.has(id))).toEqual([]) + }) + + test("paths without a page start nothing", () => { + expect(pageCodeIdOf("/sign-in")).toBeUndefined() + expect(pageCodeIdOf("/lab/buttons")).toBeUndefined() + expect(pageCodeIdOf("/traces/a/b")).toBeUndefined() + }) + + test("every registry page loads through its shared import", () => { + pages.forEach((page) => expect(page.loadModule, page.id).toBe(pageCode[page.id])) + }) + + test("the registry's load reuses the import boot.ts started", async () => { + const started = pageCode.TracesList() + startRoutePageCode("/traces") + expect(pageCode.TracesList()).toBe(started) + expect(unloadedPageOf(routeOf("/traces"))).toEqual(Option.some("TracesList")) + await Effect.runPromise(loadPageCode("TracesList")) + expect(unloadedPageOf(routeOf("/traces"))).toEqual(Option.none()) + expect(pageCode.TracesList()).toBe(started) + }) +}) diff --git a/apps/web/src/page/code.ts b/apps/web/src/page/code.ts new file mode 100644 index 0000000000..c94317501d --- /dev/null +++ b/apps/web/src/page/code.ts @@ -0,0 +1,225 @@ +// Imported by boot.ts: keep it free of imports, so the route's page code starts early. + +/** A page's chunk import, started at most once; a failed one can be started again. */ +const shared = (load: () => Promise): (() => Promise) => { + const started = new Map>() + return () => { + const loading = + started.get(load) ?? + load().catch((cause: unknown) => { + started.delete(load) + return Promise.reject(cause) + }) + started.set(load, loading) + return loading + } +} + +/** + * Every page's code, by page id. Each `index.ts` registers its own entry here, so boot.ts's early + * start and the registry's `loadPageCode` share one import. + */ +export const pageCode = { + Account: shared(() => import("./settings/account/implementation")), + AgentSession: shared(() => import("./agent-sessions/session/implementation")), + AgentSessionTool: shared(() => import("./agent-sessions/tool/implementation")), + AgentSessionTools: shared(() => import("./agent-sessions/tools/implementation")), + AgentSessionsList: shared(() => import("./agent-sessions/list/implementation")), + AlertEditor: shared(() => import("./alerts/editor/implementation")), + AlertIncident: shared(() => import("./alerts/incident/implementation")), + AlertRule: shared(() => import("./alerts/rule/implementation")), + AlertsHub: shared(() => import("./alerts/hub/implementation")), + AlertsUptimeMonitor: shared(() => import("./alerts/uptime-monitor/implementation")), + AlertsUptimeNew: shared(() => import("./alerts/uptime-form/implementation")), + Analytics: shared(() => import("./analytics/implementation")), + AnomaliesList: shared(() => import("./anomalies/list/implementation")), + Anomaly: shared(() => import("./anomalies/detail/implementation")), + CodeReview: shared(() => import("./code-review/implementation")), + DashboardTemplates: shared(() => import("./dashboards/templates/implementation")), + DashboardView: shared(() => import("./dashboards/view/implementation")), + DashboardWidget: shared(() => import("./dashboards/widget/implementation")), + DashboardsList: shared(() => import("./dashboards/list/implementation")), + ErrorIssue: shared(() => import("./errors/issue/implementation")), + ErrorsHub: shared(() => import("./errors/hub/implementation")), + InfraAws: shared(() => import("./infra-integrations/aws/list/implementation")), + InfraAwsResource: shared(() => import("./infra-integrations/aws/resource/implementation")), + InfraCloudflare: shared(() => import("./infra-integrations/cloudflare/list/implementation")), + InfraCloudflareWorker: shared(() => import("./infra-integrations/cloudflare/worker/implementation")), + InfraCloudflareZone: shared(() => import("./infra-integrations/cloudflare/zone/implementation")), + InfraContainer: shared(() => import("./infra/containers/detail/implementation")), + InfraContainers: shared(() => import("./infra/containers/list/implementation")), + InfraGcp: shared(() => import("./infra-integrations/gcp/list/implementation")), + InfraGcpResource: shared(() => import("./infra-integrations/gcp/workload/implementation")), + InfraHost: shared(() => import("./infra/hosts/detail/implementation")), + InfraHosts: shared(() => import("./infra/hosts/list/implementation")), + InfraKubernetesNode: shared(() => import("./infra/kubernetes/node/implementation")), + InfraKubernetesNodes: shared(() => import("./infra/kubernetes/nodes/implementation")), + InfraKubernetesPod: shared(() => import("./infra/kubernetes/pod/implementation")), + InfraKubernetesPods: shared(() => import("./infra/kubernetes/pods/implementation")), + InfraKubernetesServiceLens: shared(() => import("./infra/kubernetes/service/implementation")), + InfraKubernetesWorkload: shared(() => import("./infra/kubernetes/workload/implementation")), + InfraKubernetesWorkloads: shared(() => import("./infra/kubernetes/workloads/implementation")), + InfraOverview: shared(() => import("./infra/overview/implementation")), + InfraPlanetscale: shared(() => import("./infra-integrations/planetscale/list/implementation")), + InfraPlanetscaleDatabase: shared(() => import("./infra-integrations/planetscale/db/implementation")), + InfraRailway: shared(() => import("./infra-integrations/railway/list/implementation")), + InfraRailwayService: shared(() => import("./infra-integrations/railway/service/implementation")), + InfraSupabase: shared(() => import("./infra-integrations/supabase/list/implementation")), + InfraSupabaseProject: shared(() => import("./infra-integrations/supabase/project/implementation")), + IngestRuleEditor: shared(() => import("./settings/ingest-rule/implementation")), + Integrations: shared(() => import("./integrations/implementation")), + Investigation: shared(() => import("./investigations/detail/implementation")), + InvestigationsList: shared(() => import("./investigations/list/implementation")), + Log: shared(() => import("./logs/log/implementation")), + LogsList: shared(() => import("./logs/list/implementation")), + McpPage: shared(() => import("./settings/mcp-page/implementation")), + MetricDetail: shared(() => import("./metrics/detail/implementation")), + MetricsBrowse: shared(() => import("./metrics/browse/implementation")), + Overview: shared(() => import("./overview/implementation")), + Recommendation: shared(() => import("./recommendations/implementation")), + Release: shared(() => import("./releases/detail/implementation")), + ReleasesList: shared(() => import("./releases/list/implementation")), + ReplayDetail: shared(() => import("./replays/detail/implementation")), + ReplaysList: shared(() => import("./replays/list/implementation")), + Security: shared(() => import("./security/tabs/implementation")), + SecurityFinding: shared(() => import("./security/finding/implementation")), + SecurityRun: shared(() => import("./security/run/implementation")), + ServiceDetail: shared(() => import("./services/detail/implementation")), + ServiceMap: shared(() => import("./service-map/implementation")), + ServicesList: shared(() => import("./services/list/implementation")), + Settings: shared(() => import("./settings/implementation")), + Share: shared(() => import("./share/implementation")), + SourceDetail: shared(() => import("./sources/detail/implementation")), + SourcesList: shared(() => import("./sources/list/implementation")), + SourcesNew: shared(() => import("./sources/new/implementation")), + TraceDetail: shared(() => import("./traces/detail/implementation")), + TracesList: shared(() => import("./traces/list/implementation")), +} + +export type PageCodeId = keyof typeof pageCode + +/** + * Each routed path as `/segment/:/*` (`:` one segment, `*` the rest) and the page that renders it. + * Mirrors `route/routers.ts`; `code.test.ts` checks every page route against the real parser. + */ +const routePaths: ReadonlyArray = [ + ["/", "Overview"], + ["/account", "Account"], + ["/integrations", "Integrations"], + ["/mcp", "McpPage"], + ["/service-map", "ServiceMap"], + ["/settings", "Settings"], + ["/sources", "SourcesList"], + ["/sources/new", "SourcesNew"], + ["/sources/:", "SourceDetail"], + ["/agent-sessions/:", "AgentSession"], + ["/alerts/:", "AlertRule"], + ["/alerts/create", "AlertEditor"], + ["/alerts/uptime/new", "AlertsUptimeNew"], + ["/alerts/uptime/:", "AlertsUptimeMonitor"], + ["/settings/ingestion/rules/new", "IngestRuleEditor"], + ["/settings/ingestion/rules/:", "IngestRuleEditor"], + ["/anomalies/:", "Anomaly"], + ["/code-review/ci", "CodeReview"], + ["/code-review/issues", "CodeReview"], + ["/code-review/pull-requests", "CodeReview"], + ["/code-review/settings", "CodeReview"], + ["/security", "Security"], + ["/security/findings", "Security"], + ["/security/runs", "Security"], + ["/security/runs/:", "SecurityRun"], + ["/security/findings/:/:", "SecurityFinding"], + ["/dashboards/:", "DashboardView"], + ["/dashboards/templates", "DashboardTemplates"], + ["/investigations/:", "Investigation"], + ["/logs/:", "Log"], + ["/metrics/:", "MetricDetail"], + ["/recommendations/:", "Recommendation"], + ["/releases/:", "Release"], + ["/replays/:", "ReplayDetail"], + ["/services/:", "ServiceDetail"], + ["/share/:", "Share"], + ["/traces/:", "TraceDetail"], + ["/agent-sessions", "AgentSessionsList"], + ["/alerts", "AlertsHub"], + ["/analytics", "Analytics"], + ["/anomalies", "AnomaliesList"], + ["/code-review", "CodeReview"], + ["/dashboards", "DashboardsList"], + ["/errors", "ErrorsHub"], + ["/infra", "InfraOverview"], + ["/investigations", "InvestigationsList"], + ["/logs", "LogsList"], + ["/metrics", "MetricsBrowse"], + ["/releases", "ReleasesList"], + ["/replays", "ReplaysList"], + ["/services", "ServicesList"], + ["/traces", "TracesList"], + ["/agent-sessions/tools/:", "AgentSessionTool"], + ["/alerts/incidents/:", "AlertIncident"], + ["/errors/issues/:", "ErrorIssue"], + ["/infra/cloudflare/:", "InfraCloudflareZone"], + ["/infra/containers/:", "InfraContainer"], + ["/infra/hosts/:", "InfraHost"], + ["/infra/planetscale/:", "InfraPlanetscaleDatabase"], + ["/infra/railway/:", "InfraRailwayService"], + ["/infra/supabase/:", "InfraSupabaseProject"], + ["/agent-sessions/tools", "AgentSessionTools"], + ["/infra/cloudflare", "InfraCloudflare"], + ["/infra/containers", "InfraContainers"], + ["/infra/aws", "InfraAws"], + ["/infra/gcp", "InfraGcp"], + ["/infra/hosts", "InfraHosts"], + ["/infra/planetscale", "InfraPlanetscale"], + ["/infra/railway", "InfraRailway"], + ["/infra/supabase", "InfraSupabase"], + ["/dashboards/:/widgets/:", "DashboardWidget"], + ["/infra/cloudflare/workers/:", "InfraCloudflareWorker"], + ["/infra/aws/:/*", "InfraAwsResource"], + ["/infra/gcp/:/:", "InfraGcpResource"], + ["/infra/kubernetes/nodes/:", "InfraKubernetesNode"], + ["/infra/kubernetes/pods/:", "InfraKubernetesPod"], + ["/infra/kubernetes/services/:", "InfraKubernetesServiceLens"], + ["/infra/kubernetes/nodes", "InfraKubernetesNodes"], + ["/infra/kubernetes/pods", "InfraKubernetesPods"], + ["/infra/kubernetes/services", "InfraKubernetesServiceLens"], + ["/infra/kubernetes/workloads", "InfraKubernetesWorkloads"], + ["/infra/kubernetes/workloads/:/:", "InfraKubernetesWorkload"], +] + +const segmentsOf = (path: string) => path.split("/").filter((segment) => segment !== "") + +const literalCount = (pattern: ReadonlyArray) => + pattern.filter((segment) => segment !== ":" && segment !== "*").length + +const matches = (pattern: ReadonlyArray, segments: ReadonlyArray) => + pattern.at(-1) === "*" + ? segments.length >= pattern.length && + pattern.slice(0, -1).every((segment, index) => segment === ":" || segment === segments[index]) + : segments.length === pattern.length && + pattern.every((segment, index) => segment === ":" || segment === segments[index]) + +const patterns = routePaths.map(([path, id]) => ({ pattern: segmentsOf(path), id })) + +/** The page that renders `pathname`, preferring literal segments over params (`/alerts/create`). */ +export const pageCodeIdOf = (pathname: string): PageCodeId | undefined => { + const segments = segmentsOf(pathname) + return patterns + .filter(({ pattern }) => matches(pattern, segments)) + .reduce<{ readonly pattern: ReadonlyArray; readonly id: PageCodeId } | undefined>( + (best, candidate) => + best === undefined || literalCount(candidate.pattern) > literalCount(best.pattern) + ? candidate + : best, + undefined, + )?.id +} + +/** + * Called by boot.ts: the route's page code downloads beside the app bundle instead of after + * `init`. The registry's load of the same page picks up this import. + */ +export const startRoutePageCode = (pathname: string): void => { + const id = pageCodeIdOf(pathname) + if (id !== undefined) void pageCode[id]().catch(() => undefined) +} diff --git a/apps/web/src/page/contract.ts b/apps/web/src/page/contract.ts index d6f453ba99..fa0515e9a7 100644 --- a/apps/web/src/page/contract.ts +++ b/apps/web/src/page/contract.ts @@ -370,7 +370,7 @@ export const definePage = < Slot, Wrapped, keyOf, - load: () => load().then((module) => module.implementation), + loadModule: load, }), } } @@ -395,7 +395,8 @@ export interface LazyPage Option.Option - readonly load: () => Promise> + /** `implementation.ts`, through `pageCode` (`page/code.ts`): boot.ts may have started it already. */ + readonly loadModule: () => Promise<{ readonly implementation: PageImplementation }> } /** The page's code (`implementation.ts`): `implementPage(entry, { init, update, view, ... })`. */ diff --git a/apps/web/src/page/dashboards/list/index.ts b/apps/web/src/page/dashboards/list/index.ts index 46964271d5..571ec64484 100644 --- a/apps/web/src/page/dashboards/list/index.ts +++ b/apps/web/src/page/dashboards/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/dashboards`: the org's dashboards. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("DashboardsList", { Model, Message }, { routes: ["Dashboards"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.DashboardsList) diff --git a/apps/web/src/page/dashboards/templates/index.ts b/apps/web/src/page/dashboards/templates/index.ts index 4be8fc6d9b..d73a4a23ea 100644 --- a/apps/web/src/page/dashboards/templates/index.ts +++ b/apps/web/src/page/dashboards/templates/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/dashboards/templates`: the gallery; the selected template rides in `?template=`. */ export const entry = definePage("DashboardTemplates", { Model, Message }, { routes: ["DashboardsTemplates"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.DashboardTemplates) diff --git a/apps/web/src/page/dashboards/view/index.ts b/apps/web/src/page/dashboards/view/index.ts index 242a50e2ca..a7655cc18c 100644 --- a/apps/web/src/page/dashboards/view/index.ts +++ b/apps/web/src/page/dashboards/view/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/dashboards/$dashboardId`: one instance per dashboard; `mode=edit` and view params are search. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Dashboard"], key: (route) => route.dashboardId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.DashboardView) diff --git a/apps/web/src/page/dashboards/widget/index.ts b/apps/web/src/page/dashboards/widget/index.ts index 153afc5dcd..219c73044b 100644 --- a/apps/web/src/page/dashboards/widget/index.ts +++ b/apps/web/src/page/dashboards/widget/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/dashboards/$dashboardId/widgets/$widgetId`: one instance per widget. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["DashboardWidget"], key: (route) => `${route.dashboardId}/${route.widgetId}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.DashboardWidget) diff --git a/apps/web/src/page/errors/hub/index.ts b/apps/web/src/page/errors/hub/index.ts index 0ad4e9b852..35f862d39f 100644 --- a/apps/web/src/page/errors/hub/index.ts +++ b/apps/web/src/page/errors/hub/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/errors`: the issues list. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("ErrorsHub", { Model, Message }, { routes: ["Errors"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ErrorsHub) diff --git a/apps/web/src/page/errors/issue/index.ts b/apps/web/src/page/errors/issue/index.ts index 29b859e878..3e3d8a6c7a 100644 --- a/apps/web/src/page/errors/issue/index.ts +++ b/apps/web/src/page/errors/issue/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/errors/issues/$issueId`: one instance per issue; tab and window changes arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["ErrorIssue"], key: (route) => route.issueId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ErrorIssue) diff --git a/apps/web/src/page/infra-integrations/aws/list/index.ts b/apps/web/src/page/infra-integrations/aws/list/index.ts index 0cc14d2fcd..d7c19e3a94 100644 --- a/apps/web/src/page/infra-integrations/aws/list/index.ts +++ b/apps/web/src/page/infra-integrations/aws/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/aws`: the connected accounts' streamed resources, or the on-ramp while none sends any. */ export const entry = definePage("InfraAws", { Model, Message }, { routes: ["InfraAws"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraAws) diff --git a/apps/web/src/page/infra-integrations/aws/resource/index.ts b/apps/web/src/page/infra-integrations/aws/resource/index.ts index b819c9370a..3a9659d1a1 100644 --- a/apps/web/src/page/infra-integrations/aws/resource/index.ts +++ b/apps/web/src/page/infra-integrations/aws/resource/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/aws/$kind/$name`: one resource's charts; another resource is a new instance. */ export const entry = definePage( @@ -13,4 +14,4 @@ export const entry = definePage( }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraAwsResource) diff --git a/apps/web/src/page/infra-integrations/cloudflare/list/index.ts b/apps/web/src/page/infra-integrations/cloudflare/list/index.ts index e6bd1f0595..cfbc8e9452 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/list/index.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/cloudflare`: zones, Workers and queues, or why there are none yet. */ export const entry = definePage("InfraCloudflare", { Model, Message }, { routes: ["InfraCloudflare"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraCloudflare) diff --git a/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts b/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts index 9a73f01709..4dc65f5426 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/worker/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/cloudflare/workers/$scriptName`: one Worker; another script is another instance. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraCloudflareWorker"], key: (route) => `InfraCloudflareWorker:${route.scriptName}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraCloudflareWorker) diff --git a/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts b/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts index f83a31692a..6d6fd4baae 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/zone/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/cloudflare/$zoneName`: one zone's traffic, DNS and security; another zone is another instance. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraCloudflareZone"], key: (route) => `InfraCloudflareZone:${route.zoneName}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraCloudflareZone) diff --git a/apps/web/src/page/infra-integrations/gcp/list/index.ts b/apps/web/src/page/infra-integrations/gcp/list/index.ts index c052291f45..ca2d1cac18 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/index.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/gcp`: the Google Cloud workloads by view, and the resource inventory. */ export const entry = definePage("InfraGcp", { Model, Message }, { routes: ["InfraGcp"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraGcp) diff --git a/apps/web/src/page/infra-integrations/gcp/workload/index.ts b/apps/web/src/page/infra-integrations/gcp/workload/index.ts index 9268c0cc7d..d86da4edf5 100644 --- a/apps/web/src/page/infra-integrations/gcp/workload/index.ts +++ b/apps/web/src/page/infra-integrations/gcp/workload/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/gcp/$service/$name`: one workload, or a Cloud SQL instance; each is its own instance. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraGcpResource"], key: (route) => `InfraGcpResource:${route.service}:${route.name}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraGcpResource) diff --git a/apps/web/src/page/infra-integrations/planetscale/db/index.ts b/apps/web/src/page/infra-integrations/planetscale/db/index.ts index dbb58431d9..70e697d766 100644 --- a/apps/web/src/page/infra-integrations/planetscale/db/index.ts +++ b/apps/web/src/page/infra-integrations/planetscale/db/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/planetscale/$dbName`: one database's branches; a new database is a new instance. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraPlanetscaleDatabase"], key: (route) => `InfraPlanetscaleDatabase:${route.dbName}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraPlanetscaleDatabase) diff --git a/apps/web/src/page/infra-integrations/planetscale/list/index.ts b/apps/web/src/page/infra-integrations/planetscale/list/index.ts index d689d9d7f8..4a46dde00e 100644 --- a/apps/web/src/page/infra-integrations/planetscale/list/index.ts +++ b/apps/web/src/page/infra-integrations/planetscale/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/planetscale`: the database fleet, or the connect prompt. */ export const entry = definePage("InfraPlanetscale", { Model, Message }, { routes: ["InfraPlanetscale"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraPlanetscale) diff --git a/apps/web/src/page/infra-integrations/railway/list/index.ts b/apps/web/src/page/infra-integrations/railway/list/index.ts index 42271898f8..e2b10eb910 100644 --- a/apps/web/src/page/infra-integrations/railway/list/index.ts +++ b/apps/web/src/page/infra-integrations/railway/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/railway`: Railway services, or the connect card while no token is saved. */ export const entry = definePage("InfraRailway", { Model, Message }, { routes: ["InfraRailway"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraRailway) diff --git a/apps/web/src/page/infra-integrations/railway/service/index.ts b/apps/web/src/page/infra-integrations/railway/service/index.ts index 26c784ecbe..cf41cfc3d3 100644 --- a/apps/web/src/page/infra-integrations/railway/service/index.ts +++ b/apps/web/src/page/infra-integrations/railway/service/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/railway/$serviceId`: one service's usage; a new service id is a new instance. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraRailwayService"], key: (route) => `InfraRailwayService:${route.serviceId}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraRailwayService) diff --git a/apps/web/src/page/infra-integrations/supabase/list/index.ts b/apps/web/src/page/infra-integrations/supabase/list/index.ts index 9113b50536..640da30f15 100644 --- a/apps/web/src/page/infra-integrations/supabase/list/index.ts +++ b/apps/web/src/page/infra-integrations/supabase/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/supabase`: the organization's projects, or the connect on-ramp while none is connected. */ export const entry = definePage("InfraSupabase", { Model, Message }, { routes: ["InfraSupabase"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraSupabase) diff --git a/apps/web/src/page/infra-integrations/supabase/project/index.ts b/apps/web/src/page/infra-integrations/supabase/project/index.ts index 08d566968c..e49f61b8fc 100644 --- a/apps/web/src/page/infra-integrations/supabase/project/index.ts +++ b/apps/web/src/page/infra-integrations/supabase/project/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/supabase/$projectRef`: one project's charts; a new ref is a new instance. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraSupabaseProject"], key: (route) => `InfraSupabaseProject:${route.projectRef}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraSupabaseProject) diff --git a/apps/web/src/page/infra/containers/detail/index.ts b/apps/web/src/page/infra/containers/detail/index.ts index fd1cc7aa97..ac06feef46 100644 --- a/apps/web/src/page/infra/containers/detail/index.ts +++ b/apps/web/src/page/infra/containers/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/containers/$containerName`: one instance per container name; a window change arrives as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraContainer"], key: (route) => route.containerName }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraContainer) diff --git a/apps/web/src/page/infra/containers/list/index.ts b/apps/web/src/page/infra/containers/list/index.ts index 6aafdb1de7..5997dfae5a 100644 --- a/apps/web/src/page/infra/containers/list/index.ts +++ b/apps/web/src/page/infra/containers/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/containers`: the container fleet. Search changes arrive as `routeChanged`. */ export const entry = definePage("InfraContainers", { Model, Message }, { routes: ["InfraContainers"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraContainers) diff --git a/apps/web/src/page/infra/hosts/detail/index.ts b/apps/web/src/page/infra/hosts/detail/index.ts index 644c1a4fdc..527989542f 100644 --- a/apps/web/src/page/infra/hosts/detail/index.ts +++ b/apps/web/src/page/infra/hosts/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/hosts/$hostName`: one instance per host; a window change arrives as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraHost"], key: (route) => route.hostName }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraHost) diff --git a/apps/web/src/page/infra/hosts/list/index.ts b/apps/web/src/page/infra/hosts/list/index.ts index eafe147d86..eba29715fc 100644 --- a/apps/web/src/page/infra/hosts/list/index.ts +++ b/apps/web/src/page/infra/hosts/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/hosts`: the host fleet. Search changes arrive as `routeChanged`. */ export const entry = definePage("InfraHosts", { Model, Message }, { routes: ["InfraHosts"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraHosts) diff --git a/apps/web/src/page/infra/kubernetes/node/index.ts b/apps/web/src/page/infra/kubernetes/node/index.ts index 85e1968e8a..b6d3e8f78d 100644 --- a/apps/web/src/page/infra/kubernetes/node/index.ts +++ b/apps/web/src/page/infra/kubernetes/node/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/kubernetes/nodes/$nodeName`: one instance per node; a window change arrives as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraKubernetesNode"], key: (route) => route.nodeName }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesNode) diff --git a/apps/web/src/page/infra/kubernetes/nodes/index.ts b/apps/web/src/page/infra/kubernetes/nodes/index.ts index a1b91f3c8e..f11acc7e2d 100644 --- a/apps/web/src/page/infra/kubernetes/nodes/index.ts +++ b/apps/web/src/page/infra/kubernetes/nodes/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/kubernetes/nodes`: the node fleet. Search changes arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraKubernetesNodes"] }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesNodes) diff --git a/apps/web/src/page/infra/kubernetes/pod/index.ts b/apps/web/src/page/infra/kubernetes/pod/index.ts index ee3004be03..2761b0fd26 100644 --- a/apps/web/src/page/infra/kubernetes/pod/index.ts +++ b/apps/web/src/page/infra/kubernetes/pod/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/kubernetes/pods/$podName`: one instance per pod name; the window and namespace arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraKubernetesPod"], key: (route) => route.podName }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesPod) diff --git a/apps/web/src/page/infra/kubernetes/pods/index.ts b/apps/web/src/page/infra/kubernetes/pods/index.ts index 2de14a52c6..faa5d2d8c1 100644 --- a/apps/web/src/page/infra/kubernetes/pods/index.ts +++ b/apps/web/src/page/infra/kubernetes/pods/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/kubernetes/pods`: the pod fleet. Search changes (and the peek) arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraKubernetesPods"] }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesPods) diff --git a/apps/web/src/page/infra/kubernetes/service/index.ts b/apps/web/src/page/infra/kubernetes/service/index.ts index 0296596f19..e6605e7495 100644 --- a/apps/web/src/page/infra/kubernetes/service/index.ts +++ b/apps/web/src/page/infra/kubernetes/service/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** * `/infra/kubernetes/services` and `/infra/kubernetes/services/$serviceName`: the service lens. One @@ -12,4 +13,4 @@ export const entry = definePage( { routes: ["InfraKubernetesServices", "InfraKubernetesService"], key: () => "lens" }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesServiceLens) diff --git a/apps/web/src/page/infra/kubernetes/workload/index.ts b/apps/web/src/page/infra/kubernetes/workload/index.ts index ecc1eb5661..995f45e1b4 100644 --- a/apps/web/src/page/infra/kubernetes/workload/index.ts +++ b/apps/web/src/page/infra/kubernetes/workload/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/kubernetes/workloads/$kind/$workloadName`: one instance per workload; the window arrives as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraKubernetesWorkload"], key: (route) => `${route.kind}/${route.workloadName}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesWorkload) diff --git a/apps/web/src/page/infra/kubernetes/workloads/index.ts b/apps/web/src/page/infra/kubernetes/workloads/index.ts index 1ba75ba801..b87dd2de23 100644 --- a/apps/web/src/page/infra/kubernetes/workloads/index.ts +++ b/apps/web/src/page/infra/kubernetes/workloads/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../../code" /** `/infra/kubernetes/workloads`: the workloads of one kind. Search changes arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["InfraKubernetesWorkloads"] }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraKubernetesWorkloads) diff --git a/apps/web/src/page/infra/overview/index.ts b/apps/web/src/page/infra/overview/index.ts index 4552ec1c20..838aa9eed3 100644 --- a/apps/web/src/page/infra/overview/index.ts +++ b/apps/web/src/page/infra/overview/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/infra`: every source's health, what needs a look first. */ export const entry = definePage("InfraOverview", { Model, Message }, { routes: ["Infra"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InfraOverview) diff --git a/apps/web/src/page/integrations/index.ts b/apps/web/src/page/integrations/index.ts index 86663533b8..b6fc0cf3bc 100644 --- a/apps/web/src/page/integrations/index.ts +++ b/apps/web/src/page/integrations/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** `/integrations`: the hub, or one integration's page (`?integration=`). One instance for both. */ export const entry = definePage("Integrations", { Model, Message }, { routes: ["Integrations"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Integrations) diff --git a/apps/web/src/page/investigations/detail/index.ts b/apps/web/src/page/investigations/detail/index.ts index 14e61f9141..25988ef01e 100644 --- a/apps/web/src/page/investigations/detail/index.ts +++ b/apps/web/src/page/investigations/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/investigations/$id`: one instance per investigation; the tab and open action are search state. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Investigation"], key: (route) => route.id }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Investigation) diff --git a/apps/web/src/page/investigations/list/index.ts b/apps/web/src/page/investigations/list/index.ts index 2bb44e5ead..deeae168d4 100644 --- a/apps/web/src/page/investigations/list/index.ts +++ b/apps/web/src/page/investigations/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/investigations`: one instance; a search change arrives as `routeChanged`. */ export const entry = definePage("InvestigationsList", { Model, Message }, { routes: ["Investigations"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.InvestigationsList) diff --git a/apps/web/src/page/logs/list/index.ts b/apps/web/src/page/logs/list/index.ts index cafeb98812..a0a17db285 100644 --- a/apps/web/src/page/logs/list/index.ts +++ b/apps/web/src/page/logs/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/logs`: the logs stream. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("LogsList", { Model, Message }, { routes: ["Logs"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.LogsList) diff --git a/apps/web/src/page/logs/log/index.ts b/apps/web/src/page/logs/log/index.ts index e346ea79f0..bf8af272a2 100644 --- a/apps/web/src/page/logs/log/index.ts +++ b/apps/web/src/page/logs/log/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../code" /** `/logs/$logId`: one instance per log key, so picking another log mounts a fresh page. */ export const entry = definePage("Log", { Model, Message }, { routes: ["Log"], key: (route) => route.logId }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Log) diff --git a/apps/web/src/page/metrics/browse/index.ts b/apps/web/src/page/metrics/browse/index.ts index 7a807f5438..61ef7b7e0e 100644 --- a/apps/web/src/page/metrics/browse/index.ts +++ b/apps/web/src/page/metrics/browse/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/metrics`: the metric explorer. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("MetricsBrowse", { Model, Message }, { routes: ["Metrics"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.MetricsBrowse) diff --git a/apps/web/src/page/metrics/detail/index.ts b/apps/web/src/page/metrics/detail/index.ts index f6aaa7e8a8..4c9c8a986c 100644 --- a/apps/web/src/page/metrics/detail/index.ts +++ b/apps/web/src/page/metrics/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/metrics/$metricName`: one instance per metric; query changes arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Metric"], key: (route) => `Metric:${route.metricName}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.MetricDetail) diff --git a/apps/web/src/page/overview/index.ts b/apps/web/src/page/overview/index.ts index ce5d0cfe94..c355c91cd6 100644 --- a/apps/web/src/page/overview/index.ts +++ b/apps/web/src/page/overview/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** `/`: the Overview home page. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("Overview", { Model, Message }, { routes: ["Home"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Overview) diff --git a/apps/web/src/page/recommendations/index.ts b/apps/web/src/page/recommendations/index.ts index dcc8630535..9c34ab8a78 100644 --- a/apps/web/src/page/recommendations/index.ts +++ b/apps/web/src/page/recommendations/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** `/recommendations/$recommendationKey`: one instance per recommendation. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Recommendation"], key: (route) => route.recommendationKey }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Recommendation) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 309b75782d..418c834eff 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -221,7 +221,7 @@ export const loadPageCode = (id: string): Effect.Effect => onNone: () => Effect.void, onSome: (page) => Effect.tryPromise({ - try: (): Promise => page.load(), + try: (): Promise => page.loadModule().then((module) => module.implementation), catch: (cause) => new PageLoadError({ message: `The ${id} page failed to load`, pageId: id, cause }), }).pipe( diff --git a/apps/web/src/page/releases/detail/index.ts b/apps/web/src/page/releases/detail/index.ts index 957c3316b5..66b21933be 100644 --- a/apps/web/src/page/releases/detail/index.ts +++ b/apps/web/src/page/releases/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/releases/$commitSha`: one instance per commit; a search change arrives as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Release"], key: (route) => route.commitSha }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Release) diff --git a/apps/web/src/page/releases/list/index.ts b/apps/web/src/page/releases/list/index.ts index 1dcd2e367b..ef76b36c4d 100644 --- a/apps/web/src/page/releases/list/index.ts +++ b/apps/web/src/page/releases/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/releases`: one instance; a search change arrives as `routeChanged`. */ export const entry = definePage("ReleasesList", { Model, Message }, { routes: ["Releases"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ReleasesList) diff --git a/apps/web/src/page/replays/detail/index.ts b/apps/web/src/page/replays/detail/index.ts index bc6d540036..1c76d1b75d 100644 --- a/apps/web/src/page/replays/detail/index.ts +++ b/apps/web/src/page/replays/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/replays/$sessionId`: one instance per session; `t` arrives as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Replay"], key: (route) => route.sessionId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ReplayDetail) diff --git a/apps/web/src/page/replays/list/index.ts b/apps/web/src/page/replays/list/index.ts index a2a6238a13..4fa108fb6b 100644 --- a/apps/web/src/page/replays/list/index.ts +++ b/apps/web/src/page/replays/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/replays`: session replays. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("ReplaysList", { Model, Message }, { routes: ["Replays"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ReplaysList) diff --git a/apps/web/src/page/security/finding/index.ts b/apps/web/src/page/security/finding/index.ts index 176f112cb7..a0b1b7afa3 100644 --- a/apps/web/src/page/security/finding/index.ts +++ b/apps/web/src/page/security/finding/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../code" /** `/security/findings/$targetId/$fingerprint`: one instance per finding. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["SecurityFinding"], key: (route) => `${route.targetId}/${route.fingerprint}` }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.SecurityFinding) diff --git a/apps/web/src/page/security/run/index.ts b/apps/web/src/page/security/run/index.ts index fe8207fe9f..97e4967e93 100644 --- a/apps/web/src/page/security/run/index.ts +++ b/apps/web/src/page/security/run/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../code" /** `/security/runs/$runId`: one instance per run. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["SecurityRun"], key: (route) => route.runId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.SecurityRun) diff --git a/apps/web/src/page/security/tabs/index.ts b/apps/web/src/page/security/tabs/index.ts index c09d5e030e..02d461b2e3 100644 --- a/apps/web/src/page/security/tabs/index.ts +++ b/apps/web/src/page/security/tabs/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../code" /** * `/security` and its tabs (Overview, Findings, Runs) as one instance per org: a tab change keeps @@ -12,4 +13,4 @@ export const entry = definePage( { routes: ["Security", "SecurityFindings", "SecurityRuns"], key: () => "security" }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Security) diff --git a/apps/web/src/page/service-map/index.ts b/apps/web/src/page/service-map/index.ts index f5cc95eac1..86f04617ce 100644 --- a/apps/web/src/page/service-map/index.ts +++ b/apps/web/src/page/service-map/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** `/service-map`. One instance per org; search changes (window, environment, focus) arrive as `routeChanged`. */ export const entry = definePage("ServiceMap", { Model, Message }, { routes: ["ServiceMap"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ServiceMap) diff --git a/apps/web/src/page/services/detail/index.ts b/apps/web/src/page/services/detail/index.ts index 7d4d8dd4f5..c4f197c19e 100644 --- a/apps/web/src/page/services/detail/index.ts +++ b/apps/web/src/page/services/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/services/$serviceName`: one instance per service; tab, environment and window changes are `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Service"], key: (route) => route.serviceName }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ServiceDetail) diff --git a/apps/web/src/page/services/list/index.ts b/apps/web/src/page/services/list/index.ts index 9a38cbac94..7a4a839e1f 100644 --- a/apps/web/src/page/services/list/index.ts +++ b/apps/web/src/page/services/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/services`: the services list. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("ServicesList", { Model, Message }, { routes: ["Services"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.ServicesList) diff --git a/apps/web/src/page/settings/account/index.ts b/apps/web/src/page/settings/account/index.ts index 093739b33b..324cfb073a 100644 --- a/apps/web/src/page/settings/account/index.ts +++ b/apps/web/src/page/settings/account/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./model" import { Model } from "./model" +import { pageCode } from "../../code" /** `/account`: one instance; a tab switch is a search change (`routeChanged`). */ export const entry = definePage("Account", { Model, Message }, { routes: ["Account"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Account) diff --git a/apps/web/src/page/settings/index.ts b/apps/web/src/page/settings/index.ts index d72afabfc3..b8e7addbac 100644 --- a/apps/web/src/page/settings/index.ts +++ b/apps/web/src/page/settings/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** `/settings`: one instance per org; a tab switch is a search change (`routeChanged`). */ export const entry = definePage("Settings", { Model, Message }, { routes: ["Settings"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Settings) diff --git a/apps/web/src/page/settings/ingest-rule/index.ts b/apps/web/src/page/settings/ingest-rule/index.ts index 4bf3c553cd..64a47f0ca7 100644 --- a/apps/web/src/page/settings/ingest-rule/index.ts +++ b/apps/web/src/page/settings/ingest-rule/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/settings/ingestion/rules/new` and `/settings/ingestion/rules/:id`: one instance per rule, or the new one. */ export const entry = definePage( @@ -12,4 +13,4 @@ export const entry = definePage( }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.IngestRuleEditor) diff --git a/apps/web/src/page/settings/mcp-page/index.ts b/apps/web/src/page/settings/mcp-page/index.ts index 30e064ff1e..176398883e 100644 --- a/apps/web/src/page/settings/mcp-page/index.ts +++ b/apps/web/src/page/settings/mcp-page/index.ts @@ -4,6 +4,7 @@ import { definePage } from "../../contract" import { Message as SettingsMessage } from "../message" import * as McpMessage from "../mcp/message" import * as McpModel from "../mcp/model" +import { pageCode } from "../../code" export const Model = Schema.Struct({ nowMs: Schema.Number, isAdmin: Schema.Boolean, mcp: McpModel.Model }) export type Model = typeof Model.Type @@ -17,4 +18,4 @@ export type Message = typeof Message.Type /** `/mcp`: the same `McpSection` as `/settings?tab=mcp`, standalone. */ export const entry = definePage("McpPage", { Model, Message }, { routes: ["Mcp"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.McpPage) diff --git a/apps/web/src/page/share/index.ts b/apps/web/src/page/share/index.ts index 3b5df7d640..b54eb58472 100644 --- a/apps/web/src/page/share/index.ts +++ b/apps/web/src/page/share/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../code" /** * `/share/$token`: a public page outside the shell. The root renders it bare and gives it a @@ -12,4 +13,4 @@ export const entry = definePage( { routes: ["Share"], key: (route) => route.token }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.Share) diff --git a/apps/web/src/page/sources/detail/index.ts b/apps/web/src/page/sources/detail/index.ts index 4941b0f23d..eea29efc70 100644 --- a/apps/web/src/page/sources/detail/index.ts +++ b/apps/web/src/page/sources/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/sources/$sourceId`: one instance per source; a tab link is a `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Source"], key: (route) => route.sourceId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.SourceDetail) diff --git a/apps/web/src/page/sources/list/index.ts b/apps/web/src/page/sources/list/index.ts index b09fc8afeb..abe1fa3999 100644 --- a/apps/web/src/page/sources/list/index.ts +++ b/apps/web/src/page/sources/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/sources`: every source the org sends from. */ export const entry = definePage("SourcesList", { Model, Message }, { routes: ["Sources"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.SourcesList) diff --git a/apps/web/src/page/sources/new/index.ts b/apps/web/src/page/sources/new/index.ts index 9206cd76b9..612dc5930b 100644 --- a/apps/web/src/page/sources/new/index.ts +++ b/apps/web/src/page/sources/new/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/sources/new`: one instance per visit; `?platform=` or `?integration=` moves between the steps. */ export const entry = definePage("SourcesNew", { Model, Message }, { routes: ["SourcesNew"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.SourcesNew) diff --git a/apps/web/src/page/traces/detail/index.ts b/apps/web/src/page/traces/detail/index.ts index c5ebb89a0d..8fb2285ba2 100644 --- a/apps/web/src/page/traces/detail/index.ts +++ b/apps/web/src/page/traces/detail/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/traces/$traceId`: one instance per trace; `spanId`, `view` and `t` arrive as `routeChanged`. */ export const entry = definePage( @@ -9,4 +10,4 @@ export const entry = definePage( { routes: ["Trace"], key: (route) => route.traceId }, ) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.TraceDetail) diff --git a/apps/web/src/page/traces/list/index.ts b/apps/web/src/page/traces/list/index.ts index 8c63c440fe..cb672b65f3 100644 --- a/apps/web/src/page/traces/list/index.ts +++ b/apps/web/src/page/traces/list/index.ts @@ -1,8 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { pageCode } from "../../code" /** `/traces`: the trace list. One instance per org; search changes arrive as `routeChanged`. */ export const entry = definePage("TracesList", { Model, Message }, { routes: ["Traces"] }) -export const page = entry.lazy(() => import("./implementation")) +export const page = entry.lazy(pageCode.TracesList) From 8e82ad5e21e1d56d04cd8701b9580c5d599a4378 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 09:40:08 +0100 Subject: [PATCH 5/5] perf(web): import the page code statically from boot.ts Chrome holds a dynamic import of a module that the app's own graph also imports until that whole graph has loaded. On the preview the route's page chunk was requested at about 1.04 s at 4x CPU, after init, the same as with no early start. A static import puts page/code.ts in boot's own graph, so the page chunk starts beside the app bundle. --- apps/web/src/boot.ts | 6 ++++-- apps/web/src/page/code.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/boot.ts b/apps/web/src/boot.ts index 7a45b67046..f5a2b38bb4 100644 --- a/apps/web/src/boot.ts +++ b/apps/web/src/boot.ts @@ -2,7 +2,9 @@ // downloading and evaluating. The OAuth return still leaves the URL before anything reads it. import "./platform/oauth-return-boot" import { startClerkBoot } from "./platform/clerk-boot" +// Static on purpose: Chrome holds a dynamic import of a module the app's own graph also imports +// until that whole graph has loaded, which would start the page code no earlier than `init`. +import { startRoutePageCode } from "./page/code" startClerkBoot() -// Not a static import: the page loaders carry every page's preload list, Clerk's start won't wait for it. -void import("./page/code").then(({ startRoutePageCode }) => startRoutePageCode(location.pathname)) +startRoutePageCode(location.pathname) diff --git a/apps/web/src/page/code.ts b/apps/web/src/page/code.ts index c94317501d..f37c4483ea 100644 --- a/apps/web/src/page/code.ts +++ b/apps/web/src/page/code.ts @@ -1,4 +1,4 @@ -// Imported by boot.ts: keep it free of imports, so the route's page code starts early. +// In boot.ts's static graph, ahead of the app bundle: keep it free of imports. /** A page's chunk import, started at most once; a failed one can be started again. */ const shared = (load: () => Promise): (() => Promise) => {