diff --git a/apps/web/src/boot.ts b/apps/web/src/boot.ts index 1ab0e7f2f6..f5a2b38bb4 100644 --- a/apps/web/src/boot.ts +++ b/apps/web/src/boot.ts @@ -2,5 +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() +startRoutePageCode(location.pathname) 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 { 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: {}, + 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 -> 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( + 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([]) + // 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, + 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..f49d52dce6 100644 --- a/apps/web/src/page/agent-sessions/list/index.ts +++ b/apps/web/src/page/agent-sessions/list/index.ts @@ -1,20 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.AgentSessionsList) 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..857ed6015d 100644 --- a/apps/web/src/page/agent-sessions/session/index.ts +++ b/apps/web/src/page/agent-sessions/session/index.ts @@ -1,23 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.AgentSession) 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..ba17626174 100644 --- a/apps/web/src/page/agent-sessions/tool/index.ts +++ b/apps/web/src/page/agent-sessions/tool/index.ts @@ -1,27 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.AgentSessionTool) 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..fe27855d11 100644 --- a/apps/web/src/page/agent-sessions/tools/index.ts +++ b/apps/web/src/page/agent-sessions/tools/index.ts @@ -1,20 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.AgentSessionTools) 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..585ed7f3ee 100644 --- a/apps/web/src/page/alerts/editor/index.ts +++ b/apps/web/src/page/alerts/editor/index.ts @@ -1,109 +1,17 @@ -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, - }), -})) +import { pageCode } from "../../code" /** `/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(pageCode.AlertEditor) 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..050aaa139c 100644 --- a/apps/web/src/page/alerts/hub/index.ts +++ b/apps/web/src/page/alerts/hub/index.ts @@ -1,21 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.AlertsHub) 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..6578f5549a 100644 --- a/apps/web/src/page/alerts/incident/index.ts +++ b/apps/web/src/page/alerts/incident/index.ts @@ -1,41 +1,13 @@ -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 })), - }), -})) +import { pageCode } from "../../code" /** `/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(pageCode.AlertIncident) 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..5f2e3c9243 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -1,47 +1,13 @@ -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 })), - }), -})) +import { pageCode } from "../../code" /** `/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(pageCode.AlertRule) 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..a2eef29d7c 100644 --- a/apps/web/src/page/alerts/uptime-form/index.ts +++ b/apps/web/src/page/alerts/uptime-form/index.ts @@ -1,39 +1,13 @@ -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 })), - }, - ), -})) +import { pageCode } from "../../code" /** `/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(pageCode.AlertsUptimeNew) 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..6d01dda812 100644 --- a/apps/web/src/page/alerts/uptime-monitor/index.ts +++ b/apps/web/src/page/alerts/uptime-monitor/index.ts @@ -1,39 +1,13 @@ -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 })), - }), -})) +import { pageCode } from "../../code" /** `/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(pageCode.AlertsUptimeMonitor) 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..cbe7edc859 100644 --- a/apps/web/src/page/analytics/index.ts +++ b/apps/web/src/page/analytics/index.ts @@ -1,22 +1,9 @@ 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 { pageCode } from "../code" /** `/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(pageCode.Analytics) 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..4b21290520 100644 --- a/apps/web/src/page/anomalies/detail/index.ts +++ b/apps/web/src/page/anomalies/detail/index.ts @@ -1,44 +1,13 @@ -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, - }, - ), -})) +import { pageCode } from "../../code" /** `/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(pageCode.Anomaly) 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..ec974adf0a 100644 --- a/apps/web/src/page/anomalies/list/index.ts +++ b/apps/web/src/page/anomalies/list/index.ts @@ -1,82 +1,9 @@ -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, - }, - ), -})) +import { pageCode } from "../../code" /** `/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(pageCode.AnomaliesList) 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..26ee5a3716 100644 --- a/apps/web/src/page/code-review/index.ts +++ b/apps/web/src/page/code-review/index.ts @@ -1,76 +1,14 @@ -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" +import { pageCode } from "../code" /** * `/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 +20,7 @@ export const page = definePage( "CodeReviewSettings", ], key: () => "code-review", - init, - update, - routeChanged, - contextChanged, - view, - subscriptions, - breadcrumbs, }, ) + +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..f37c4483ea --- /dev/null +++ b/apps/web/src/page/code.ts @@ -0,0 +1,225 @@ +// 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) => { + 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 41317e6e4f..fa0515e9a7 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,70 @@ 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, + loadModule: load, + }), } +} + +/** 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 + /** `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, ... })`. */ +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 +420,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 +440,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 +450,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 +583,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..571ec64484 100644 --- a/apps/web/src/page/dashboards/list/index.ts +++ b/apps/web/src/page/dashboards/list/index.ts @@ -1,21 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.DashboardsList) 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..d73a4a23ea 100644 --- a/apps/web/src/page/dashboards/templates/index.ts +++ b/apps/web/src/page/dashboards/templates/index.ts @@ -1,19 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.DashboardTemplates) 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..a7655cc18c 100644 --- a/apps/web/src/page/dashboards/view/index.ts +++ b/apps/web/src/page/dashboards/view/index.ts @@ -1,32 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.DashboardView) 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..219c73044b 100644 --- a/apps/web/src/page/dashboards/widget/index.ts +++ b/apps/web/src/page/dashboards/widget/index.ts @@ -1,36 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.DashboardWidget) 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..35f862d39f 100644 --- a/apps/web/src/page/errors/hub/index.ts +++ b/apps/web/src/page/errors/hub/index.ts @@ -1,22 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.ErrorsHub) 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..3e3d8a6c7a 100644 --- a/apps/web/src/page/errors/issue/index.ts +++ b/apps/web/src/page/errors/issue/index.ts @@ -1,35 +1,13 @@ -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", - }) +import { pageCode } from "../../code" /** `/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(pageCode.ErrorIssue) 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..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,22 +1,9 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraAws) 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..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,28 +1,17 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraAwsResource) 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..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,23 +1,9 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraCloudflare) 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..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,28 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraCloudflareWorker) 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..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,28 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraCloudflareZone) 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..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,23 +1,9 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraGcp) 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..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,28 +1,13 @@ -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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraGcpResource) 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..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,26 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraPlanetscaleDatabase) 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..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,21 +1,9 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraPlanetscale) 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..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,24 +1,9 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraRailway) 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..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,27 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraRailwayService) 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..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,24 +1,9 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraSupabase) 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..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,27 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraSupabaseProject) 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..ac06feef46 100644 --- a/apps/web/src/page/infra/containers/detail/index.ts +++ b/apps/web/src/page/infra/containers/detail/index.ts @@ -1,27 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraContainer) 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..5997dfae5a 100644 --- a/apps/web/src/page/infra/containers/list/index.ts +++ b/apps/web/src/page/infra/containers/list/index.ts @@ -1,20 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraContainers) 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..527989542f 100644 --- a/apps/web/src/page/infra/hosts/detail/index.ts +++ b/apps/web/src/page/infra/hosts/detail/index.ts @@ -1,27 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraHost) 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..eba29715fc 100644 --- a/apps/web/src/page/infra/hosts/list/index.ts +++ b/apps/web/src/page/infra/hosts/list/index.ts @@ -1,20 +1,9 @@ import { definePage } from "../../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraHosts) 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..b6d3e8f78d 100644 --- a/apps/web/src/page/infra/kubernetes/node/index.ts +++ b/apps/web/src/page/infra/kubernetes/node/index.ts @@ -1,24 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraKubernetesNode) 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..f11acc7e2d 100644 --- a/apps/web/src/page/infra/kubernetes/nodes/index.ts +++ b/apps/web/src/page/infra/kubernetes/nodes/index.ts @@ -1,21 +1,13 @@ 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 { pageCode } from "../../../code" /** `/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(pageCode.InfraKubernetesNodes) 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..2761b0fd26 100644 --- a/apps/web/src/page/infra/kubernetes/pod/index.ts +++ b/apps/web/src/page/infra/kubernetes/pod/index.ts @@ -1,24 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraKubernetesPod) 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..faa5d2d8c1 100644 --- a/apps/web/src/page/infra/kubernetes/pods/index.ts +++ b/apps/web/src/page/infra/kubernetes/pods/index.ts @@ -1,21 +1,13 @@ 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 { pageCode } from "../../../code" /** `/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(pageCode.InfraKubernetesPods) 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..e6605e7495 100644 --- a/apps/web/src/page/infra/kubernetes/service/index.ts +++ b/apps/web/src/page/infra/kubernetes/service/index.ts @@ -1,26 +1,16 @@ 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 { pageCode } from "../../../code" /** * `/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(pageCode.InfraKubernetesServiceLens) 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..995f45e1b4 100644 --- a/apps/web/src/page/infra/kubernetes/workload/index.ts +++ b/apps/web/src/page/infra/kubernetes/workload/index.ts @@ -1,25 +1,13 @@ 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" +import { pageCode } from "../../../code" /** `/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(pageCode.InfraKubernetesWorkload) 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..b87dd2de23 100644 --- a/apps/web/src/page/infra/kubernetes/workloads/index.ts +++ b/apps/web/src/page/infra/kubernetes/workloads/index.ts @@ -1,21 +1,13 @@ 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 { pageCode } from "../../../code" /** `/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(pageCode.InfraKubernetesWorkloads) 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..838aa9eed3 100644 --- a/apps/web/src/page/infra/overview/index.ts +++ b/apps/web/src/page/infra/overview/index.ts @@ -1,21 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, infraSurfacesSettled, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.InfraOverview) 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..b6fc0cf3bc 100644 --- a/apps/web/src/page/integrations/index.ts +++ b/apps/web/src/page/integrations/index.ts @@ -1,32 +1,9 @@ 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" +import { pageCode } from "../code" /** `/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(pageCode.Integrations) 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..25988ef01e 100644 --- a/apps/web/src/page/investigations/detail/index.ts +++ b/apps/web/src/page/investigations/detail/index.ts @@ -1,23 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.Investigation) 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..deeae168d4 100644 --- a/apps/web/src/page/investigations/list/index.ts +++ b/apps/web/src/page/investigations/list/index.ts @@ -1,19 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.InvestigationsList) 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..a0a17db285 100644 --- a/apps/web/src/page/logs/list/index.ts +++ b/apps/web/src/page/logs/list/index.ts @@ -1,22 +1,9 @@ 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 { pageCode } from "../../code" /** `/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(pageCode.LogsList) 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..bf8af272a2 100644 --- a/apps/web/src/page/logs/log/index.ts +++ b/apps/web/src/page/logs/log/index.ts @@ -1,18 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.Log) 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..61ef7b7e0e 100644 --- a/apps/web/src/page/metrics/browse/index.ts +++ b/apps/web/src/page/metrics/browse/index.ts @@ -1,20 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.MetricsBrowse) 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..4c9c8a986c 100644 --- a/apps/web/src/page/metrics/detail/index.ts +++ b/apps/web/src/page/metrics/detail/index.ts @@ -1,26 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.MetricDetail) 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..c355c91cd6 100644 --- a/apps/web/src/page/overview/index.ts +++ b/apps/web/src/page/overview/index.ts @@ -1,23 +1,9 @@ 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" +import { pageCode } from "../code" /** `/`: 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(pageCode.Overview) 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..9c34ab8a78 100644 --- a/apps/web/src/page/recommendations/index.ts +++ b/apps/web/src/page/recommendations/index.ts @@ -1,19 +1,13 @@ import { definePage } from "../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { breadcrumbsOf, view } from "./view" +import { pageCode } from "../code" /** `/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(pageCode.Recommendation) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 905ede7194..418c834eff 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.loadModule().then((module) => module.implementation), + 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..66b21933be 100644 --- a/apps/web/src/page/releases/detail/index.ts +++ b/apps/web/src/page/releases/detail/index.ts @@ -1,21 +1,13 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { breadcrumbsOf, view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.Release) 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..ef76b36c4d 100644 --- a/apps/web/src/page/releases/list/index.ts +++ b/apps/web/src/page/releases/list/index.ts @@ -1,20 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.ReleasesList) 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..1c76d1b75d 100644 --- a/apps/web/src/page/replays/detail/index.ts +++ b/apps/web/src/page/replays/detail/index.ts @@ -1,22 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.ReplayDetail) 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..4fa108fb6b 100644 --- a/apps/web/src/page/replays/list/index.ts +++ b/apps/web/src/page/replays/list/index.ts @@ -1,21 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, preload, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.ReplaysList) 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..a0b1b7afa3 100644 --- a/apps/web/src/page/security/finding/index.ts +++ b/apps/web/src/page/security/finding/index.ts @@ -1,18 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.SecurityFinding) 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..97e4967e93 100644 --- a/apps/web/src/page/security/run/index.ts +++ b/apps/web/src/page/security/run/index.ts @@ -1,47 +1,13 @@ -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" +import { pageCode } from "../../code" /** `/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(pageCode.SecurityRun) 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..02d461b2e3 100644 --- a/apps/web/src/page/security/tabs/index.ts +++ b/apps/web/src/page/security/tabs/index.ts @@ -1,25 +1,16 @@ 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" +import { pageCode } from "../../code" /** * `/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(pageCode.Security) 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..86f04617ce 100644 --- a/apps/web/src/page/service-map/index.ts +++ b/apps/web/src/page/service-map/index.ts @@ -1,22 +1,9 @@ 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 { pageCode } from "../code" /** `/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(pageCode.ServiceMap) 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..c4f197c19e 100644 --- a/apps/web/src/page/services/detail/index.ts +++ b/apps/web/src/page/services/detail/index.ts @@ -1,21 +1,13 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { contextChanged, init, routeChanged, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.ServiceDetail) 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..7a4a839e1f 100644 --- a/apps/web/src/page/services/list/index.ts +++ b/apps/web/src/page/services/list/index.ts @@ -1,24 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.ServicesList) 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..324cfb073a 100644 --- a/apps/web/src/page/settings/account/index.ts +++ b/apps/web/src/page/settings/account/index.ts @@ -1,18 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.Account) 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..b8e7addbac 100644 --- a/apps/web/src/page/settings/index.ts +++ b/apps/web/src/page/settings/index.ts @@ -1,21 +1,9 @@ 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" +import { pageCode } from "../code" /** `/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(pageCode.Settings) 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..64a47f0ca7 100644 --- a/apps/web/src/page/settings/ingest-rule/index.ts +++ b/apps/web/src/page/settings/ingest-rule/index.ts @@ -1,23 +1,16 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.IngestRuleEditor) 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..176398883e --- /dev/null +++ b/apps/web/src/page/settings/mcp-page/index.ts @@ -0,0 +1,21 @@ +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" +import { pageCode } from "../../code" + +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(pageCode.McpPage) 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..b54eb58472 100644 --- a/apps/web/src/page/share/index.ts +++ b/apps/web/src/page/share/index.ts @@ -1,25 +1,16 @@ 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" +import { pageCode } from "../code" /** * `/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(pageCode.Share) 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..eea29efc70 100644 --- a/apps/web/src/page/sources/detail/index.ts +++ b/apps/web/src/page/sources/detail/index.ts @@ -1,22 +1,13 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.SourceDetail) 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..abe1fa3999 100644 --- a/apps/web/src/page/sources/list/index.ts +++ b/apps/web/src/page/sources/list/index.ts @@ -1,18 +1,9 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" -import { init, update } from "./update" -import { view } from "./view" +import { pageCode } from "../../code" /** `/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(pageCode.SourcesList) 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..612dc5930b 100644 --- a/apps/web/src/page/sources/new/index.ts +++ b/apps/web/src/page/sources/new/index.ts @@ -1,21 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.SourcesNew) 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..8fb2285ba2 100644 --- a/apps/web/src/page/traces/detail/index.ts +++ b/apps/web/src/page/traces/detail/index.ts @@ -1,25 +1,13 @@ -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" +import { pageCode } from "../../code" /** `/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(pageCode.TraceDetail) 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..cb672b65f3 100644 --- a/apps/web/src/page/traces/list/index.ts +++ b/apps/web/src/page/traces/list/index.ts @@ -1,23 +1,9 @@ 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" +import { pageCode } from "../../code" /** `/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(pageCode.TracesList) 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..5e0bbf292f 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,59 @@ 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). 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 + : modifyFields(model, { + page: () => null, + parkedPages: (parked) => parkPages(parked, model.page, null), + }) + return isAwaitingPageCode(model, pageId) + ? { model: left } + : { + model: modifyFields(left, { pageCode: () => PageCode.Loading({ pageId }) }), + 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 +966,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 +1051,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..0ac75834b0 100644 --- a/apps/web/src/view.ts +++ b/apps/web/src/view.ts @@ -7,13 +7,20 @@ 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, type PageMessage, pageBreadcrumbs, pageSource, + unloadedPageOf, viewPage, viewPageTopbarActions, } from "./page/registry" @@ -119,10 +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) : placeholderContent(h), + model.route._tag === "NotFound" + ? notFoundContent(h) + : Option.getOrElse(failedPageCodeView(model, h), () => placeholderContent(h)), ) /** The routed page's own top-bar actions, if it has any. */ @@ -243,7 +260,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)