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/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 2c63c17aef..d44acc4a11 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,98 +311,97 @@ 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),
- kind: Schema.optional(Schema.String),
- resource_type: Schema.optional(Schema.String),
- action: Schema.optional(Schema.String),
- resource: Schema.optional(Schema.String),
- affected: Schema.optional(Schema.String),
- request: Schema.optional(Schema.String),
- entry: Schema.optional(Schema.String),
+ range: StringParam,
+ since: StringParam,
+ until: StringParam,
+ actor: StringParam,
+ actor_type: StringParam,
+ outcome: StringParam,
+ kind: StringParam,
+ resource_type: StringParam,
+ action: StringParam,
+ resource: StringParam,
+ affected: StringParam,
+ request: StringParam,
+ entry: 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)
@@ -426,7 +424,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. */
@@ -438,7 +436,7 @@ export const SourceSearch = Schema.Struct({
})
export const DashboardsTemplatesSearch = Schema.Struct({
- template: Schema.optional(Schema.String),
+ template: StringParam,
})
export const InvestigationSearch = Schema.Struct({
@@ -446,35 +444,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,
})
@@ -482,20 +477,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"])),
})
@@ -508,7 +503,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,
@@ -526,24 +521,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,
})
/**
@@ -560,24 +554,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,
})
@@ -587,10 +580,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,
@@ -604,21 +596,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,
@@ -627,31 +617,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,
@@ -668,11 +657,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,
@@ -697,7 +685,6 @@ const AttributeFilterParam = Schema.Struct({
or: Schema.optional(Schema.Array(Schema.Struct(attributeFilterParamFields))),
})
-// sessionTimeRangeSearchMiddleware()
export const TracesSearch = Schema.Struct({
services: OptionalStringArrayParam,
spanNames: OptionalStringArrayParam,
@@ -711,13 +698,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,
@@ -735,7 +722,6 @@ export const TracesSearch = Schema.Struct({
// Nested detail routes
-// sessionTimeRangeSearchMiddleware()
export const AgentSessionToolSearch = Schema.Struct({
...ToolAnalyticsSearchFields,
...TimeRangeSearchFields,
@@ -743,57 +729,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"])),
@@ -801,25 +779,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"]),
@@ -840,49 +815,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,
})
@@ -893,53 +862,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,
@@ -947,15 +906,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,
@@ -979,13 +937,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,
@@ -995,9 +951,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,
})
@@ -1014,17 +969,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,
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