From 90fafc2370aa05128216866d28ad11d6d02cfeb2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:26:26 +0100 Subject: [PATCH 1/2] fix(web): wire API reachability, lenient string search params, sidebar cookie, boot flags - Reachability: the client's request wrapper now starts the origin's failure run on any transport failure (the 45 s deadline included) and ends it on any response, and the warehouse layer provides ApiReachability from that clock. Nothing provided it before, so a dropped connection inside the 15 s grace window was reported as a WarehouseQueryError instead of WarehouseUnreachableError. - Search params: string params decode through one lenient codec, so a link written by hand with an unquoted number, boolean or null (?userId=12345, ?search=404, ?q=true) opens the page instead of NotFound. String arrays take the same values. It replaces the one-off DigitsParam on the Cloud SQL peek. The search parser keeps a number that does not print back as written (past 2^53, exponent form) as text, so a long numeric id is not rounded. - Sidebar: the sidebar_state cookie written on every toggle is read with the other boot flags, so a collapsed sidebar stays collapsed after a reload. - Flags: the schema carries shell.platform and shell.isSidebarOpen, which a decode of the flags used to strip. - search.ts imports the dashboard variable prefix and the errors hub vocabulary from their owners instead of redeclaring them, and drops comments describing a router this app no longer uses. --- apps/web/src/data/warehouse.ts | 9 + .../lib/services/common/http-client.test.ts | 59 ++- .../src/lib/services/common/http-client.ts | 43 +- apps/web/src/main.test.ts | 27 ++ apps/web/src/main.ts | 2 + apps/web/src/route/route.test.ts | 41 ++ apps/web/src/route/search.ts | 437 ++++++++---------- apps/web/src/route/tanstack-search.ts | 8 +- apps/web/src/shell/command.ts | 2 +- apps/web/src/shell/flags.test.ts | 26 ++ apps/web/src/shell/flags.ts | 5 + apps/web/src/shell/index.ts | 1 + apps/web/src/shell/model.ts | 4 +- 13 files changed, 402 insertions(+), 262 deletions(-) create mode 100644 apps/web/src/main.test.ts create mode 100644 apps/web/src/shell/flags.test.ts diff --git a/apps/web/src/data/warehouse.ts b/apps/web/src/data/warehouse.ts index 03784a0ccc..f9a0109dc8 100644 --- a/apps/web/src/data/warehouse.ts +++ b/apps/web/src/data/warehouse.ts @@ -1,10 +1,13 @@ import { QueryEngineExecuteBatchRequest } from "@maple/query-engine" +import { isBlipping, originOf } from "@maple/web-data/lib/peer-reachability" import { + ApiReachability, MapleApiV2Http, MapleInternalHttp, WarehouseExecutor, } from "@maple/web-data/warehouse/warehouse-services" import { Effect, Layer } from "effect" +import { apiBaseUrl } from "../lib/services/common/api-base-url" import { MapleApiV2Client, MapleInternalApiClient } from "../platform/api-clients" import { withResourceTiming } from "../lib/resource-timing" import { makeExecuteBatcher } from "./execute-batcher" @@ -32,8 +35,14 @@ const WarehouseExecutorLive = Layer.effect( }), ) +/** The API origin's reachability clock, which the HTTP client keeps (`withRequestTimeout`). */ +export const ApiReachabilityLive = Layer.succeed(ApiReachability, { + isBlipping: (nowMs) => isBlipping(originOf(apiBaseUrl), nowMs), +}) + export const WarehouseLive = Layer.mergeAll( WarehouseExecutorLive, + ApiReachabilityLive, Layer.effect(MapleInternalHttp, Effect.service(MapleInternalApiClient)), Layer.effect(MapleApiV2Http, Effect.service(MapleApiV2Client)), ).pipe(Layer.provide([MapleInternalApiClient.layer, MapleApiV2Client.layer])) diff --git a/apps/web/src/lib/services/common/http-client.test.ts b/apps/web/src/lib/services/common/http-client.test.ts index 634d7e5151..b5fd614972 100644 --- a/apps/web/src/lib/services/common/http-client.test.ts +++ b/apps/web/src/lib/services/common/http-client.test.ts @@ -1,17 +1,28 @@ import { assert, describe, it } from "@effect/vitest" -import { Effect, Fiber, Result } from "effect" +import { MapleInternalApi } from "@maple/domain/http" +import { Effect, Fiber, Layer, Result } from "effect" +import { HttpApiClient } from "effect/http-api" import { TestClock } from "effect/testing" import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http" +import { PEER_OUTAGE_GRACE_MS } from "@maple/web-data/lib/peer-reachability" +import { + MapleInternalHttp, + runWarehouseQuery, + WarehouseQueryError, + WarehouseUnreachableError, +} from "@maple/web-data/warehouse/effect-utils" +import { ApiReachabilityLive } from "../../../data/warehouse" +import { apiBaseUrl } from "./api-base-url" import { withRequestTimeout } from "./http-client" import { isRetryableTransportError } from "./retry-policy" const NEVER_RESPONDS: typeof globalThis.fetch = () => new Promise(() => {}) -const get = (fetchStub: typeof globalThis.fetch) => +const get = (fetchStub: typeof globalThis.fetch, url = "https://api.test/anything") => Effect.gen(function* () { const client = withRequestTimeout(yield* HttpClient.HttpClient) - return yield* client.get("https://api.test/anything") + return yield* client.get(url) }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, fetchStub)) /** @@ -56,3 +67,45 @@ describe("withRequestTimeout", () => { }), ) }) + +const DROPS: typeof globalThis.fetch = () => Promise.reject(new TypeError("Failed to fetch")) +const ANSWERS_500: typeof globalThis.fetch = async () => new Response("{}", { status: 500 }) + +// `runWarehouseQuery` runs on the internal client; these reads go through `get` instead. +const InternalHttp = Layer.effect(MapleInternalHttp, HttpApiClient.make(MapleInternalApi)).pipe( + Layer.provide(FetchHttpClient.layer), +) + +/** A warehouse read over the app's client, failing as the dashboard would report it. */ +const queryFailure = (fetchStub: typeof globalThis.fetch) => + runWarehouseQuery("listThings", () => get(fetchStub, `${apiBaseUrl}/internal/things`)).pipe( + Effect.provide([ApiReachabilityLive, InternalHttp]), + Effect.flip, + ) + +describe("API reachability", () => { + it.effect("reports a dropped connection as unreachable, then as an outage once it lasts", () => + Effect.gen(function* () { + // Any response ends a run left over from another test. + yield* get(ANSWERS_500, `${apiBaseUrl}/internal/things`) + + assert.instanceOf(yield* queryFailure(DROPS), WarehouseUnreachableError) + + yield* TestClock.adjust(PEER_OUTAGE_GRACE_MS) + assert.instanceOf(yield* queryFailure(DROPS), WarehouseQueryError) + }), + ) + + it.effect("starts a new grace window after the API answers, even with a 500", () => + Effect.gen(function* () { + yield* get(ANSWERS_500, `${apiBaseUrl}/internal/things`) + assert.instanceOf(yield* queryFailure(DROPS), WarehouseUnreachableError) + + yield* TestClock.adjust(PEER_OUTAGE_GRACE_MS - 1) + yield* get(ANSWERS_500, `${apiBaseUrl}/internal/things`) + yield* TestClock.adjust(PEER_OUTAGE_GRACE_MS - 1) + + assert.instanceOf(yield* queryFailure(DROPS), WarehouseUnreachableError) + }), + ) +}) diff --git a/apps/web/src/lib/services/common/http-client.ts b/apps/web/src/lib/services/common/http-client.ts index 602164866e..ca301233ea 100644 --- a/apps/web/src/lib/services/common/http-client.ts +++ b/apps/web/src/lib/services/common/http-client.ts @@ -1,32 +1,45 @@ import { HttpClient, HttpClientError } from "effect/http" import { Clock, Duration, Effect } from "effect" -import { noteUnreachable, originOf } from "@maple/web-data/lib/peer-reachability" +import { noteReachable, noteUnreachable, originOf } from "@maple/web-data/lib/peer-reachability" const CLIENT_TIMEOUT = Duration.seconds(45) +const isTransportError = (error: unknown): boolean => + HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError" + /** * The client-wide request deadline. Timing out the fiber interrupts it, which - * aborts the in-flight fetch, and the origin is marked unreachable here. + * aborts the in-flight fetch. + * + * It also keeps the origin's reachability clock (`peer-reachability.ts`): a + * request that got no response (the deadline included) starts or extends the + * run of failures, and any response at all, a 500 included, ends it. */ export const withRequestTimeout = (client: HttpClient.HttpClient): HttpClient.HttpClient => - HttpClient.transform(client, (effect, request) => - effect.pipe( + HttpClient.transform(client, (effect, request) => { + const origin = originOf(request.url) + return effect.pipe( Effect.timeoutOrElse({ duration: CLIENT_TIMEOUT, - orElse: () => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis - noteUnreachable(originOf(request.url), now) - const description = `No response within ${Duration.toMillis(CLIENT_TIMEOUT)}ms` - // A `TimeoutError` cause marks it non-retryable (`isRetryableTransportError`). - return yield* new HttpClientError.HttpClientError({ + orElse: () => { + const description = `No response within ${Duration.toMillis(CLIENT_TIMEOUT)}ms` + // A `TimeoutError` cause marks it non-retryable (`isRetryableTransportError`). + return Effect.fail( + new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, description, cause: new DOMException(description, "TimeoutError"), }), - }) - }), + }), + ) + }, }), - ), - ) + Effect.tap(() => Effect.sync(() => noteReachable(origin))), + Effect.tapError((error) => + isTransportError(error) + ? Effect.map(Clock.currentTimeMillis, (now) => noteUnreachable(origin, now)) + : Effect.void, + ), + ) + }) diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts new file mode 100644 index 0000000000..fd93028d46 --- /dev/null +++ b/apps/web/src/main.test.ts @@ -0,0 +1,27 @@ +import { Schema } from "effect" +import { describe, expect, test, vi } from "vitest" + +// The shell's favorites listener targets `window` at import. +vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) +import { Flags } from "./main" +import * as Shell from "./shell" + +describe("Flags", () => { + test("flags that went through the codec still carry every shell flag the init reads", () => { + const decoded = Schema.decodeSync(Flags)({ + hasStoredSession: false, + timeZone: "UTC", + knownToRenderApp: {}, + shell: { + isSidebarOpen: false, + isMobile: false, + theme: "light", + pinnedNamespaces: {}, + dashboardFavorites: [], + platform: "windows", + }, + }) + + expect(Shell.init(decoded.shell)).toMatchObject({ platform: "windows", isSidebarOpen: false }) + }) +}) diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 2ecf6c44e7..f28b138d1f 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -31,6 +31,7 @@ export const Flags = Schema.Struct({ timeZone: Schema.String, knownToRenderApp: Schema.Record(Schema.String, Schema.Boolean), shell: Schema.Struct({ + isSidebarOpen: Schema.optionalKey(Schema.Boolean), isMobile: Schema.Boolean, isFilterCollapsed: Schema.optionalKey(Schema.Boolean), isBelowSm: Schema.optionalKey(Schema.Boolean), @@ -39,6 +40,7 @@ export const Flags = Schema.Struct({ dashboardFavorites: Schema.Array(Schema.String), rememberedSurfaces: Schema.optionalKey(Schema.Record(Schema.String, Schema.Array(Shell.NavSurface))), isQuotaApproachingDismissed: Schema.optionalKey(Schema.Boolean), + platform: Schema.optionalKey(Shell.Platform), }), }) export type Flags = typeof Flags.Type diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index e9d860e27a..2c35a58573 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -25,6 +25,47 @@ describe("routes", () => { } }) + test("a string param a link wrote unquoted decodes to its text", () => { + expect(parse("/replays?userId=12345")).toMatchObject({ _tag: "Replays", search: { userId: "12345" } }) + expect(parse("/logs?search=404")).toMatchObject({ _tag: "Logs", search: { search: "404" } }) + expect(parse("/users?q=true")).toMatchObject({ _tag: "Users", search: { q: "true" } }) + expect(parse("/metrics?q=null")).toMatchObject({ _tag: "Metrics", search: { q: "null" } }) + expect(parse("/traces?httpStatusCodes=%5B404%2C%22500%22%5D")).toMatchObject({ + _tag: "Traces", + search: { httpStatusCodes: ["404", "500"] }, + }) + }) + + test("an unquoted id too long or too odd for a number keeps every character", () => { + // Past 2^53, and exponent form: both would change when read as a number. + expect(parse("/traces/abc?spanId=9876543210987654321")).toMatchObject({ + _tag: "Trace", + search: { spanId: "9876543210987654321" }, + }) + expect(parse("/logs?traceId=1e5")).toMatchObject({ _tag: "Logs", search: { traceId: "1e5" } }) + // A span id is 16 hex characters, which can all be digits. + expect(parse("/agent-sessions/s1?span=1234567890123456")).toMatchObject({ + _tag: "AgentSession", + search: { span: "1234567890123456" }, + }) + // The one required string param. + expect(parse("/infra/railway/svc?environmentId=42")).toMatchObject({ + _tag: "InfraRailwayService", + search: { environmentId: "42" }, + }) + }) + + test("a numeric-looking string is written quoted and reads back the same", () => { + const route = parse("/replays?userId=12345") + expect(hrefOf(route)).toBe("/replays?userId=%2212345%22") + expect(parse(hrefOf(route))).toEqual(route) + }) + + test("a number param still reads a number the text does not print back as", () => { + expect(parse("/replays?durationMin=1e3")).toMatchObject({ search: { durationMin: 1000 } }) + expect(parse("/traces?minDurationMs=1.50")).toMatchObject({ search: { minDurationMs: 1.5 } }) + }) + test("a user page takes any id the API accepts, decoded, and nothing longer", () => { // Cloudflare delivers an encoded `%2F` as a real slash, so the id is the rest of the path. const route = parse("/users/user/42%20ada") diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index c676941169..f2f0f5c713 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -19,9 +19,27 @@ import { } from "@maple/domain/http" import { PushSourcePlatform } from "@maple/domain/primitives" import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" +import { VARIABLE_PARAM_PREFIX } from "../lib/dashboard-controls/search-params" +import { HUB_SORTS, HUB_VIEWS, LEGACY_HUB_VIEWS, SEVERITY_FILTERS } from "../lib/errors/hub-params" -// apps/web/src/lib/search-params.ts -const StringArray = Schema.Array(Schema.String) +/** + * A string as a link wrote it. The search format reads an unquoted `404`, `true` or `null` as + * JSON, so a hand-written `?userId=12345` arrives as a number: it prints back to its text. + */ +const LenientString = Schema.Union([ + Schema.String, + Schema.Union([Schema.Number, Schema.Boolean, Schema.Null]).pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((value: number | boolean | null) => String(value)), + // Never reached: the plain string member encodes every string first. + encode: SchemaGetter.forbidden(() => "A string param is written as a string"), + }), + ), +]) + +const StringParam = Schema.optional(LenientString) + +const StringArray = Schema.Array(LenientString) const StringArrayFromJsonString = Schema.fromJsonString(Schema.Array(Schema.String)) export const BooleanFromStringParam = Schema.fromJsonString(Schema.Boolean) @@ -32,38 +50,27 @@ export const OptionalStringArrayParam = Schema.optional( Schema.Union([StringArray, StringArrayFromJsonString]), ) -/** A string of digits a URL wrote unquoted, which the search format parses as a number. */ -const DigitsParam = Schema.Number.pipe( - Schema.decodeTo(Schema.String, { - decode: SchemaGetter.transform((value: number) => String(value)), - encode: SchemaGetter.transform((value: string) => Number(value)), - }), -) - const BooleanParam = Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])) const NumberParam = Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])) // Time range export const TimeRangeSearchFields = { - startTime: Schema.optional(Schema.String), - endTime: Schema.optional(Schema.String), - timePreset: Schema.optional(Schema.String), + startTime: StringParam, + endTime: StringParam, + timePreset: StringParam, } -// apps/web/src/lib/dashboard-controls/search-params.ts -export const VARIABLE_PARAM_PREFIX = "var-" - export const variableSearchRest = Schema.Record( Schema.TemplateLiteral([VARIABLE_PARAM_PREFIX, Schema.String]), Schema.Unknown, ) export const dashboardViewParamsSchema = { - filter: Schema.optional(Schema.String), - collapsed: Schema.optional(Schema.String), - expanded: Schema.optional(Schema.String), - tab: Schema.optional(Schema.String), - widget: Schema.optional(Schema.String), + filter: StringParam, + collapsed: StringParam, + expanded: StringParam, + tab: StringParam, + widget: StringParam, refresh: Schema.optional(Schema.Union([Schema.Number, Schema.String])), /** This viewer's time range; read leniently by `timeRangeFromSearch`, so a bad value is ignored. */ range: Schema.optional(Schema.Unknown), @@ -78,39 +85,39 @@ const TOOL_PERCENTILES = ["p50", "p90", "p95"] as const const ToolAnalyticsSearchFields = { metric: Schema.optional(Schema.Literals(TOOL_METRICS)), percentile: Schema.optional(Schema.Literals(TOOL_PERCENTILES)), - tool: Schema.optional(Schema.String), - model: Schema.optional(Schema.String), - q: Schema.optional(Schema.String), - service: Schema.optional(Schema.String), - env: Schema.optional(Schema.String), + tool: StringParam, + model: StringParam, + q: StringParam, + service: StringParam, + env: StringParam, failing: BooleanParam, - error: Schema.optional(Schema.String), - session: Schema.optional(Schema.String), - variant: Schema.optional(Schema.String), + error: StringParam, + session: StringParam, + variant: StringParam, } // apps/web/src/components/analytics/filters.ts const analyticsFilterSearchFields = { - host: Schema.optional(Schema.String), - pagePath: Schema.optional(Schema.String), - referrerHost: Schema.optional(Schema.String), - country: Schema.optional(Schema.String), - deviceType: Schema.optional(Schema.String), - browserName: Schema.optional(Schema.String), - osName: Schema.optional(Schema.String), - language: Schema.optional(Schema.String), - utmSource: Schema.optional(Schema.String), - utmMedium: Schema.optional(Schema.String), - utmCampaign: Schema.optional(Schema.String), + host: StringParam, + pagePath: StringParam, + referrerHost: StringParam, + country: StringParam, + deviceType: StringParam, + browserName: StringParam, + osName: StringParam, + language: StringParam, + utmSource: StringParam, + utmMedium: StringParam, + utmCampaign: StringParam, visitorType: Schema.optional(Schema.Literals(["new", "returning"])), traffic: Schema.optional(Schema.Literals(["all", "humans", "bots"])), - eventName: Schema.optional(Schema.String), + eventName: StringParam, } // Code review const CodeReviewSearchFields = { repo: Schema.optional(VcsRepositoryId), - author: Schema.optional(Schema.String), + author: StringParam, ...TimeRangeSearchFields, } @@ -119,14 +126,14 @@ const CodeReviewIssuesSearchFields = { severity: Schema.optional(PrReviewSeverity), category: Schema.optional(PrReviewCategory), state: Schema.optional(PrReviewFindingStatus), - q: Schema.optional(Schema.String), + q: StringParam, review: Schema.optional(PrReviewId), } const CodeReviewListSearchFields = { ...CodeReviewSearchFields, status: Schema.optional(Schema.Literals(["completed", "failed", "skipped", "running", "queued"])), - q: Schema.optional(Schema.String), + q: StringParam, sortBy: Schema.optional(CodeReviewSortKey), sortDir: Schema.optional(CodeReviewSortDir), review: Schema.optional(PrReviewId), @@ -168,7 +175,7 @@ const cloudflareFilterSearchFields = { cacheStatuses: OptionalStringArrayParam, statusClasses: OptionalStringArrayParam, paths: OptionalStringArrayParam, - pathContains: Schema.optional(Schema.String), + pathContains: StringParam, countries: OptionalStringArrayParam, methods: OptionalStringArrayParam, protocols: OptionalStringArrayParam, @@ -185,15 +192,9 @@ const planetscaleFilterSearchFields = { branches: OptionalStringArrayParam, branchStates: OptionalStringArrayParam, branchRoles: OptionalStringArrayParam, - branchContains: Schema.optional(Schema.String), + branchContains: StringParam, } -// apps/web/src/lib/errors/hub-params.ts -const HUB_VIEWS = ["open", "closed", "all"] as const -const LEGACY_HUB_VIEWS = ["triage", "active", "resolved"] as const -const HUB_SORTS = ["last_seen", "volume", "severity"] as const -const SEVERITY_FILTERS = ["all", "critical", "high", "medium", "low", "unset"] as const - // Tab sets of the account, settings (plus retired settings tabs), issue and investigation navs export const accountTabValues = [ "profile", @@ -247,9 +248,8 @@ export const ErrorIssuesSearch = Schema.Struct({ // Top-level routes -// sessionTimeRangeSearchMiddleware() export const HomeSearch = Schema.Struct({ - environment: Schema.optional(Schema.String), + environment: StringParam, ...TimeRangeSearchFields, }) @@ -258,7 +258,7 @@ export const AccountSearch = Schema.Struct({ }) export const CliLoginSearch = Schema.Struct({ - user_code: Schema.optional(Schema.String), + user_code: StringParam, }) // Keeps `tab` only when it is a string; the route redirects to /integrations. @@ -272,37 +272,36 @@ export const DeveloperSearch = Schema.Struct({ }) export const IntegrationsSearch = Schema.Struct({ - integration: Schema.optional(Schema.String), - chat: Schema.optional(Schema.String), - chat_reason: Schema.optional(Schema.String), - chat_workspace: Schema.optional(Schema.String), - chat_identity: Schema.optional(Schema.String), - chat_identity_name: Schema.optional(Schema.String), + integration: StringParam, + chat: StringParam, + chat_reason: StringParam, + chat_workspace: StringParam, + chat_identity: StringParam, + chat_identity_name: StringParam, }) export const McpAuthorizeSearch = Schema.Struct({ - request_id: Schema.optional(Schema.String), + request_id: StringParam, }) export const OrgRequiredSearch = Schema.Struct({ - redirect_url: Schema.optional(Schema.String), + redirect_url: StringParam, }) export const QuickStartSearch = Schema.Struct({ - redirect_url: Schema.optional(Schema.String), + redirect_url: StringParam, checkout: Schema.optional(Schema.Literal("complete")), }) export const SelectPlanSearch = Schema.Struct({ - redirect_url: Schema.optional(Schema.String), + redirect_url: StringParam, checkout: Schema.optional(Schema.Literal("complete")), }) -// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) export const ServiceMapSearch = Schema.Struct({ view: Schema.optional(Schema.Literals(["2d", "3d"])), - environment: Schema.optional(Schema.String), - focusService: Schema.optional(Schema.String), + environment: StringParam, + focusService: StringParam, focusHops: Schema.optional(Schema.Literals([1, 2])), focusMode: Schema.optional(Schema.Literals(["dim", "hide"])), ...TimeRangeSearchFields, @@ -312,91 +311,90 @@ export const SettingsSearch = Schema.Struct({ tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacySettingsTabValues])), checkout: Schema.optional(Schema.Literal("complete")), // audit-log-filters.ts `AuditLogSearchFields` - range: Schema.optional(Schema.String), - since: Schema.optional(Schema.String), - until: Schema.optional(Schema.String), - actor: Schema.optional(Schema.String), - actor_type: Schema.optional(Schema.String), - outcome: Schema.optional(Schema.String), + range: StringParam, + since: StringParam, + until: StringParam, + actor: StringParam, + actor_type: StringParam, + outcome: StringParam, // Data sources deep links from `/integrations`: one source, or the connect form of a kind. - source: Schema.optional(Schema.String), + source: StringParam, connect: Schema.optional(Schema.Literals(DATA_SOURCE_KINDS)), }) export const SignInSearch = Schema.Struct({ - redirect_url: Schema.optional(Schema.String), + redirect_url: StringParam, }) export const SignUpSearch = Schema.Struct({ - redirect_url: Schema.optional(Schema.String), + redirect_url: StringParam, }) export const UnsubscribeSearch = Schema.Struct({ - token: Schema.optional(Schema.String), + token: StringParam, }) // Detail routes export const AgentSessionSearch = Schema.Struct({ - t: Schema.optional(Schema.String), - end: Schema.optional(Schema.String), - view: Schema.optional(Schema.String), - span: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed())), - tool: Schema.optional(Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed())), + t: StringParam, + end: StringParam, + view: StringParam, + span: Schema.optional(LenientString.check(Schema.isMinLength(1), Schema.isTrimmed())), + tool: Schema.optional(LenientString.check(Schema.isMinLength(1), Schema.isTrimmed())), }) -// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) export const AlertRuleSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), + tab: StringParam, ...TimeRangeSearchFields, }) export const AlertsCreateSearch = Schema.Struct({ - serviceName: Schema.optional(Schema.String), - ruleId: Schema.optional(Schema.String), - template: Schema.optional(Schema.String), - dashboardId: Schema.optional(Schema.String), - widgetId: Schema.optional(Schema.String), - chart: Schema.optional(Schema.String), + serviceName: StringParam, + ruleId: StringParam, + template: StringParam, + dashboardId: StringParam, + widgetId: StringParam, + chart: StringParam, }) /** `/alerts/uptime/new`: `monitorId` edits that monitor; `serviceName` and `kind` prefill a new one. */ export const AlertsUptimeNewSearch = Schema.Struct({ - monitorId: Schema.optional(Schema.String), - serviceName: Schema.optional(Schema.String), - kind: Schema.optional(Schema.String), + monitorId: StringParam, + serviceName: StringParam, + kind: StringParam, }) export const AlertsUptimeMonitorSearch = Schema.Struct({}) /** `/alerts/slos/new`: `sloId` edits that SLO; `serviceName` prefills a new one. */ export const AlertsSloNewSearch = Schema.Struct({ - sloId: Schema.optional(Schema.String), - serviceName: Schema.optional(Schema.String), + sloId: StringParam, + serviceName: StringParam, }) export const AlertsSloSearch = Schema.Struct({}) /** `/alerts/schedules/new`: `scheduleId` edits that on-call schedule. */ export const AlertsScheduleNewSearch = Schema.Struct({ - scheduleId: Schema.optional(Schema.String), + scheduleId: StringParam, }) export const AlertsScheduleSearch = Schema.Struct({}) /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ - template: Schema.optional(Schema.String), + template: StringParam, }) /** The retired `/alerts/status-pages/$pageId`: `tab` picks the view its redirect opens. */ export const AlertsStatusPageSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), + tab: StringParam, }) /** `/status-pages/$pageId`: `view` is `appearance`, `settings`, `json` or `live`; none is the page itself. */ export const StatusPageSearch = Schema.Struct({ - view: Schema.optional(Schema.String), + view: StringParam, }) export const CodeReviewCiSearch = Schema.Struct(CodeReviewCiSearchFields) @@ -419,7 +417,7 @@ export const DashboardSearch = Schema.StructWithRest( export const SourcesNewSearch = Schema.Struct({ platform: Schema.optional(PushSourcePlatform), /** A data integration's connect step. A plain string: OAuth callbacks return here, so an unknown id falls back to the picker. */ - integration: Schema.optional(Schema.String), + integration: StringParam, }) /** Push sources show Setup, integration sources show Connection; a link to the other one falls back. */ @@ -431,7 +429,7 @@ export const SourceSearch = Schema.Struct({ }) export const DashboardsTemplatesSearch = Schema.Struct({ - template: Schema.optional(Schema.String), + template: StringParam, }) export const InvestigationSearch = Schema.Struct({ @@ -439,35 +437,32 @@ export const InvestigationSearch = Schema.Struct({ action: Schema.optional(Schema.Unknown), }) -// sessionTimeRangeSearchMiddleware() export const MetricSearch = Schema.Struct({ type: Schema.optional(Schema.Literals(QUERY_BUILDER_METRIC_TYPES)), agg: Schema.optional( Schema.Literals(["avg", "sum", "min", "max", "count", "latest", "rate", "increase"]), ), - where: Schema.optional(Schema.String), - groupBy: Schema.optional(Schema.String), - step: Schema.optional(Schema.String), - bd: Schema.optional(Schema.String), + where: StringParam, + groupBy: StringParam, + step: StringParam, + bd: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) export const ReleaseSearch = Schema.Struct({ - service: Schema.optional(Schema.String), + service: StringParam, environments: OptionalStringArrayParam, ...TimeRangeSearchFields, }) export const ReplaySearch = Schema.Struct({ - t: Schema.optional(Schema.String), + t: StringParam, }) -// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) export const ServiceSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), + tab: StringParam, /** The API tab's open endpoint drawer, by normalized span name ("GET /users/{id}"). */ - endpoint: Schema.optional(Schema.String), + endpoint: StringParam, environments: OptionalStringArrayParam, ...TimeRangeSearchFields, }) @@ -475,20 +470,20 @@ export const ServiceSearch = Schema.Struct({ export const ShareSearch = Schema.StructWithRest( Schema.Struct({ embed: Schema.optional(Schema.Boolean), - theme: Schema.optional(Schema.String), - from: Schema.optional(Schema.String), - to: Schema.optional(Schema.String), - range: Schema.optional(Schema.String), + theme: StringParam, + from: StringParam, + to: StringParam, + range: StringParam, /** The link creator's IANA zone; a calendar range ("today", "7d") resolves in it. */ - tz: Schema.optional(Schema.String), + tz: StringParam, refresh: Schema.optional(Schema.Union([Schema.Number, Schema.String])), }), [variableSearchRest], ) export const TraceSearch = Schema.Struct({ - spanId: Schema.optional(Schema.String), - t: Schema.optional(Schema.String), + spanId: StringParam, + t: StringParam, view: Schema.optional(Schema.Literals(["waterfall", "timeline", "flow"])), }) @@ -501,7 +496,7 @@ export const AgentSessionsSearch = Schema.Struct({ models: OptionalStringArrayParam, agents: OptionalStringArrayParam, tools: OptionalStringArrayParam, - q: Schema.optional(Schema.String), + q: StringParam, hasErrors: BooleanParam, grouped: BooleanParam, durationMin: NumberParam, @@ -519,24 +514,23 @@ export const AgentSessionsSearch = Schema.Struct({ }) export const AlertsSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), - serviceName: Schema.optional(Schema.String), - status: Schema.optional(Schema.String), + tab: StringParam, + serviceName: StringParam, + status: StringParam, /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ - signal: Schema.optional(Schema.String), + signal: StringParam, /** The Anomalies view's incidents: open (the default), "resolved" or "all". */ - anomalyStatus: Schema.optional(Schema.String), + anomalyStatus: StringParam, /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ - window: Schema.optional(Schema.String), + window: StringParam, }) -// sessionTimeRangeSearchMiddleware() export const AnalyticsSearch = Schema.Struct({ ...analyticsFilterSearchFields, ...TimeRangeSearchFields, - tab: Schema.optional(Schema.String), + tab: StringParam, /** The Search tab's Search Console property, when more than one is enabled. */ - site: Schema.optional(Schema.String), + site: StringParam, }) /** @@ -553,24 +547,23 @@ export const AnomaliesSearch = Schema.Struct({ export const CodeReviewSearch = Schema.Struct(CodeReviewSearchFields) export const DashboardsSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, scope: Schema.optional(Schema.Literals(["all", "favorites"])), - tags: Schema.optional(Schema.Array(Schema.String)), + tags: Schema.optional(StringArray), sort: Schema.optional(Schema.Literals(["updated", "created", "name-asc", "name-desc", "widgets"])), }) export const ErrorsSearch = Schema.Struct({ - service: Schema.optional(Schema.String), - env: Schema.optional(Schema.String), + service: StringParam, + env: StringParam, kind: Schema.optional(IssueKind), regressed: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), view: Schema.optional(Schema.Literals([...HUB_VIEWS, ...LEGACY_HUB_VIEWS])), - q: Schema.optional(Schema.String), + q: StringParam, sort: Schema.optional(Schema.Literals(HUB_SORTS)), severity: Schema.optional(Schema.Literals(SEVERITY_FILTERS)), }) -// sessionTimeRangeSearchMiddleware() export const InfraSearch = Schema.Struct({ ...TimeRangeSearchFields, }) @@ -580,10 +573,9 @@ export const InvestigationsSearch = Schema.Struct({ kind: Schema.optional(Schema.Literals(["alert", "error", "anomaly", "question", "verification"])), sort: Schema.optional(Schema.Literals(["updated", "severity", "confidence"])), dir: Schema.optional(Schema.Literals(["asc", "desc"])), - q: Schema.optional(Schema.String), + q: StringParam, }) -// sessionTimeRangeSearchMiddleware() export const LogsSearch = Schema.Struct({ services: OptionalStringArrayParam, severities: OptionalStringArrayParam, @@ -597,21 +589,19 @@ export const LogsSearch = Schema.Struct({ excludedNamespaces: OptionalStringArrayParam, attrs: OptionalStringArrayParam, columns: OptionalStringArrayParam, - search: Schema.optional(Schema.String), - traceId: Schema.optional(Schema.String), + search: StringParam, + traceId: StringParam, refresh: dashboardViewParamsSchema.refresh, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const MetricsSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, type: Schema.optional(Schema.Literals(QUERY_BUILDER_METRIC_TYPES)), view: Schema.optional(Schema.Literals(["grid", "table"])), ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) export const ReleasesSearch = Schema.Struct({ environments: OptionalStringArrayParam, excludedEnvironments: OptionalStringArrayParam, @@ -620,31 +610,30 @@ export const ReleasesSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const ReplaysSearch = Schema.Struct({ - service: Schema.optional(Schema.String), - browser: Schema.optional(Schema.String), - country: Schema.optional(Schema.String), - deviceType: Schema.optional(Schema.String), - userId: Schema.optional(Schema.String), - user: Schema.optional(Schema.String), - group: Schema.optional(Schema.String), - groupId: Schema.optional(Schema.String), - visitorId: Schema.optional(Schema.String), + service: StringParam, + browser: StringParam, + country: StringParam, + deviceType: StringParam, + userId: StringParam, + user: StringParam, + group: StringParam, + groupId: StringParam, + visitorId: StringParam, hasErrors: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), durationMin: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), durationMax: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), activeMin: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), activeMax: Schema.optional(Schema.Union([Schema.Number, NumberFromStringParam])), - q: Schema.optional(Schema.String), - page: Schema.optional(Schema.String), + q: StringParam, + page: StringParam, tags: OptionalStringArrayParam, ...TimeRangeSearchFields, }) /** `/users`: the directory's search, toggles and facet picks (a pinned namespace overrides `namespaces`). */ export const UsersSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, hasErrors: BooleanParam, hasReplay: BooleanParam, namespaces: OptionalStringArrayParam, @@ -661,11 +650,10 @@ export type UserDetailTab = (typeof USER_DETAIL_TABS)[number] /** `/users/$userId`: the tab and a namespace picked on the page (the org pin overrides it). */ export const UserDetailSearch = Schema.Struct({ tab: Schema.optional(Schema.Literals(USER_DETAIL_TABS)), - namespace: Schema.optional(Schema.String), + namespace: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware({ maxRangeSeconds: one year }) export const ServicesSearch = Schema.Struct({ environments: OptionalStringArrayParam, namespaces: OptionalStringArrayParam, @@ -690,7 +678,6 @@ const AttributeFilterParam = Schema.Struct({ or: Schema.optional(Schema.Array(Schema.Struct(attributeFilterParamFields))), }) -// sessionTimeRangeSearchMiddleware() export const TracesSearch = Schema.Struct({ services: OptionalStringArrayParam, spanNames: OptionalStringArrayParam, @@ -704,13 +691,13 @@ export const TracesSearch = Schema.Struct({ rootOnly: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), hideNoise: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), minSpanCount: Schema.optional(Schema.Union([Schema.Number, Schema.NumberFromString])), - whereClause: Schema.optional(Schema.String), + whereClause: StringParam, attributeFilters: Schema.optional(Schema.Array(AttributeFilterParam)), resourceAttributeFilters: Schema.optional(Schema.Array(AttributeFilterParam)), - peek: Schema.optional(Schema.String), - peekRow: Schema.optional(Schema.String), - peekT: Schema.optional(Schema.String), - peekSpan: Schema.optional(Schema.String), + peek: StringParam, + peekRow: StringParam, + peekT: StringParam, + peekSpan: StringParam, serviceMatchMode: ContainsMatchMode, spanNameMatchMode: ContainsMatchMode, deploymentEnvMatchMode: ContainsMatchMode, @@ -728,7 +715,6 @@ export const TracesSearch = Schema.Struct({ // Nested detail routes -// sessionTimeRangeSearchMiddleware() export const AgentSessionToolSearch = Schema.Struct({ ...ToolAnalyticsSearchFields, ...TimeRangeSearchFields, @@ -736,57 +722,49 @@ export const AgentSessionToolSearch = Schema.Struct({ export const AlertIncidentSearch = Schema.Struct({}) -// sessionTimeRangeSearchMiddleware() export const ErrorIssueSearch = Schema.Struct({ tab: Schema.optional(Schema.Literals(ISSUE_TABS)), ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraCloudflareZoneSearch = Schema.Struct({ ...cloudflareFilterSearchFields, ...TimeRangeSearchFields, tab: Schema.optional(Schema.Literals(ZONE_TABS)), }) -// sessionTimeRangeSearchMiddleware() export const InfraContainerSearch = Schema.Struct({ - host: Schema.optional(Schema.String), + host: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraHostSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraPlanetscaleDatabaseSearch = Schema.Struct({ - branch: Schema.optional(Schema.String), + branch: StringParam, ...planetscaleFilterSearchFields, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraRailwayServiceSearch = Schema.Struct({ ...TimeRangeSearchFields, - environmentId: Schema.String, + environmentId: LenientString, }) -// sessionTimeRangeSearchMiddleware() export const InfraSupabaseProjectSearch = Schema.Struct({ ...TimeRangeSearchFields, }) // Nested list routes -// sessionTimeRangeSearchMiddleware() /** `/mcp-servers`, a server, its tool page and its sessions share one search: the time range and the toolbar. */ export const McpServersSearch = Schema.Struct({ ...TimeRangeSearchFields, - client: Schema.optional(Schema.String), + client: StringParam, kind: Schema.optional(Schema.Literals(MCP_CLIENT_KINDS)), - q: Schema.optional(Schema.String), + q: StringParam, failing: Schema.optional(Schema.Boolean), metric: Schema.optional(Schema.Literals(["calls", "error_rate", "duration", "clients"])), percentile: Schema.optional(Schema.Literals(["p50", "p90", "p95"])), @@ -794,25 +772,22 @@ export const McpServersSearch = Schema.Struct({ calls: Schema.optional(Schema.Literals(["slowest", "failed", "recent"])), }) -// sessionTimeRangeSearchMiddleware() export const AgentSessionToolsSearch = Schema.Struct({ ...ToolAnalyticsSearchFields, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraCloudflareSearch = Schema.Struct({ ...TimeRangeSearchFields, view: Schema.optional(Schema.Literals(CLOUDFLARE_VIEWS)), - q: Schema.optional(Schema.String), + q: StringParam, scope: Schema.optional(Schema.Literals([...ZONE_SCOPES, ...WORKER_SCOPES])), previews: Schema.optional(Schema.Boolean), - account: Schema.optional(Schema.String), + account: StringParam, }) -// sessionTimeRangeSearchMiddleware() export const InfraContainersSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, scope: Schema.optional(Schema.Literals(["saturated", "elevated", "stale"])), sortBy: Schema.optional( Schema.Literals(["saturation", "cpuPct", "memoryPct", "containerName", "lastSeen"]), @@ -833,49 +808,43 @@ export const InfraContainersSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraAwsSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), - scope: Schema.optional(Schema.String), - region: Schema.optional(Schema.String), - q: Schema.optional(Schema.String), + tab: StringParam, + scope: StringParam, + region: StringParam, + q: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraGcpSearch = Schema.Struct({ - view: Schema.optional(Schema.String), - tab: Schema.optional(Schema.String), - scope: Schema.optional(Schema.String), - project: Schema.optional(Schema.String), - region: Schema.optional(Schema.String), - q: Schema.optional(Schema.String), - type: Schema.optional(Schema.String), + view: StringParam, + tab: StringParam, + scope: StringParam, + project: StringParam, + region: StringParam, + q: StringParam, + type: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraHostsSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, scope: Schema.optional(Schema.Literals(["saturated", "elevated", "stale"])), ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware(); beforeLoad redirects to /infra/kubernetes/pods carrying search. +// Redirects to /infra/kubernetes/pods carrying search. export const InfraKubernetesSearch = Schema.Struct(TimeRangeSearchFields) -// sessionTimeRangeSearchMiddleware() export const InfraPlanetscaleSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraRailwaySearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraSupabaseSearch = Schema.Struct({ ...TimeRangeSearchFields, }) @@ -886,53 +855,43 @@ export const DashboardWidgetSearch = Schema.StructWithRest(Schema.Struct(dashboa variableSearchRest, ]) -// sessionTimeRangeSearchMiddleware() export const InfraCloudflareWorkerSearch = Schema.Struct({ ...TimeRangeSearchFields, - account: Schema.optional(Schema.String), + account: StringParam, }) -// sessionTimeRangeSearchMiddleware() /** The resource's keys after its name, as `AWS_INFRA_KINDS[kind].identity` orders them. */ export const InfraAwsResourceSearch = Schema.Struct({ - cluster: Schema.optional(Schema.String), - region: Schema.optional(Schema.String), - account: Schema.optional(Schema.String), + cluster: StringParam, + region: StringParam, + account: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraGcpResourceSearch = Schema.Struct({ - project: Schema.optional(Schema.String), - region: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), - cluster: Schema.optional(Schema.String), - location: Schema.optional(Schema.String), - zone: Schema.optional(Schema.String), - backend: Schema.optional(Schema.String), - /** - * A Cloud SQL instance's statement open in the side panel, by its Query Insights hash. The hash - * is digits, which a hand-written `peek=123` parses as a number. - */ - peek: Schema.optional(Schema.Union([Schema.String, DigitsParam])), + project: StringParam, + region: StringParam, + namespace: StringParam, + cluster: StringParam, + location: StringParam, + zone: StringParam, + backend: StringParam, + /** A Cloud SQL instance's statement open in the side panel, by its Query Insights hash (digits). */ + peek: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesNodeSearch = Schema.Struct(TimeRangeSearchFields) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesPodSearch = Schema.Struct({ - namespace: Schema.optional(Schema.String), + namespace: StringParam, ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesServiceSearch = Schema.Struct(TimeRangeSearchFields) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesNodesSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, status: Schema.optional(Schema.Literals(["active", "idle", "ended"])), nodeNames: OptionalStringArrayParam, clusters: OptionalStringArrayParam, @@ -940,15 +899,14 @@ export const InfraKubernetesNodesSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesPodsSearch = Schema.Struct({ - q: Schema.optional(Schema.String), + q: StringParam, scope: Schema.optional(Schema.Literals(["saturated", "elevated", "unbounded", "ended"])), sortBy: Schema.optional( Schema.Literals(["saturation", "cpuUsage", "cpuLimitPct", "memoryLimitPct", "podName", "lastSeen"]), ), sortDir: SortDirParam, - peek: Schema.optional(Schema.String), + peek: StringParam, podNames: OptionalStringArrayParam, namespaces: OptionalStringArrayParam, nodeNames: OptionalStringArrayParam, @@ -972,13 +930,11 @@ export const InfraKubernetesPodsSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesServicesSearch = Schema.Struct(TimeRangeSearchFields) -// sessionTimeRangeSearchMiddleware() export const InfraKubernetesWorkloadsSearch = Schema.Struct({ kind: Schema.optional(WorkloadKindLiteral), - q: Schema.optional(Schema.String), + q: StringParam, scope: Schema.optional(Schema.Literals(["saturated", "elevated", "stale"])), workloadNames: OptionalStringArrayParam, namespaces: OptionalStringArrayParam, @@ -988,9 +944,8 @@ export const InfraKubernetesWorkloadsSearch = Schema.Struct({ ...TimeRangeSearchFields, }) -// sessionTimeRangeSearchMiddleware(); params.parse decodes `kind` against the workload-kind literals. export const InfraKubernetesWorkloadSearch = Schema.Struct({ - namespace: Schema.optional(Schema.String), + namespace: StringParam, ...TimeRangeSearchFields, }) @@ -1007,17 +962,17 @@ export const SecurityFindingsSearch = Schema.Struct({ severity: Schema.optional( Schema.Literals(["critical", "high", "medium", "low", "informational", "needs_validation"]), ), - target: Schema.optional(Schema.String), - q: Schema.optional(Schema.String), + target: StringParam, + q: StringParam, rejected: Schema.optional(Schema.Boolean), }) export const SecurityRunsSearch = Schema.Struct({ - target: Schema.optional(Schema.String), + target: StringParam, }) /** One repository's breakdown on the Dependencies tab; absent shows them all. */ export const SecurityDependenciesSearch = Schema.Struct({ - repo: Schema.optional(Schema.String), + repo: StringParam, /** The repositories or advisories table; absent shows the matrix, or a repository's upgrade plan. */ view: Schema.optional(Schema.Literals(["repositories", "advisories"])), }) diff --git a/apps/web/src/route/tanstack-search.ts b/apps/web/src/route/tanstack-search.ts index d12e1fa7c8..6dfc5f2d90 100644 --- a/apps/web/src/route/tanstack-search.ts +++ b/apps/web/src/route/tanstack-search.ts @@ -12,7 +12,13 @@ const decodeJson = (raw: string): Option.Option => // TanStack Router's default search format: each value is JSON when it parses as JSON, raw // otherwise, and a string that would parse as JSON is written quoted. Same URLs both ways. -const parseValue = (raw: string): Schema.Json => Option.getOrElse(decodeJson(raw), () => raw) +// A number that does not print back as written (20 digits, `1e5`) stays text: a string param +// stringifies what it gets, and an id that lost its low digits would be the wrong id. +const printsBack = (raw: string) => (value: Schema.Json) => + !Predicate.isNumber(value) || String(value) === raw + +const parseValue = (raw: string): Schema.Json => + Option.getOrElse(Option.filter(decodeJson(raw), printsBack(raw)), () => raw) const isJson = (raw: string): boolean => Option.isSome(decodeJson(raw)) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ebadbbb555..538bb36529 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -101,7 +101,7 @@ export const SaveInfraSurfaces = Command.define("SaveInfraSurfaces", { }), }) -/** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ +/** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days), which `readShellFlags` reads at boot. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, messages: [Message.CompletedSaveSidebarState], diff --git a/apps/web/src/shell/flags.test.ts b/apps/web/src/shell/flags.test.ts new file mode 100644 index 0000000000..814d4e1271 --- /dev/null +++ b/apps/web/src/shell/flags.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test, vi } from "vitest" + +// The shell's favorites listener targets `window` at import. +vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) +import { isSidebarOpenIn } from "./flags" +import { init } from "./model" + +const flags = { isMobile: false, theme: "light" as const, pinnedNamespaces: {}, dashboardFavorites: [] } + +const initWithCookie = (cookie: string) => init({ ...flags, isSidebarOpen: isSidebarOpenIn(cookie) }) + +describe("the sidebar's remembered state", () => { + test("a sidebar collapsed before the reload starts collapsed", () => { + expect(initWithCookie("theme=dark; sidebar_state=false; other=1").isSidebarOpen).toBe(false) + }) + + test("a sidebar left open, or never toggled, starts open", () => { + expect(initWithCookie("theme=dark; sidebar_state=true").isSidebarOpen).toBe(true) + expect(initWithCookie("").isSidebarOpen).toBe(true) + expect(init(flags).isSidebarOpen).toBe(true) + }) + + test("another cookie that only ends in the same text is not the sidebar's", () => { + expect(initWithCookie("my_sidebar_state=false").isSidebarOpen).toBe(true) + }) +}) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index c320341099..61875459bf 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -61,10 +61,15 @@ const readRememberedSurfaces = readPrefixed( Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(NavSurface))), ) +/** `SaveSidebarState`'s cookie: open unless the last toggle collapsed it (as Maple Local reads it). */ +export const isSidebarOpenIn = (cookie: string): boolean => + !Arr.contains(Str.split("; ")(cookie), "sidebar_state=false") + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces return { + isSidebarOpen: isSidebarOpenIn(document.cookie), isMobile: window.matchMedia(MOBILE_QUERY).matches, isFilterCollapsed: window.matchMedia(FILTER_COLLAPSED_QUERY).matches, isBelowSm: window.matchMedia(SMALL_QUERY).matches, diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 9fc3eedb40..364fd9e910 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -4,6 +4,7 @@ export { readShellFlags } from "./flags" export { NavSurface } from "./nav" export { Message, OutMessage, Theme } from "./message" export { init, Model, type ShellClerk } from "./model" +export { Platform } from "./palette/shortcuts" export { favoritesChanges, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 13f1c0df7b..f501b6a745 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -127,6 +127,8 @@ export const initRightSheet = (): Dialog.Model => Dialog.init(RIGHT_SHEET_ID, "s /** Persisted shell state, read before the first render (see `main.ts` flags). */ export type ShellFlags = Readonly<{ + /** The `sidebar_state` cookie; absent is open. */ + isSidebarOpen?: boolean isMobile: boolean isFilterCollapsed?: boolean isBelowSm?: boolean @@ -139,7 +141,7 @@ export type ShellFlags = Readonly<{ }> export const init = (flags: ShellFlags): Model => ({ - isSidebarOpen: true, + isSidebarOpen: flags.isSidebarOpen ?? true, isSidebarToggleInstant: false, isMobileNavOpen: false, isMobile: flags.isMobile, From faf82864a8e64c7e0962602d1b9a4459527e1f86 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:26:33 +0100 Subject: [PATCH 2/2] perf(views,local-ui): keep bar chart series per rows array, stop the query cache's use-time leak - Charts: bucketBarSeries and seriesDefinitionsOf keep their answer per rows array. Past 12 series the bar chart returned a fresh array on every render, so the spec cache and the encoding cache (both keyed on array identity) missed on every hover tick: all rows were folded again and the whole spec encoded to JSON again. - Maple Local: collecting the query cache now drops the use times of the entries it drops. Relative ranges mint a new key per window, so the use-time map of a tab left open grew without bound and was copied on every lookup. The entries and their use times live in one ref, so they change together. --- apps/local-ui/src/data/query-cache.test.ts | 60 ++++++++++++++++++-- apps/local-ui/src/data/query-cache.ts | 49 ++++++++++------ packages/views/src/charts/bar-chart.test.ts | 62 +++++++++++++++++++++ packages/views/src/charts/bar-chart.ts | 6 +- packages/views/src/charts/chart-spec.ts | 18 ++++++ packages/views/src/charts/timeseries.ts | 5 +- 6 files changed, 175 insertions(+), 25 deletions(-) create mode 100644 packages/views/src/charts/bar-chart.test.ts diff --git a/apps/local-ui/src/data/query-cache.test.ts b/apps/local-ui/src/data/query-cache.test.ts index b7d781d39e..3b76914a51 100644 --- a/apps/local-ui/src/data/query-cache.test.ts +++ b/apps/local-ui/src/data/query-cache.test.ts @@ -1,7 +1,8 @@ -import { Effect, Layer, Option } from "effect" +import { Array as Arr, Effect, Layer, Option } from "effect" import { TestClock } from "effect/testing" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" -import { LocalQueryCache } from "./query-cache" +import { GC_MS, hashKey } from "./cache-store" +import { type CacheState, collectUnused, LocalQueryCache } from "./query-cache" // The cache as the runtime builds it, on a test clock: `gcTime` drops what nothing used for an hour. @@ -10,12 +11,21 @@ const OTHER = ["local", "trace", "t2"] beforeEach(() => { vi.stubGlobal("window", { location: new URL("http://127.0.0.1:4318/") }) - vi.stubGlobal("document", { visibilityState: "visible", addEventListener: () => undefined, removeEventListener: () => undefined }) + vi.stubGlobal("document", { + visibilityState: "visible", + addEventListener: () => undefined, + removeEventListener: () => undefined, + }) }) afterEach(() => vi.unstubAllGlobals()) const run = (program: Effect.Effect) => - Effect.runPromise(program.pipe(Effect.provide(LocalQueryCache.layer.pipe(Layer.provideMerge(TestClock.layer()))), Effect.scoped)) + Effect.runPromise( + program.pipe( + Effect.provide(LocalQueryCache.layer.pipe(Layer.provideMerge(TestClock.layer()))), + Effect.scoped, + ), + ) const isCached = (key: ReadonlyArray) => Effect.gen(function* () { @@ -53,3 +63,45 @@ describe("LocalQueryCache gcTime", () => { expect(gone).toBe(true) }) }) + +describe("collectUnused", () => { + const initial: CacheState = { entries: new Map(), lastUsed: new Map() } + + /** A tab left open on a relative range: every minute is a new key, used once. */ + const afterMinutes = (minutes: number): CacheState => + Arr.reduce( + Arr.makeBy(minutes, (minute) => minute), + initial, + (state, minute) => { + const nowMs = minute * 60_000 + const key = ["local", "logs", `window-${minute}`] + return collectUnused( + { + entries: new Map([ + ...state.entries, + [hashKey(key), { key, data: minute, updatedAtMs: nowMs }], + ]), + lastUsed: new Map(state.lastUsed).set(hashKey(key), nowMs), + }, + nowMs, + ) + }, + ) + + test("use times are kept only for the results still cached", () => { + const state = afterMinutes(300) + + expect(state.entries.size).toBe(GC_MS / 60_000) + expect([...state.lastUsed.keys()]).toEqual([...state.entries.keys()]) + }) + + test("a result used within the hour keeps its use time", () => { + const key = ["local", "trace", "t1"] + const state: CacheState = { + entries: new Map([[hashKey(key), { key, data: 1, updatedAtMs: 0 }]]), + lastUsed: new Map([[hashKey(key), 1_000]]), + } + + expect(collectUnused(state, GC_MS).lastUsed.get(hashKey(key))).toBe(1_000) + }) +}) diff --git a/apps/local-ui/src/data/query-cache.ts b/apps/local-ui/src/data/query-cache.ts index 4b2d3f67d9..ea1e0a3776 100644 --- a/apps/local-ui/src/data/query-cache.ts +++ b/apps/local-ui/src/data/query-cache.ts @@ -23,6 +23,21 @@ const SAVE_DELAY_MS = 2_000 /** A cached result: exact for the key, or an older window standing in for it. */ export type CacheHit = Readonly<{ data: CacheValue; updatedAtMs: number; isStandIn: boolean }> +/** The results and when each was last read or written (`gcTime`'s clock), replaced together. */ +export type CacheState = Readonly<{ entries: CacheEntries; lastUsed: ReadonlyMap }> + +/** + * `gcTime` applied: drops what nothing used for an hour, and the use times of what it dropped. + * Relative ranges mint a new key per window, so use times kept past their entry grow without bound. + */ +export const collectUnused = (state: CacheState, nowMs: number): CacheState => { + const entries = dropUnused(state.entries, state.lastUsed, nowMs) + return { + entries, + lastUsed: new Map(Arr.filter(Arr.fromIterable(state.lastUsed), ([hash]) => entries.has(hash))), + } +} + /** The saved results this page was opened with (Flags: `hasCache` before anything loads). */ export const readSavedEntries: Effect.Effect = Effect.gen(function* () { const nowMs = yield* Clock.currentTimeMillis @@ -47,19 +62,19 @@ export class LocalQueryCache extends Context.Service< Effect.gen(function* () { const storageKey = storageKeyFor(localServerPortForLocation(window.location)) const restored = yield* readSavedEntries - const entries = yield* Ref.make(restored) // `gcTime`'s clock per entry: a restored result starts unobserved at load. const startedAtMs = yield* Clock.currentTimeMillis - const lastUsed = yield* Ref.make>( - new Map(Arr.map([...restored.keys()], (hash) => [hash, startedAtMs])), - ) + const state = yield* Ref.make({ + entries: restored, + lastUsed: new Map(Arr.map([...restored.keys()], (hash) => [hash, startedAtMs])), + }) const isSaveScheduled = yield* Ref.make(false) const scope = yield* Effect.scope /** Drops what nothing used for an hour; returns what is left. */ const collect = Effect.gen(function* () { const nowMs = yield* Clock.currentTimeMillis - const used = yield* Ref.get(lastUsed) - return yield* Ref.updateAndGet(entries, (current) => dropUnused(current, used, nowMs)) + const current = yield* Ref.updateAndGet(state, (before) => collectUnused(before, nowMs)) + return current.entries }) const save = Effect.flatMap(collect, (current) => Effect.sync(() => writeLocalStorage(storageKey, serialize(current))), @@ -92,7 +107,10 @@ export class LocalQueryCache extends Context.Service< ), ) const touch = (hash: string, nowMs: number) => - Ref.update(lastUsed, (used) => new Map(used).set(hash, nowMs)) + Ref.update(state, (before) => ({ + entries: before.entries, + lastUsed: new Map(before.lastUsed).set(hash, nowMs), + })) return { lookup: (key) => Effect.gen(function* () { @@ -120,15 +138,14 @@ export class LocalQueryCache extends Context.Service< onSome: (value) => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis - yield* touch(hashKey(key), now) - yield* Ref.update( - entries, - (current) => - new Map([ - ...current, - [hashKey(key), { key, data: value, updatedAtMs }], - ]), - ) + const hash = hashKey(key) + yield* Ref.update(state, (before) => ({ + entries: new Map([ + ...before.entries, + [hash, { key, data: value, updatedAtMs }], + ]), + lastUsed: new Map(before.lastUsed).set(hash, now), + })) yield* collect yield* scheduleSave }), diff --git a/packages/views/src/charts/bar-chart.test.ts b/packages/views/src/charts/bar-chart.test.ts new file mode 100644 index 0000000000..e7e956d62e --- /dev/null +++ b/packages/views/src/charts/bar-chart.test.ts @@ -0,0 +1,62 @@ +import { MAX_BAR_SERIES, OTHER_LABEL } from "@maple/ui/components/charts/_shared/bucket-series" +import { Array as Arr, Record as Rec } from "effect" +import { describe, expect, test } from "vitest" +import { bucketBarSeries } from "./bar-chart" +import { seriesDefinitionsOf } from "./timeseries" + +type Row = Readonly> + +/** Two buckets of `seriesCount` series; series `sN` reads `N * scale`. */ +const rowsOf = (seriesCount: number, scale = 1): ReadonlyArray => + Arr.map(["2026-10-01 12:00:00", "2026-10-01 12:01:00"], (bucket) => ({ + bucket, + ...Rec.fromEntries( + Arr.makeBy(seriesCount, (index) => [`series-${index + 1}`, (index + 1) * scale] as const), + ), + })) + +describe("bucketBarSeries", () => { + test("rows past the series cap fold once per rows array, so a re-render gets the same rows", () => { + const rows = rowsOf(MAX_BAR_SERIES + 3) + const bucketed = bucketBarSeries(rows) + + expect(Rec.keys(bucketed[0] ?? {})).toContain(OTHER_LABEL) + expect(bucketBarSeries(rows)).toBe(bucketed) + }) + + test("another rows array is folded on its own values", () => { + const bucketed = bucketBarSeries(rowsOf(MAX_BAR_SERIES + 3)) + const doubled = bucketBarSeries(rowsOf(MAX_BAR_SERIES + 3, 2)) + + expect(doubled).not.toBe(bucketed) + expect(doubled[0]?.[OTHER_LABEL]).toBe(Number(bucketed[0]?.[OTHER_LABEL]) * 2) + }) + + test("rows under the cap pass through", () => { + const rows = rowsOf(MAX_BAR_SERIES) + + expect(bucketBarSeries(rows)).toBe(rows) + }) +}) + +describe("seriesDefinitionsOf", () => { + test("the same rows array gets the same definitions back", () => { + const rows = rowsOf(3) + + expect(seriesDefinitionsOf(rows)).toBe(seriesDefinitionsOf(rows)) + expect(seriesDefinitionsOf(rows).definitions).toEqual([ + { rawKey: "series-1", chartKey: "s1" }, + { rawKey: "series-2", chartKey: "s2" }, + { rawKey: "series-3", chartKey: "s3" }, + ]) + }) + + test("another rows array gets its own definitions", () => { + const three = seriesDefinitionsOf(rowsOf(3)) + const four = seriesDefinitionsOf(rowsOf(4)) + + expect(four).not.toBe(three) + expect(four.totalSeriesCount).toBe(4) + expect(three.totalSeriesCount).toBe(3) + }) +}) diff --git a/packages/views/src/charts/bar-chart.ts b/packages/views/src/charts/bar-chart.ts index 45fcfe7636..d1e6c60fa2 100644 --- a/packages/views/src/charts/bar-chart.ts +++ b/packages/views/src/charts/bar-chart.ts @@ -10,7 +10,7 @@ import { MAX_BAR_SERIES, OTHER_LABEL, } from "@maple/ui/components/charts/_shared/bucket-series" -import { ChartSpec, ChartTooltip, specCache, type TimeseriesAxisSpec } from "./chart-spec" +import { ChartSpec, ChartTooltip, rowsCache, specCache, type TimeseriesAxisSpec } from "./chart-spec" import { bucketTooltipBody } from "./bucket-tooltip" import { plotFrame } from "./plot-frame" import { resolveColor, type ThemeColors } from "./theme-colors" @@ -49,7 +49,7 @@ export interface BarChartInputs { type Row = Readonly> /** `bucketBarSeries`: past `MAX_BAR_SERIES` the long tail folds into one "Other" series. */ -export const bucketBarSeries = (data: ReadonlyArray): ReadonlyArray => { +export const bucketBarSeries = rowsCache((data: ReadonlyArray): ReadonlyArray => { const seriesKeys = [ ...new Set( Arr.flatMap(data, (row) => @@ -60,7 +60,7 @@ export const bucketBarSeries = (data: ReadonlyArray): ReadonlyArray => if (seriesKeys.length <= MAX_BAR_SERIES) return data const { rows } = bucketTimeseries(data, seriesKeys, MAX_BAR_SERIES) return Arr.map(rows, (row, index) => (data[index]?.partial === true ? { ...row, partial: true } : row)) -} +}) const barSpec = specCache( (data: ReadonlyArray, rest: Omit[0], "data">) => diff --git a/packages/views/src/charts/chart-spec.ts b/packages/views/src/charts/chart-spec.ts index b21123ae0d..1d61a1653d 100644 --- a/packages/views/src/charts/chart-spec.ts +++ b/packages/views/src/charts/chart-spec.ts @@ -500,6 +500,24 @@ export const specCache = , Rest extends obje return spec } } + +/** + * A function of a rows array alone that keeps its answer per array: a chart re-rendered over the + * same rows gets the same value back, which is what `specCache` keys on. Rows are never edited in place. + */ +export const rowsCache = , A extends object>( + compute: (data: Rows) => A, +): ((data: Rows) => A) => { + const cache = new WeakMap() + return (data) => { + const cached = cache.get(data) + if (cached !== undefined) return cached + const result = compute(data) + cache.set(data, result) + return result + } +} + export const decodeChartSpec = Schema.decodeUnknownOption(ChartSpecJson) /** What a tooltip body renders: the focused datum, reduced to data. */ diff --git a/packages/views/src/charts/timeseries.ts b/packages/views/src/charts/timeseries.ts index 244333f695..144e100509 100644 --- a/packages/views/src/charts/timeseries.ts +++ b/packages/views/src/charts/timeseries.ts @@ -24,6 +24,7 @@ import { type TimeseriesAxisContext, type TimeseriesRow, } from "./timeseries-rows" +import { rowsCache } from "./chart-spec" import { resolveColor, type ThemeColors } from "./theme-colors" import { Array as Arr, Record as Rec } from "effect" @@ -40,7 +41,7 @@ export interface SeriesDefinition { } /** `normaliseTimeseriesRows`'s series discovery: first-seen order across all rows, `s1..sN` keys. */ -export const seriesDefinitionsOf = (data: ReadonlyArray>>) => { +export const seriesDefinitionsOf = rowsCache((data: ReadonlyArray>>) => { const rawKeys = [ ...new Set( Arr.flatMap(data, (row) => @@ -55,7 +56,7 @@ export const seriesDefinitionsOf = (data: ReadonlyArray