diff --git a/apps/api/src/routes/v2/setup-audit.http.test.ts b/apps/api/src/routes/v2/setup-audit.http.test.ts index 6e69663d1d..432cac749a 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -21,6 +21,8 @@ import { PlanetScaleDiscoveryService } from "@maple/backend/services/integration import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService" import { RecommendationIssueService } from "@maple/backend/services/errors/RecommendationIssueService" import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" +import { OnboardingService } from "@maple/backend/services/org/OnboardingService" +import { OrganizationService } from "@maple/backend/services/org/OrganizationService" import { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" import { IntegrationInventoryService } from "@maple/backend/services/integrations/IntegrationInventoryService" import { SignalPresenceService } from "@maple/backend/services/org/SignalPresenceService" @@ -138,7 +140,20 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi = warehouseStub()) => { Layer.provide(planetScaleStubs), ), Layer.effect(SetupAuditService, SetupAuditService.make).pipe( - Layer.provide(Layer.mergeAll(warehouseLive, IntegrationInventoryService.layer)), + Layer.provide( + Layer.mergeAll( + warehouseLive, + IntegrationInventoryService.layer, + OnboardingService.layer, + // An undatable org: the audit may stamp an existing row but never creates one. + Layer.succeed(OrganizationService, { + retrieve: () => Effect.die("org lookup not exercised"), + }), + Layer.effect(SignalPresenceService, SignalPresenceService.make).pipe( + Layer.provide(warehouseLive), + ), + ), + ), ), // Sibling group in `AllV2GroupLayersLive`; the stub bundle is deliberately // unused here, so it needs its own (warehouse-only) layer. diff --git a/apps/api/src/routes/v2/telemetry-signals.http.test.ts b/apps/api/src/routes/v2/telemetry-signals.http.test.ts index 8c03013b9e..5ecccba32f 100644 --- a/apps/api/src/routes/v2/telemetry-signals.http.test.ts +++ b/apps/api/src/routes/v2/telemetry-signals.http.test.ts @@ -17,6 +17,8 @@ import { IngestAttributeMappingService } from "@maple/backend/services/org/Inges import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysService" import { RecommendationIssueService } from "@maple/backend/services/errors/RecommendationIssueService" import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" +import { OnboardingService } from "@maple/backend/services/org/OnboardingService" +import { OrganizationService } from "@maple/backend/services/org/OrganizationService" import { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" import { IntegrationInventoryService } from "@maple/backend/services/integrations/IntegrationInventoryService" import { PlanetScaleDiscoveryService } from "@maple/backend/services/integrations/PlanetScaleDiscoveryService" @@ -123,7 +125,20 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi) => { Layer.provide(planetScaleStubs), ), Layer.effect(SetupAuditService, SetupAuditService.make).pipe( - Layer.provide(Layer.mergeAll(warehouseLive, IntegrationInventoryService.layer)), + Layer.provide( + Layer.mergeAll( + warehouseLive, + IntegrationInventoryService.layer, + OnboardingService.layer, + // An undatable org: the audit may stamp an existing row but never creates one. + Layer.succeed(OrganizationService, { + retrieve: () => Effect.die("org lookup not exercised"), + }), + Layer.effect(SignalPresenceService, SignalPresenceService.make).pipe( + Layer.provide(warehouseLive), + ), + ), + ), ), Layer.effect(SignalPresenceService, SignalPresenceService.make).pipe(Layer.provide(warehouseLive)), ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) diff --git a/apps/web/src/components/settings/audit-log-date-range.tsx b/apps/web/src/components/settings/audit-log-date-range.tsx new file mode 100644 index 0000000000..b7d852436a --- /dev/null +++ b/apps/web/src/components/settings/audit-log-date-range.tsx @@ -0,0 +1,132 @@ +import { useState } from "react" + +import { Button } from "@maple/ui/components/ui/button" +import { Popover, PopoverContent, PopoverTrigger } from "@maple/ui/components/ui/popover" +import { cn } from "@maple/ui/lib/utils" + +import { ClockIcon } from "@/components/icons" +import { CustomRangePicker } from "@/components/time-range-picker/custom-range-picker" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone, normalizeTimestampInput } from "@/lib/timezone-format" + +import { AUDIT_RANGE_PRESETS, type AuditDateRange } from "./audit-log-filters" + +const ALL_TIME: AuditDateRange = { kind: "all" } + +function useRangeLabel(range: AuditDateRange): string { + const { effectiveTimezone } = useTimezonePreference() + if (range.kind === "all") return "All time" + if (range.kind === "preset") { + return AUDIT_RANGE_PRESETS.find((preset) => preset.value === range.preset)?.label ?? "All time" + } + const format = (value: string) => formatTimestampInTimezone(value, { timeZone: effectiveTimezone }) + if (range.since !== undefined && range.until !== undefined) { + return `${format(range.since)} to ${format(range.until)}` + } + if (range.since !== undefined) return `Since ${format(range.since)}` + if (range.until !== undefined) return `Until ${format(range.until)}` + return "All time" +} + +/** The audit log's window: everything, a rolling preset, or a fixed range. */ +export function AuditLogDateRange({ + value, + onChange, +}: { + value: AuditDateRange + onChange: (range: AuditDateRange) => void +}) { + const [open, setOpen] = useState(false) + const [custom, setCustom] = useState(false) + const label = useRangeLabel(value) + + function select(range: AuditDateRange) { + onChange(range) + setOpen(false) + setCustom(false) + } + + const options: ReadonlyArray<{ key: string; label: string; range: AuditDateRange }> = [ + { key: "all", label: "All time", range: ALL_TIME }, + ...AUDIT_RANGE_PRESETS.map((preset) => ({ + key: preset.value, + label: preset.label, + range: { kind: "preset", preset: preset.value } satisfies AuditDateRange, + })), + ] + const activeKey = value.kind === "all" ? "all" : value.kind === "preset" ? value.preset : null + + return ( + { + setOpen(next) + if (!next) setCustom(false) + }} + > + + + {label} + + } + /> + + {custom ? ( + + select({ + kind: "custom", + since: normalizeTimestampInput(range.startTime), + until: normalizeTimestampInput(range.endTime), + }) + } + onCancel={() => setCustom(false)} + /> + ) : ( +
+ {options.map((option) => ( + + ))} +
+ +
+ )} + + + ) +} diff --git a/apps/web/src/components/settings/audit-log-filters.test.ts b/apps/web/src/components/settings/audit-log-filters.test.ts new file mode 100644 index 0000000000..9426cf5b19 --- /dev/null +++ b/apps/web/src/components/settings/audit-log-filters.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest" + +import { + auditLogSearchFrom, + hasAuditLogFilters, + parseAuditLogSearch, + pickAuditLogSearch, + resolveAuditDateRange, +} from "./audit-log-filters" + +describe("parseAuditLogSearch", () => { + it("reads every filter from the search params", () => { + const filters = parseAuditLogSearch({ + range: "7d", + actor: "user_abc", + actor_type: "api_key", + outcome: "denied", + }) + expect(filters).toEqual({ + range: { kind: "preset", preset: "7d" }, + actorId: "user_abc", + actorType: "api_key", + outcome: "denied", + }) + expect(hasAuditLogFilters(filters)).toBe(true) + }) + + it("drops hand-edited values the API would reject instead of failing the page", () => { + const filters = parseAuditLogSearch({ + range: "forever", + since: "yesterday", + until: "2026-10-01 12:00:00", + actor: "", + actor_type: "robot", + outcome: "maybe", + }) + expect(filters.range).toEqual({ kind: "all" }) + expect(filters.actorId).toBeUndefined() + expect(filters.actorType).toBeUndefined() + expect(filters.outcome).toBeUndefined() + expect(hasAuditLogFilters(filters)).toBe(false) + }) + + it("keeps user, API key and agent actor ids", () => { + for (const actor of ["user_2fj3K9dLqWm8xYbT", "key_aXwpxqBkqtYwtBtmsGbR41", "actor_4CzLmR1pTx"]) { + expect(parseAuditLogSearch({ actor }).actorId).toBe(actor) + } + }) + + it("drops actor ids the app never writes", () => { + for (const actor of [ + "user_abc|2026-01-01T00:00:00Z", + "dash_abc", + "user_", + "bob", + "key_abc def", + "user_abc/../x", + ]) { + expect(parseAuditLogSearch({ actor }).actorId).toBeUndefined() + } + }) + + it("treats a since/until pair as a fixed range", () => { + expect( + parseAuditLogSearch({ since: "2026-10-01T00:00:00.000Z", until: "2026-10-02T00:00:00.000Z" }) + .range, + ).toEqual({ kind: "custom", since: "2026-10-01T00:00:00.000Z", until: "2026-10-02T00:00:00.000Z" }) + }) +}) + +describe("auditLogSearchFrom", () => { + it("clears the params of filters that were unset", () => { + const search = auditLogSearchFrom({ range: { kind: "preset", preset: "24h" } }) + expect(search).toEqual({ + range: "24h", + since: undefined, + until: undefined, + actor: undefined, + actor_type: undefined, + outcome: undefined, + }) + }) + + it("round-trips through the search params", () => { + const search = { + since: "2026-10-01T00:00:00.000Z", + until: "2026-10-02T00:00:00.000Z", + actor: "key_123", + actor_type: "api_key", + outcome: "allowed", + } + expect(pickAuditLogSearch(search)).toEqual({ range: undefined, ...search }) + }) +}) + +describe("resolveAuditDateRange", () => { + const now = Date.parse("2026-10-17T12:00:00.000Z") + + it("resolves a rolling preset against the given moment", () => { + expect(resolveAuditDateRange({ kind: "preset", preset: "24h" }, now)).toEqual({ + since: "2026-10-16T12:00:00.000Z", + until: undefined, + }) + }) + + it("passes a fixed range through and leaves all time unbounded", () => { + expect(resolveAuditDateRange({ kind: "custom", since: "2026-10-01T00:00:00.000Z" }, now)).toEqual({ + since: "2026-10-01T00:00:00.000Z", + until: undefined, + }) + expect(resolveAuditDateRange({ kind: "all" }, now)).toEqual({ since: undefined, until: undefined }) + }) +}) diff --git a/apps/web/src/components/settings/audit-log-filters.ts b/apps/web/src/components/settings/audit-log-filters.ts new file mode 100644 index 0000000000..cbce8f116a --- /dev/null +++ b/apps/web/src/components/settings/audit-log-filters.ts @@ -0,0 +1,129 @@ +import type { AuditActorType, AuditOutcome } from "@maple/domain/http" +import { Schema } from "effect" + +/** + * Audit log filters carried in the `/settings` search params, so a filtered view survives tab + * switches and can be shared. Plain strings: a hand-edited value is ignored, never a failed route. + */ +export const 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), +} + +export interface AuditLogSearch { + readonly range?: string + readonly since?: string + readonly until?: string + readonly actor?: string + readonly actor_type?: string + readonly outcome?: string +} + +/** Every key present, unset ones `undefined`, so spreading it over the search clears them. */ +export interface AuditLogSearchUpdate { + readonly range: string | undefined + readonly since: string | undefined + readonly until: string | undefined + readonly actor: string | undefined + readonly actor_type: string | undefined + readonly outcome: string | undefined +} + +export const AUDIT_RANGE_PRESETS = [ + { value: "24h", label: "Last 24 hours", ms: 24 * 60 * 60 * 1000 }, + { value: "7d", label: "Last 7 days", ms: 7 * 24 * 60 * 60 * 1000 }, + { value: "30d", label: "Last 30 days", ms: 30 * 24 * 60 * 60 * 1000 }, + { value: "90d", label: "Last 90 days", ms: 90 * 24 * 60 * 60 * 1000 }, +] as const + +export type AuditRangePreset = (typeof AUDIT_RANGE_PRESETS)[number]["value"] + +const ACTOR_TYPES: ReadonlyArray = ["user", "api_key", "agent", "system"] +const OUTCOMES: ReadonlyArray = ["allowed", "denied"] + +/** The window: a rolling preset, a fixed `since`/`until` pair, or everything. */ +export type AuditDateRange = + | { readonly kind: "all" } + | { readonly kind: "preset"; readonly preset: AuditRangePreset } + | { readonly kind: "custom"; readonly since?: string | undefined; readonly until?: string | undefined } + +export interface AuditLogFilters { + readonly range: AuditDateRange + readonly actorId?: string | undefined + readonly actorType?: AuditActorType | undefined + readonly outcome?: AuditOutcome | undefined +} + +// The API takes explicit-UTC ISO timestamps only; anything else would be a 400. +const isIsoTimestamp = (value: string | undefined): value is string => + value !== undefined && /^\d{4}-\d{2}-\d{2}T.+Z$/.test(value) && !Number.isNaN(Date.parse(value)) + +// Actor ids the app writes: Clerk `user_…`, public `key_…` and `actor_…` ids (base58 bodies). +const isActorId = (value: string | undefined): value is string => + value !== undefined && /^(?:user|key|actor)_[A-Za-z0-9]+$/.test(value) + +/** Narrow the raw search params to filters the API accepts, dropping anything malformed. */ +export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { + const preset = AUDIT_RANGE_PRESETS.find((option) => option.value === search.range) + const since = isIsoTimestamp(search.since) ? search.since : undefined + const until = isIsoTimestamp(search.until) ? search.until : undefined + const range: AuditDateRange = + preset !== undefined + ? { kind: "preset", preset: preset.value } + : since !== undefined || until !== undefined + ? { kind: "custom", since, until } + : { kind: "all" } + return { + range, + actorId: isActorId(search.actor) ? search.actor : undefined, + actorType: ACTOR_TYPES.find((type) => type === search.actor_type), + outcome: OUTCOMES.find((value) => value === search.outcome), + } +} + +/** Write filters back as search params; unset ones are `undefined` so the URL drops them. */ +export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpdate { + const { range } = filters + return { + range: range.kind === "preset" ? range.preset : undefined, + since: range.kind === "custom" ? range.since : undefined, + until: range.kind === "custom" ? range.until : undefined, + actor: filters.actorId, + actor_type: filters.actorType, + outcome: filters.outcome, + } +} + +/** Just the audit filters out of a search object, for links that should keep them. */ +export function pickAuditLogSearch(search: AuditLogSearch): AuditLogSearchUpdate { + return auditLogSearchFrom(parseAuditLogSearch(search)) +} + +/** The `since`/`until` bounds a range resolves to at `now`. */ +export function resolveAuditDateRange( + range: AuditDateRange, + now: number, +): { readonly since: string | undefined; readonly until: string | undefined } { + if (range.kind === "custom") return { since: range.since, until: range.until } + const preset = + range.kind === "preset" + ? AUDIT_RANGE_PRESETS.find((option) => option.value === range.preset) + : undefined + return { + since: preset === undefined ? undefined : new Date(now - preset.ms).toISOString(), + until: undefined, + } +} + +export function hasAuditLogFilters(filters: AuditLogFilters): boolean { + return ( + filters.range.kind !== "all" || + filters.actorId !== undefined || + filters.actorType !== undefined || + filters.outcome !== undefined + ) +} diff --git a/apps/web/src/components/settings/audit-log-person-filter.tsx b/apps/web/src/components/settings/audit-log-person-filter.tsx new file mode 100644 index 0000000000..5760877029 --- /dev/null +++ b/apps/web/src/components/settings/audit-log-person-filter.tsx @@ -0,0 +1,106 @@ +import { useOrganization } from "@clerk/clerk-react" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" + +import { + Select, + SelectContent, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectTrigger, + SelectValue, +} from "@maple/ui/components/ui/select" + +import { useApiKeysList } from "@/hooks/use-api-keys" +import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" + +import { memberDisplayName } from "./admin-only-notice" + +interface PersonOption { + readonly value: string + readonly label: string +} + +const ANYONE = "anyone" + +function useMemberOptions(): ReadonlyArray { + const { memberships } = useOrganization({ memberships: { pageSize: 100 } }) + return (memberships?.data ?? []).flatMap((membership) => { + const user = membership.publicUserData + const userId = user?.userId + if (userId === undefined) return [] + return [{ value: userId, label: memberDisplayName(user) ?? userId }] + }) +} + +/** "Anyone", or one member or API key: the audit log's `actor_id` filter. */ +export function AuditLogPersonFilter(props: { + value: string | undefined + onChange: (actorId: string | undefined) => void +}) { + // Self-hosted runs without Clerk: there is no member directory to list. + if (!isClerkAuthEnabled) return + return +} + +function PersonFilterWithMembers(props: { + value: string | undefined + onChange: (actorId: string | undefined) => void +}) { + return +} + +function PersonSelect({ + value, + onChange, + members, +}: { + value: string | undefined + onChange: (actorId: string | undefined) => void + members: ReadonlyArray +}) { + const { keys } = useApiKeysList() + // Audit rows name a key by its public `key_…` id; the synced rows carry the internal one. + const keyOptions: ReadonlyArray = keys.map((key) => ({ + value: encodePublicId(PublicIdPrefixes.apiKey, key.id), + label: key.name, + })) + const selected = [...members, ...keyOptions].find((option) => option.value === value) + const selectedLabel = value === undefined ? "Anyone" : (selected?.label ?? value) + + return ( + + ) +} diff --git a/apps/web/src/components/settings/audit-log-resource.test.ts b/apps/web/src/components/settings/audit-log-resource.test.ts new file mode 100644 index 0000000000..8e7a98a5fb --- /dev/null +++ b/apps/web/src/components/settings/audit-log-resource.test.ts @@ -0,0 +1,100 @@ +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { describe, expect, it } from "vitest" + +import { auditResourceDisplay, shortPublicId } from "./audit-log-resource" + +const RULE_ID = "3f1b7c02-9a44-4d1e-8b2f-0c5d6e7a8b91" + +const entry = (overrides: Partial[0]>) => ({ + action: "alert_rule.updated", + resource_type: null, + resource_id: null, + affected_user: null, + metadata: null, + ...overrides, +}) + +describe("auditResourceDisplay", () => { + it("names an alert rule and links to the rule by its internal id", () => { + const display = auditResourceDisplay( + entry({ + resource_type: "alert_rule", + resource_id: encodePublicId(PublicIdPrefixes.alertRule, RULE_ID), + metadata: { name: "High error rate" }, + }), + ) + expect(display).toEqual({ + text: "Alert rule · High error rate", + link: { to: "/alerts/$ruleId", params: { ruleId: RULE_ID } }, + }) + }) + + it("falls back to a short public id when no name was recorded", () => { + const publicId = encodePublicId(PublicIdPrefixes.dashboard, "dash-internal") + const display = auditResourceDisplay( + entry({ action: "dashboard.created", resource_type: "dashboard", resource_id: publicId }), + ) + expect(display?.text).toBe(`Dashboard · ${shortPublicId(publicId)}`) + expect(display?.link).toEqual({ + to: "/dashboards/$dashboardId", + params: { dashboardId: "dash-internal" }, + }) + }) + + it("does not link a resource that the entry deleted", () => { + const display = auditResourceDisplay( + entry({ + action: "alert_rule.deleted", + resource_type: "alert_rule", + resource_id: encodePublicId(PublicIdPrefixes.alertRule, RULE_ID), + }), + ) + expect(display?.link).toBeNull() + }) + + it("shows a refused request by its route", () => { + const display = auditResourceDisplay( + entry({ + action: "api.request", + resource_type: "api", + metadata: { method: "POST", path: "/v1/deployments" }, + }), + ) + expect(display).toEqual({ text: "POST /v1/deployments", link: null }) + }) + + it("uses the affected user for membership changes and links to members", () => { + const display = auditResourceDisplay( + entry({ action: "member.added", resource_type: "member", affected_user: "user_2Nf8abcdefgh" }), + ) + expect(display).toEqual({ + text: "Member · user_2Nf8", + link: { to: "/settings", search: { tab: "members" } }, + }) + }) + + it("names the tool for an MCP call and the key for an ingest key roll", () => { + expect( + auditResourceDisplay( + entry({ + action: "mcp_tool.called", + resource_type: "mcp_tool", + metadata: { tool: "search_traces" }, + }), + )?.text, + ).toBe("MCP tool · search_traces") + expect( + auditResourceDisplay( + entry({ + action: "ingest_key.rolled", + resource_type: "ingest_key", + metadata: { key_type: "private" }, + }), + )?.text, + ).toBe("Ingest key · private") + }) + + it("returns null for an entry that acted on nothing", () => { + expect(auditResourceDisplay(entry({}))).toBeNull() + }) +}) diff --git a/apps/web/src/components/settings/audit-log-resource.ts b/apps/web/src/components/settings/audit-log-resource.ts new file mode 100644 index 0000000000..461620aebb --- /dev/null +++ b/apps/web/src/components/settings/audit-log-resource.ts @@ -0,0 +1,122 @@ +import { auditResourceLabel } from "@maple/domain/audit-actions" +import { decodePublicId, PublicIdPrefixes, type V2AuditLogEntry } from "@maple/domain/http/v2" + +type AuditEntryResource = Pick< + V2AuditLogEntry, + "action" | "resource_type" | "resource_id" | "affected_user" | "metadata" +> + +/** Where a resource lives in the app. Only routes the web really has. */ +export type AuditResourceLink = + | { readonly to: "/alerts/$ruleId"; readonly params: { readonly ruleId: string } } + | { readonly to: "/dashboards/$dashboardId"; readonly params: { readonly dashboardId: string } } + | { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } } + | { readonly to: "/investigations/$id"; readonly params: { readonly id: string } } + | { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } } + | { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } } + | { readonly to: "/integrations"; readonly search: { readonly integration?: string } } + | { readonly to: "/settings"; readonly search: { readonly tab: string } } + +export interface AuditResourceDisplay { + /** "Alert rule · High error rate", "API · POST /v1/deployments", or the bare type label. */ + readonly text: string + readonly link: AuditResourceLink | null +} + +const stringField = (metadata: Record | null, key: string): string | null => { + const value = metadata?.[key] + return typeof value === "string" && value !== "" ? value : null +} + +/** `alrt_YofPTrK9782DWwcnXhpcCw` -> `alrt_YofP`: enough to tell rows apart; the detail has the rest. */ +export function shortPublicId(id: string): string { + const separator = id.indexOf("_") + if (separator === -1) return id.length > 8 ? id.slice(0, 8) : id + return id.slice(0, separator + 5) +} + +function resourceName(entry: AuditEntryResource, id: string | null): string | null { + const { metadata } = entry + return ( + stringField(metadata, "name") ?? + stringField(metadata, "tool") ?? + stringField(metadata, "key_type") ?? + stringField(metadata, "connector") ?? + (id !== null ? shortPublicId(id) : null) + ) +} + +const decoded = (prefix: (typeof PublicIdPrefixes)[keyof typeof PublicIdPrefixes], id: string | null) => + id === null ? null : decodePublicId(prefix, id) + +function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { + // A deleted resource has no page left to open. + if (entry.action.endsWith(".deleted") || entry.action.endsWith(".removed")) return null + const id = entry.resource_id + switch (entry.resource_type) { + case "alert_rule": { + const ruleId = decoded(PublicIdPrefixes.alertRule, id) + return ruleId === null ? null : { to: "/alerts/$ruleId", params: { ruleId } } + } + case "dashboard": { + const dashboardId = decoded(PublicIdPrefixes.dashboard, id) + return dashboardId === null ? null : { to: "/dashboards/$dashboardId", params: { dashboardId } } + } + case "error_issue": { + const issueId = decoded(PublicIdPrefixes.errorIssue, id) + return issueId === null ? null : { to: "/errors/issues/$issueId", params: { issueId } } + } + case "investigation": { + const investigationId = decoded(PublicIdPrefixes.investigation, id) + return investigationId === null + ? null + : { to: "/investigations/$id", params: { id: investigationId } } + } + case "anomaly_incident": { + const incidentId = decoded(PublicIdPrefixes.anomalyIncident, id) + return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } } + } + case "alert_destination": + return { to: "/alerts", search: { tab: "routing" } } + case "scrape_target": + return { to: "/integrations", search: { integration: "prometheus" } } + case "gcp_connector": + return { to: "/integrations", search: { integration: "gcp" } } + case "planetscale_integration": + return { to: "/integrations", search: { integration: "planetscale" } } + case "chat_integration": + return { to: "/integrations", search: {} } + case "api_key": + case "ingest_key": + return { to: "/settings", search: { tab: "keys" } } + case "member": + return { to: "/settings", search: { tab: "members" } } + case "attribute_mapping": + return { to: "/settings", search: { tab: "ingestion" } } + case "ai_provider_key": + return { to: "/settings", search: { tab: "ai" } } + case "warehouse_settings": + return { to: "/settings", search: { tab: "data-platform" } } + default: + return null + } +} + +/** How the Resource column reads for an entry, or null when it acted on nothing. */ +export function auditResourceDisplay(entry: AuditEntryResource): AuditResourceDisplay | null { + // Membership changes act on a person: the affected user is the resource. + const id = entry.resource_id ?? entry.affected_user + // A refused request names its route, which says more than "API". + const method = stringField(entry.metadata, "method") + const path = stringField(entry.metadata, "path") + if (method !== null && path !== null) return { text: `${method} ${path}`, link: null } + if (entry.resource_type === null) { + return id === null ? null : { text: shortPublicId(id), link: null } + } + const typeLabel = auditResourceLabel(entry.resource_type) ?? entry.resource_type + const name = resourceName(entry, id) + return { + text: name === null ? typeLabel : `${typeLabel} · ${name}`, + link: resourceLink(entry), + } +} diff --git a/apps/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx index ffec4a84b2..68b95224cd 100644 --- a/apps/web/src/components/settings/audit-log-section.tsx +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -1,4 +1,5 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { auditActionLabel, auditResourceLabel } from "@maple/domain/audit-actions" import type { AuditActorType, AuditLogSource, AuditOutcome } from "@maple/domain/http" import { encodePublicId, @@ -6,6 +7,7 @@ import { type V2AuditChanges, type V2AuditLogEntry, } from "@maple/domain/http/v2" +import { Link, useNavigate, useSearch } from "@tanstack/react-router" import { Option } from "effect" import { useState } from "react" @@ -38,6 +40,17 @@ import { formatTimestampInTimezone } from "@/lib/timezone-format" import { ErrorState } from "@/components/common/error-state" import { initialsFrom } from "@maple/ui/lib/initials" +import { AuditLogDateRange } from "./audit-log-date-range" +import { + type AuditLogFilters, + auditLogSearchFrom, + hasAuditLogFilters, + parseAuditLogSearch, + resolveAuditDateRange, +} from "./audit-log-filters" +import { AuditLogPersonFilter } from "./audit-log-person-filter" +import { auditResourceDisplay } from "./audit-log-resource" + type ActorFilter = AuditActorType | "all" type OutcomeFilter = AuditOutcome | "all" @@ -55,33 +68,31 @@ const OUTCOME_FILTERS: ReadonlyArray<{ value: OutcomeFilter; label: string }> = { value: "denied", label: "Denied" }, ] -/** Sources read as they are stored, bar the underscore in a two-word one (`chat_platform`). */ -const sourceLabel = (source: AuditLogSource): string => source.replace("_", " ") +const SOURCE_LABELS = { + dashboard: "Dashboard", + api: "API", + mcp: "MCP", + chat_platform: "Chat", + system: "System", +} satisfies Record -const ACTOR_BADGES: Record< - AuditActorType, - { label: string; variant: "secondary" | "ok" | "info" | "outline" } -> = { +const ACTOR_BADGES = { user: { label: "User", variant: "secondary" }, api_key: { label: "API key", variant: "ok" }, agent: { label: "Agent", variant: "info" }, system: { label: "System", variant: "outline" }, } satisfies Record -// Shared column lanes so the header row and entry rows stay aligned. Resource and -// source collapse when the card is narrow; time + actor + action always stay -// visible, and action is the lane that absorbs the remaining width. The card, -// not the viewport, is what the breakpoints measure: the settings column caps it -// at 48rem (766px inside its border), so every threshold must sit below that. -// Below `@md` a row wraps: time + actor on the first line, the action on its own -// line beneath, indented past the chevron. +// Shared column lanes so the header row and entry rows stay aligned. The panel, not the +// viewport, is what the breakpoints measure: resource, source and outcome fold away as it +// narrows, and below `@md` a row wraps with the action on its own line. const COL = { - time: "flex w-[112px] shrink-0 items-center gap-2", - actor: "min-w-0 flex-1 @md:w-[160px] @md:flex-none", + time: "flex w-22 shrink-0 items-center gap-2", + actor: "min-w-0 flex-1 @md:w-38 @md:flex-none", action: "min-w-0 basis-full pl-5 @md:basis-0 @md:flex-1 @md:pl-0", - resource: "hidden w-[176px] min-w-0 shrink-0 @2xl:block", - // 46rem: only a full-width settings column (736px+) leaves the action lane room for source too. - source: "hidden w-[88px] shrink-0 @min-[46rem]:block", + resource: "hidden w-56 min-w-0 shrink-0 @3xl:block", + source: "hidden w-20 shrink-0 @2xl:block", + outcome: "hidden w-15 shrink-0 @md:block", } /** @@ -98,7 +109,7 @@ function initialsOf(label: string): string { /** * `user_3BfcmIS3bUNV6BfAEkR2WzFOCvu` → `user_…FOCvu`. The prefix says what kind * of id it is and the tail is what someone compares against a copied value; - * the middle is noise in a 200px lane. The full id stays in the detail panel. + * the middle is noise in a narrow lane. The full id stays in the detail panel. */ function abbreviateId(id: string): string { const prefixEnd = id.indexOf("_") @@ -117,7 +128,7 @@ function isPlaceholder(value: unknown): value is string { return typeof value === "string" && /^<[a-z]+>$/.test(value) } -// JSON.stringify(undefined) is undefined — surface it as text. +// JSON.stringify(undefined) is undefined; surface it as text. function formatScalar(value: unknown): string { if (typeof value === "string") return value return JSON.stringify(value) ?? "undefined" @@ -166,24 +177,44 @@ interface AuditLogView { } export function AuditLogSection() { - const [actorFilter, setActorFilter] = useState("all") - const [outcomeFilter, setOutcomeFilter] = useState("all") + const search = useSearch({ from: "/settings" }) + const navigate = useNavigate({ from: "/settings" }) + const filters = parseAuditLogSearch(search) + + // Rolling presets resolve against the moment they were picked (or refreshed), so the + // page atom's key stays stable instead of moving with the clock on every render. + const [resolvedAt, setResolvedAt] = useState(() => Date.now()) + const bounds = resolveAuditDateRange(filters.range, resolvedAt) + + const filterInput = { + ...(filters.actorType !== undefined ? { actorType: filters.actorType } : undefined), + ...(filters.actorId !== undefined ? { actorId: filters.actorId } : undefined), + ...(filters.outcome !== undefined ? { outcome: filters.outcome } : undefined), + ...(bounds.since !== undefined ? { since: bounds.since } : undefined), + ...(bounds.until !== undefined ? { until: bounds.until } : undefined), + } + const [cursor, setCursor] = useState(undefined) // Frozen on the first Load more, and cleared whenever the list restarts. The // log is append-only and paginated by offset, so entries written mid-scroll // would otherwise shift later pages and make them repeat and skip rows. - const [until, setUntil] = useState(undefined) + const [pinnedUntil, setPinnedUntil] = useState(undefined) - const filterInput = { - ...(actorFilter !== "all" ? { actorType: actorFilter } : undefined), - ...(outcomeFilter !== "all" ? { outcome: outcomeFilter } : undefined), + // A filter change (from the toolbar, a shared link or back/forward) restarts the list. + const listKey = JSON.stringify(filterInput) + const [shownKey, setShownKey] = useState(listKey) + if (shownKey !== listKey) { + setShownKey(listKey) + setCursor(undefined) + setPinnedUntil(undefined) } + const pageAtom = auditLogPageAtom({ ...filterInput, ...(cursor !== undefined ? { cursor } : undefined), - ...(until !== undefined ? { until } : undefined), + ...(pinnedUntil !== undefined ? { until: pinnedUntil } : undefined), }) - // The first page for the current filters — what Refresh re-fetches. Without + // The first page for the current filters: what Refresh re-fetches. Without // the refresh the page family would hand back its cached copy from before. const firstPageAtom = auditLogPageAtom(filterInput) const pageResult = useAtomValue(pageAtom) @@ -206,69 +237,71 @@ export function AuditLogSection() { }) } - function restartList() { - setCursor(undefined) - setUntil(undefined) - } - - function handleFilterSelect(value: ActorFilter) { - if (value === actorFilter) return - setActorFilter(value) - restartList() - } - - function handleOutcomeSelect(value: OutcomeFilter) { - if (value === outcomeFilter) return - setOutcomeFilter(value) - restartList() - } - - function clearFilters() { - setActorFilter("all") - setOutcomeFilter("all") - restartList() + function setFilters(next: AuditLogFilters) { + setResolvedAt(Date.now()) + void navigate({ search: (prev) => ({ ...prev, ...auditLogSearchFrom(next) }), replace: true }) } function handleRefresh() { - restartList() - refreshFirstPage() + setCursor(undefined) + setPinnedUntil(undefined) + // A rolling window moves forward on refresh; a fixed one re-fetches as is. + if (filters.range.kind === "preset") setResolvedAt(Date.now()) + else refreshFirstPage() } // A failed page is settled, not loading: dimming the list and spinning the footer // on it would never end. Only a retry (which sets `waiting`) puts it back in flight. const failed = Result.isFailure(pageResult) const waiting = failed ? pageResult.waiting : !Result.isSuccess(pageResult) || pageResult.waiting - const filtered = actorFilter !== "all" || outcomeFilter !== "all" + const filtered = hasAuditLogFilters(filters) return ( -
- - -
- +
+
+ setFilters({ ...filters, range })} + /> + setFilters({ ...filters, actorId })} + /> +
+
+ + setFilters({ ...filters, actorType: value === "all" ? undefined : value }) + } + options={ACTOR_FILTERS} + /> + + setFilters({ ...filters, outcome: value === "all" ? undefined : value }) + } + options={OUTCOME_FILTERS} + /> +
+ +
No entries match these filters - Nothing recorded for this actor type and outcome. + Nothing recorded for this range, person, actor type and outcome. - @@ -331,6 +368,9 @@ export function AuditLogSection() { Source + + Outcome +
{view.entries.map((entry) => ( @@ -363,9 +403,9 @@ export function AuditLogSection() { // Pin the window to the newest entry already on screen before // the first Load more, so later offsets address a list that // cannot grow underneath them. - if (until === undefined) { + if (pinnedUntil === undefined) { const newest = view.entries[0] - if (newest !== undefined) setUntil(newest.occurred_at) + if (newest !== undefined) setPinnedUntil(newest.occurred_at) } setCursor(view.nextCursor) }} @@ -381,12 +421,8 @@ function ActorCell({ entry }: { entry: V2AuditLogEntry }) { return (
- {/* A person is shown as a face, not as the word "User" — the avatar - already says which kind of actor this is, and the name says who. - Keys, agents and system entries have no face and keep their badge. - The face is never lent to a key or an agent: those rows carry the - minting user's id too, and wearing it would credit a human for - something they may not have done. */} + {/* A person is shown as a face; keys, agents and system entries keep their badge. + The face is never lent to a key or an agent, which carry the minting user's id. */} {entry.actor_type === "user" ? ( {entry.actor_avatar_url !== null && } @@ -413,15 +449,23 @@ function ActorCell({ entry }: { entry: V2AuditLogEntry }) { ) } -/** `alert_rule.updated` with the verb carrying the weight — it is what a scan of the column is for. */ +/** The readable sentence, with the raw `.` kept beneath it for search and support. */ function ActionLabel({ action }: { action: string }) { - const dot = action.indexOf(".") - if (dot === -1) return {action} + const label = auditActionLabel(action) + if (label === null) return + return ( + <> + + + + ) +} + +function DeniedBadge() { return ( - - {action.slice(0, dot + 1)} - {action.slice(dot + 1)} - + + Denied + ) } @@ -429,19 +473,20 @@ function AuditLogRow({ entry }: { entry: V2AuditLogEntry }) { const [expanded, setExpanded] = useState(false) const detailId = `audit-entry-${entry.id}` const denied = entry.outcome === "denied" + const label = auditActionLabel(entry.action) ?? entry.action return (
- + {denied && } +
{expanded && (
@@ -493,27 +535,19 @@ function AuditLogRow({ entry }: { entry: V2AuditLogEntry }) { } function ResourceCell({ entry }: { entry: V2AuditLogEntry }) { - // Membership changes act on a person: the affected user is the resource. - const id = entry.resource_id ?? entry.affected_user - if (entry.resource_type === null && id === null) { - return {EMPTY_VALUE} - } + const display = auditResourceDisplay(entry) + if (display === null) return {EMPTY_VALUE} + if (display.link === null) return return ( -
- {entry.resource_type !== null && ( - - {entry.resource_type} - - )} - {id !== null && ( - - {id} - - )} -
+ + {display.text} + ) } - /** An identifier with its copy affordance; the one place the full value is shown untruncated. */ function Identifier({ value, label }: { value: string; label: string }) { return ( @@ -619,7 +653,7 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { - {sourceLabel(entry.source)} + {SOURCE_LABELS[entry.source]} {(entry.origin_ip !== null || entry.origin_country !== null) && ( {" · "} @@ -630,6 +664,9 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { + {auditActionLabel(entry.action) !== null && ( + {auditActionLabel(entry.action)} + )} {entry.action} @@ -653,9 +690,7 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { ) : ( {entry.resource_type !== null && ( - - {entry.resource_type} - + {auditResourceLabel(entry.resource_type) ?? entry.resource_type} )} {entry.resource_id !== null && ( diff --git a/apps/web/src/components/settings/data-platform-usage-section.tsx b/apps/web/src/components/settings/data-platform-usage-section.tsx index fd17a8579c..30ad019c51 100644 --- a/apps/web/src/components/settings/data-platform-usage-section.tsx +++ b/apps/web/src/components/settings/data-platform-usage-section.tsx @@ -67,7 +67,7 @@ export function DataPlatformUsageSection() { return ( diff --git a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx index 96138bc7c0..1ef95a6bc8 100644 --- a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx +++ b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx @@ -1,41 +1,51 @@ -import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format" -import { TONE_TEXT } from "@maple/ui/lib/tone" +import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" +import { trySync } from "@maple/ui/lib/try-sync" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { useEffect, useMemo, useRef, useState } from "react" -import { Exit } from "effect" +import { useEffect, useRef, useState } from "react" +import { Exit, Option } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field" -import { EmptyMessage } from "@maple/ui/components/ui/empty" -import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Panel } from "@maple/ui/components/ui/panel" import { RefreshButton } from "@maple/ui/components/ui/refresh-button" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useAsyncAction } from "@/hooks/use-mutation-action" import { Button } from "@maple/ui/components/ui/button" -import { Badge } from "@maple/ui/components/ui/badge" import { Input } from "@maple/ui/components/ui/input" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import { AlertWarningIcon, - ChevronDownIcon, ChevronRightIcon, CircleCheckIcon, CircleWarningIcon, CircleXmarkIcon, } from "@/components/icons" import { MapleInternalAtomClient, retainedInternalQuery } from "@/lib/services/common/internal-atom-client" -import { OrgClickHouseSettingsUpsertRequest } from "@maple/domain/http" +import { + type ClickHouseTableDiffEntry, + type OrgClickHouseApplySchemaStatus, + type OrgClickHouseSchemaDiffResponse, + type OrgClickHouseSettingsResponse, + OrgClickHouseSettingsUpsertRequest, +} from "@maple/domain/http" import { DataPlatformUsageSection } from "@/components/settings/data-platform-usage-section" import { toastExit } from "@/lib/error-toast" import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { UnsavedChangesGuard } from "@/components/settings/settings-save-bar" +import { useSettingsDraft } from "@/components/settings/settings-draft" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatTimestampInTimezone } from "@/lib/timezone-format" +import { RelativeTime } from "@/components/common/relative-time" import { ErrorState } from "@/components/common/error-state" -import { schemaApplyPlan } from "@/components/settings/schema-apply-plan" +import { + columnDriftResolvedByApply, + schemaApplyPlan, + type SchemaDiffStatus, + schemaDiffCounts, +} from "@/components/settings/schema-apply-plan" interface OrgClickHouseSettingsSectionProps { isAdmin: boolean @@ -43,105 +53,286 @@ interface OrgClickHouseSettingsSectionProps { } export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgClickHouseSettingsSectionProps) { - const [chUrl, setChUrl] = useState("") - const [chUser, setChUser] = useState("default") - const [chPassword, setChPassword] = useState("") - const [chDatabase, setChDatabase] = useState("default") - const [isStarting, setIsStarting] = useState(false) - const [disableOpen, setDisableOpen] = useState(false) - const [applyConfirmOpen, setApplyConfirmOpen] = useState(false) - const { effectiveTimezone } = useTimezonePreference() - const [expandedDrifts, setExpandedDrifts] = useState>(new Set()) + if (!isAdmin || !hasEntitlement) return null + return +} +function DataPlatform() { const settingsQueryAtom = retainedInternalQuery("orgClickHouseSettings", "get", {}) const settingsResult = useAtomValue(settingsQueryAtom) const refreshSettings = useAtomRefresh(settingsQueryAtom) const diffQueryAtom = retainedInternalQuery("orgClickHouseSettings", "schemaDiff", {}) - const diffResult = useAtomValue(diffQueryAtom) const refreshDiff = useAtomRefresh(diffQueryAtom) - const statusQueryAtom = retainedInternalQuery("orgClickHouseSettings", "applySchemaStatus", {}) - const statusResult = useAtomValue(statusQueryAtom) - const refreshStatus = useAtomRefresh(statusQueryAtom) + const settings = Result.isSuccess(settingsResult) ? settingsResult.value : null + + return ( + + + {Result.isFailure(settingsResult) ? ( + + ) : settings === null ? ( + + ) : ( + { + refreshSettings() + refreshDiff() + }} + /> + )} + + {settings?.configured === true && ( + + )} + + + ) +} + +interface ConnectionDraft { + readonly url: string + readonly user: string + readonly database: string +} + +const isHttpUrl = (value: string): boolean => { + const url = Option.getOrNull(trySync(() => new URL(value.trim()))) + return url !== null && (url.protocol === "https:" || url.protocol === "http:") +} + +function ConnectionStatus({ settings }: { settings: OrgClickHouseSettingsResponse }) { + if (!settings.configured) { + return ( +
+ + Not connected + · Queries use Maple's warehouse +
+ ) + } + const failing = settings.syncStatus === "error" + return ( +
+
+ + {failing ? "Connection error" : "Connected"} + {settings.lastSyncAt !== null && ( + + · last checked + + )} +
+ {failing && settings.lastSyncError !== null && ( +

{settings.lastSyncError}

+ )} +
+ ) +} + +function ConnectionPanel({ + settings, + onChanged, +}: { + settings: OrgClickHouseSettingsResponse + onChanged: () => void +}) { + const saved: ConnectionDraft = { + url: settings.chUrl ?? "", + user: settings.chUser ?? "default", + database: settings.chDatabase ?? "default", + } + const { draft, isDirty, updateDraft, discard, commit, submit, abort } = useSettingsDraft(saved) + const [password, setPassword] = useState("") + const [disconnectOpen, setDisconnectOpen] = useState(false) + const dirty = isDirty || password !== "" const upsertMutation = useAtomSet(MapleInternalAtomClient.mutation("orgClickHouseSettings", "upsert"), { mode: "promiseExit", }) - const applyMutation = useAtomSet( - MapleInternalAtomClient.mutation("orgClickHouseSettings", "applySchema"), - { - mode: "promiseExit", - }, - ) const deleteMutation = useAtomSet(MapleInternalAtomClient.mutation("orgClickHouseSettings", "delete"), { mode: "promiseExit", }) const [handleSave, isSaving] = useAsyncAction(async () => { + const submitted = submit() + const sent = { url: draft.url.trim(), user: draft.user.trim(), database: draft.database.trim() } const result = await upsertMutation({ payload: new OrgClickHouseSettingsUpsertRequest({ - url: chUrl, - user: chUser, - password: chPassword, - database: chDatabase, + ...sent, + ...(password !== "" ? { password } : undefined), }), }) - - if (!toastExit(result, { success: "ClickHouse connection saved", error: "Failed to save settings" })) + if (!toastExit(result, { success: "Connection saved and tested", error: "Connection test failed" })) { + abort() return - setChPassword("") - refreshSettings() - refreshDiff() + } + setPassword("") + commit({ submitted, sent }) + onChanged() }) - const [handleRefreshDiff, isRefreshingDiff] = useAsyncAction(async () => { - refreshDiff() - // Atom refresh is fire-and-forget; tiny delay so the spinner is visible on - // fast re-runs and we don't end the busy state before the new request lands. - await new Promise((resolve) => setTimeout(resolve, 300)) + const [handleDisconnect, isDisconnecting] = useAsyncAction(async () => { + const result = await deleteMutation({}) + setDisconnectOpen(false) + if (!toastExit(result, { success: "Disconnected", error: "Failed to disconnect" })) return + setPassword("") + discard() + onChanged() }) - const [handleDisable, isDisabling] = useAsyncAction(async () => { - const result = await deleteMutation({}) - setDisableOpen(false) + const busy = isSaving || isDisconnecting + const valid = isHttpUrl(draft.url) && draft.user.trim() !== "" && draft.database.trim() !== "" - if ( - !toastExit(result, { - success: "BYO ClickHouse disabled", - error: "Failed to disable BYO ClickHouse", - }) - ) - return - setChUrl("") - setChPassword("") - refreshSettings() - refreshDiff() - }) + return ( + + + +
+ + ClickHouse URL + { + const url = event.target.value + updateDraft((current) => ({ ...current, url })) + }} + disabled={busy} + /> + HTTP interface URL (port 8123 by default). + +
+ + User + { + const user = event.target.value + updateDraft((current) => ({ ...current, user })) + }} + disabled={busy} + /> + + + Database + { + const database = event.target.value + updateDraft((current) => ({ ...current, database })) + }} + disabled={busy} + /> + +
+ + Password + setPassword(event.target.value)} + disabled={busy} + /> + +
+
+ {settings.configured ? ( + + ) : ( + + )} +
+ Saving runs a test query first. + +
+
+ void handleDisconnect()} + pending={isDisconnecting} + /> +
+ ) +} - const settings = Result.builder(settingsResult) - .onSuccess((value) => value) - .orElse(() => null) +const STATUS_COUNTS: ReadonlyArray<{ status: SchemaDiffStatus; label: string; tone: string }> = [ + { status: "up_to_date", label: "Up to date", tone: "text-foreground" }, + { status: "missing", label: "Missing", tone: TONE_TEXT.crit }, + { status: "drifted", label: "Drifted", tone: TONE_TEXT.warn }, + { status: "wrong_kind", label: "Wrong kind", tone: TONE_TEXT.warn }, +] + +const kindLabel = (kind: ClickHouseTableDiffEntry["kind"]) => + kind === "materialized_view" ? "view" : "table" + +function SchemaSection({ + settings, + onApplied, +}: { + settings: OrgClickHouseSettingsResponse + onApplied: () => void +}) { + const diffQueryAtom = retainedInternalQuery("orgClickHouseSettings", "schemaDiff", {}) + const diffResult = useAtomValue(diffQueryAtom) + const refreshDiff = useAtomRefresh(diffQueryAtom) - const diff = Result.builder(diffResult) - .onSuccess((value) => value) - .orElse(() => null) + const statusQueryAtom = retainedInternalQuery("orgClickHouseSettings", "applySchemaStatus", {}) + const statusResult = useAtomValue(statusQueryAtom) + const refreshStatus = useAtomRefresh(statusQueryAtom) - const applyStatus = Result.builder(statusResult) - .onSuccess((value) => value) - .orElse(() => null) + const applyMutation = useAtomSet( + MapleInternalAtomClient.mutation("orgClickHouseSettings", "applySchema"), + { + mode: "promiseExit", + }, + ) + const [isStarting, setIsStarting] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + const { effectiveTimezone } = useTimezonePreference() + const diff = Result.isSuccess(diffResult) ? diffResult.value : null + const applyStatus = Result.isSuccess(statusResult) ? statusResult.value : null const runActive = applyStatus?.status === "queued" || applyStatus?.status === "running" const isApplying = isStarting || runActive - const configured = settings?.configured === true - const isBusy = isSaving || isApplying || isRefreshingDiff || isDisabling - // Poll the background apply run while it's active. Ticks pause while the tab is - // hidden, so the terminal-transition toast below fires on refocus rather than - // in the background — this is a foreground progress indicator. + // Poll the background apply run while it's active. Ticks pause while the tab is hidden, so + // the terminal-transition toast below fires on refocus rather than in the background. useIntervalRefresh(refreshStatus, { intervalMs: 2_000, enabled: runActive }) - // Toast + refresh the diff on terminal transitions (running → succeeded/failed). + // Toast and refresh the diff on terminal transitions (running to succeeded or failed). const prevApplyStatusRef = useRef(null) useEffect(() => { const status = applyStatus?.status ?? null @@ -149,7 +340,7 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli prevApplyStatusRef.current = status if (prev !== "queued" && prev !== "running") return if (status === "succeeded") { - refreshSettings() + onApplied() refreshDiff() toastManager.add({ title: "Schema applied", type: "success" }) } else if (status === "failed") { @@ -159,54 +350,20 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli type: "error", }) } - }, [applyStatus?.status, applyStatus?.errorMessage, refreshSettings, refreshDiff]) - - useEffect(() => { - if (settings?.chUrl != null) setChUrl(settings.chUrl) - if (settings?.chUser != null) setChUser(settings.chUser) - if (settings?.chDatabase != null) setChDatabase(settings.chDatabase) - }, [settings?.chUrl, settings?.chUser, settings?.chDatabase]) - - const isValidUrl = useMemo(() => { - const trimmed = chUrl.trim() - if (trimmed.length === 0) return false - try { - const url = new URL(trimmed) - return url.protocol === "https:" || url.protocol === "http:" - } catch { - return false - } - }, [chUrl]) - - const statusBadge = useMemo(() => { - if (!configured) return Default Maple Tinybird - if (settings?.syncStatus === "error") return Needs attention - return Connected - }, [configured, settings?.syncStatus]) - - const diffSummary = useMemo(() => { - if (!diff) return null - const counts = { up_to_date: 0, missing: 0, drifted: 0, wrong_kind: 0 } - for (const entry of diff.entries) counts[entry.status]++ - return counts - }, [diff]) - - const applyPlan = useMemo(() => (diff ? schemaApplyPlan(diff.entries) : null), [diff]) + }, [applyStatus?.status, applyStatus?.errorMessage, onApplied, refreshDiff]) async function handleApply() { - setApplyConfirmOpen(false) - // Apply now runs in a background workflow (heavy backfill migrations can't - // fit one request). Kick it off, then the status poll drives progress. + setConfirmOpen(false) + // Apply runs in a background workflow; kick it off and let the status poll drive progress. setIsStarting(true) const result = await applyMutation({}) - if (Exit.isSuccess(result)) { refreshStatus() - if (result.value.status === "already_running") { - toastManager.add({ title: "A schema apply is already in progress", type: "info" }) - } else { - toastManager.add({ title: "Schema apply started" }) - } + toastManager.add( + result.value.status === "already_running" + ? { title: "A schema apply is already in progress", type: "info" } + : { title: "Schema apply started" }, + ) // Hand off to the status poll; keep the button busy until it reports active. setTimeout(() => setIsStarting(false), 1500) return @@ -215,323 +372,293 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli toastExit(result, { error: "Failed to start schema apply" }) } - function toggleDriftRow(name: string) { - setExpandedDrifts((prev) => { - const next = new Set(prev) - if (next.has(name)) next.delete(name) - else next.add(name) - return next - }) - } - - if (!isAdmin || !hasEntitlement) return null + const plan = diff === null ? null : schemaApplyPlan(diff.entries) + const behind = diff !== null && diff.appliedSchemaVersion !== diff.expectedSchemaVersion + const hasWork = plan !== null && (plan.create.length > 0 || plan.addColumns.length > 0 || behind) return ( - <> - - - : statusBadge - } + refreshDiff()} + pending={Result.isSuccess(diffResult) && diffResult.waiting} + disabled={isApplying} + /> + } + > + {Result.isFailure(diffResult) ? ( + + ) : diff === null ? ( + + ) : ( + + )} + +
+ + {settings.schemaVersion !== null && settings.lastSyncAt !== null ? ( + <> + Last applied version{" "} + on{" "} + {formatTimestampInTimezone(settings.lastSyncAt, { + timeZone: effectiveTimezone, + withYear: true, + })} + + ) : ( + "Schema never applied" + )} + + - -
- - )} -
- - - {configured ? ( - -
- - - - {settings?.lastSyncAt - ? formatTimestampInTimezone(settings.lastSyncAt, { - timeZone: effectiveTimezone, - withYear: true, - }) - : "Never"} - - - {settings?.schemaVersion ? ( - - ) : ( - EMPTY_VALUE - )} - - - {diff?.expectedSchemaVersion ? ( - - ) : ( - EMPTY_VALUE - )} - - - {diffSummary - ? `${diffSummary.up_to_date} ok · ${diffSummary.missing} missing · ${diffSummary.drifted} drift` - : EMPTY_VALUE} - - - - - {Result.isInitial(diffResult) ? ( - - ) : Result.isFailure(diffResult) ? ( - - ) : diff && diff.entries.length > 0 ? ( - - {diff.entries.map((entry) => { - const isExpanded = expandedDrifts.has(entry.name) - const isDrifted = entry.status === "drifted" - return ( -
- - {isDrifted && isExpanded ? ( -
    - {entry.columnDrifts.map((drift) => ( -
  • - {drift.kind === "missing" - ? `– missing column \`${drift.column}\` (expected ${drift.expectedType})` - : drift.kind === "extra" - ? `– extra column \`${drift.column}\` (${drift.actualType})` - : `– type mismatch on \`${drift.column}\`: expected ${drift.expectedType}, got ${drift.actualType}`} -
  • - ))} -
- ) : null} -
- ) - })} -
- ) : ( - No tables in the schema. - )} - -
- void handleRefreshDiff()} - pending={isRefreshingDiff} - disabled={isBusy} - /> - -
- {runActive && ( -

- {applyStatus?.phase ?? "Applying schema…"} - {applyStatus?.stepsTotal != null && applyStatus?.stepsDone != null - ? ` (${applyStatus.stepsDone}/${applyStatus.stepsTotal})` - : ""} - {applyStatus?.currentMigration != null - ? ` · migration ${applyStatus.currentMigration}` - : ""} - {". Runs in the background, safe to leave this page."} -

- )} - {applyStatus?.status === "failed" && applyStatus.errorMessage && ( -

{applyStatus.errorMessage}

- )} -
-
- ) : null} - - + Review and apply + +
} - title="Apply the schema to your ClickHouse?" + title="Apply the schema to your cluster?" description={ <> - This runs DDL against{" "} - {settings?.chDatabase ?? chDatabase} on your + This runs DDL against {settings.chDatabase} on your cluster. Pending Maple migrations run first and can rebuild Maple's own views and - backfill data. The snapshot pass below then only adds objects and columns. + backfill data. The snapshot pass then only adds what is listed below. } confirmLabel="Apply schema" onConfirm={() => void handleApply()} pending={isStarting} > - {applyPlan !== null && } + {plan !== null && } + + ) +} - - This org will fall back to the default Maple-managed Tinybird Cloud. Tables in your - ClickHouse cluster are NOT touched: disable just removes Maple's pointer to it. - - } - confirmLabel="Disable" - onConfirm={() => void handleDisable()} - pending={isDisabling} - /> +function SchemaDiff({ diff }: { diff: OrgClickHouseSchemaDiffResponse }) { + const [showUpToDate, setShowUpToDate] = useState(false) + const counts = schemaDiffCounts(diff.entries) + const needsWork = diff.entries.filter((entry) => entry.status !== "up_to_date") + const upToDate = diff.entries.filter((entry) => entry.status === "up_to_date") + + return ( + <> +
+ {STATUS_COUNTS.map(({ status, label, tone }, index) => ( +
0 && "sm:border-l", + index % 2 === 1 && "border-l", + )} + > +
{label}
+
+ {counts[status]} +
+
+ ))} +
+ {needsWork.length === 0 && diff.entries.length > 0 && ( +

+ Every table and view matches what Maple expects. +

+ )} + {needsWork.map((entry) => ( + + ))} + {upToDate.length > 0 && ( + <> + + {showUpToDate && ( +
    + {upToDate.map((entry) => ( +
  • + {entry.name} + {kindLabel(entry.kind)} +
  • + ))} +
+ )} + + )} + + ) +} + +function entrySummary(entry: ClickHouseTableDiffEntry): { text: string; tone: string } { + switch (entry.status) { + case "missing": + return { text: "Missing, created on apply", tone: TONE_TEXT.crit } + case "wrong_kind": + return { text: `Exists as a ${kindLabel(entry.actualKind)}, fix manually`, tone: TONE_TEXT.warn } + case "drifted": + return { + text: `Drifted, ${entry.columnDrifts.length} ${entry.columnDrifts.length === 1 ? "column" : "columns"}`, + tone: TONE_TEXT.warn, + } + case "up_to_date": + return { text: "Up to date", tone: "text-muted-foreground" } + } +} + +const DRIFT_LABEL = { + missing: "Missing column", + extra: "Extra column", + type_mismatch: "Type mismatch", +} as const + +function DiffRow({ entry }: { entry: ClickHouseTableDiffEntry }) { + const [expanded, setExpanded] = useState(true) + const summary = entrySummary(entry) + const Icon = entry.status === "missing" ? CircleXmarkIcon : CircleWarningIcon + const head = ( + <> + + {entry.name} + {kindLabel(entry.kind)} + {summary.text} + + ) + + if (entry.status !== "drifted") { + return
{head}
+ } + return ( +
+ + {expanded && ( +
    + {entry.columnDrifts.map((drift) => { + const resolved = columnDriftResolvedByApply(entry.kind, drift) + return ( +
  • + + {DRIFT_LABEL[drift.kind]} + + + {drift.kind === "missing" + ? `${drift.column} ${drift.expectedType}` + : drift.kind === "extra" + ? `${drift.column} ${drift.actualType}` + : `${drift.column} ${drift.actualType} vs ${drift.expectedType}`} + + + {resolved ? "Added on apply" : "Fix manually"} + +
  • + ) + })} +
+ )} +
+ ) +} + +/** Progress of a running apply, why the last one failed, and what it skipped. */ +function ApplyRunStatus({ status }: { status: OrgClickHouseApplySchemaStatus | null }) { + if (status === null) return null + const running = status.status === "queued" || status.status === "running" + const steps = + status.stepsTotal !== null && status.stepsDone !== null + ? ` (${status.stepsDone}/${status.stepsTotal})` + : "" + return ( + <> + {running && ( +

+ {status.phase ?? "Applying schema"} + {steps} + {status.currentMigration !== null ? ` · migration ${status.currentMigration}` : ""}. Runs + in the background, safe to leave this page. +

+ )} + {status.status === "failed" && status.errorMessage !== null && ( +

+ Last apply failed: {status.errorMessage} +

+ )} + {!running && status.skipped.length > 0 && ( +
+

+ The last apply skipped{" "} + {status.skipped.length === 1 ? "one step" : `${status.skipped.length} steps`}. Apply + again once the cause is fixed. +

+
    + {status.skipped.map((item) => ( +
  • + {item.id}: {item.reason} +
  • + ))} +
+
+ )} ) } @@ -544,12 +671,18 @@ function SchemaApplyPlanList({ plan }: { plan: ReturnType `${table.name}: ${table.columns.join(", ")}`), }, { - label: "Not resolved by Apply (fix manually)", + label: "Not resolved by apply (fix manually)", items: plan.unresolved.map((entry) => `${entry.name}: ${entry.reason}`), }, ].filter((group) => group.items.length > 0) - if (groups.length === 0) return null + if (groups.length === 0) { + return ( +

+ No tables or columns to add; only pending migrations run. +

+ ) + } return (
{groups.map((group) => ( diff --git a/apps/web/src/components/settings/schema-apply-plan.test.ts b/apps/web/src/components/settings/schema-apply-plan.test.ts index 4f8d6c4afe..48eef4c301 100644 --- a/apps/web/src/components/settings/schema-apply-plan.test.ts +++ b/apps/web/src/components/settings/schema-apply-plan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { schemaApplyPlan } from "./schema-apply-plan" +import { columnDriftResolvedByApply, schemaApplyPlan, schemaDiffCounts } from "./schema-apply-plan" describe("schemaApplyPlan", () => { it("creates missing objects and adds only missing columns to drifted tables", () => { @@ -47,3 +47,33 @@ describe("schemaApplyPlan", () => { ]) }) }) + +describe("schemaDiffCounts", () => { + it("counts every diff state, including wrong-kind objects", () => { + expect( + schemaDiffCounts([ + { status: "up_to_date", name: "logs", kind: "table" }, + { status: "up_to_date", name: "traces", kind: "table" }, + { status: "missing", name: "traces_mv", kind: "materialized_view" }, + { status: "wrong_kind", name: "errors_mv", kind: "materialized_view", actualKind: "table" }, + ]), + ).toEqual({ up_to_date: 2, missing: 1, drifted: 0, wrong_kind: 1 }) + }) +}) + +describe("columnDriftResolvedByApply", () => { + it("adds missing columns to tables and leaves every other drift for a human", () => { + const missing = { kind: "missing", column: "SpanKind", expectedType: "String" } as const + const extra = { kind: "extra", column: "Legacy", actualType: "String" } as const + const mismatch = { + kind: "type_mismatch", + column: "Duration", + expectedType: "UInt64", + actualType: "Int64", + } as const + expect(columnDriftResolvedByApply("table", missing)).toBe(true) + expect(columnDriftResolvedByApply("materialized_view", missing)).toBe(false) + expect(columnDriftResolvedByApply("table", extra)).toBe(false) + expect(columnDriftResolvedByApply("table", mismatch)).toBe(false) + }) +}) diff --git a/apps/web/src/components/settings/schema-apply-plan.ts b/apps/web/src/components/settings/schema-apply-plan.ts index f9746340b4..e974ed6bfb 100644 --- a/apps/web/src/components/settings/schema-apply-plan.ts +++ b/apps/web/src/components/settings/schema-apply-plan.ts @@ -41,3 +41,26 @@ export function schemaApplyPlan(entries: ReadonlyArray }) return { create, addColumns, unresolved } } + +export type SchemaDiffStatus = ClickHouseTableDiffEntry["status"] + +/** How many tables and views are in each diff state; every state is present, zero or not. */ +export function schemaDiffCounts( + entries: ReadonlyArray, +): Record { + const count = (status: SchemaDiffStatus) => entries.filter((entry) => entry.status === status).length + return { + up_to_date: count("up_to_date"), + missing: count("missing"), + drifted: count("drifted"), + wrong_kind: count("wrong_kind"), + } +} + +/** Whether Apply fixes a column drift: only a missing column on a table is added. */ +export function columnDriftResolvedByApply( + kind: ClickHouseTableDiffEntry["kind"], + drift: ClickHouseColumnDrift, +): boolean { + return kind === "table" && drift.kind === "missing" +} diff --git a/apps/web/src/components/settings/setup-audit-section.tsx b/apps/web/src/components/settings/setup-audit-section.tsx index b0766ad283..63c5bcddf1 100644 --- a/apps/web/src/components/settings/setup-audit-section.tsx +++ b/apps/web/src/components/settings/setup-audit-section.tsx @@ -2,350 +2,359 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@m import { countLabel } from "@maple/ui/lib/format" import { Panel } from "@maple/ui/components/ui/panel" import { RefreshButton } from "@maple/ui/components/ui/refresh-button" -import { useMemo, useState } from "react" import { Link } from "@tanstack/react-router" import type { V2SetupAudit, V2SetupAuditCheck } from "@maple/domain/http/v2" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" +import { buttonVariants } from "@maple/ui/components/ui/button" +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@maple/ui/components/ui/collapsible" import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Button, buttonVariants } from "@maple/ui/components/ui/button" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" -import { TONE_TEXT } from "@maple/ui/lib/tone" +import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" import { + ArrowRightIcon, + ChevronRightIcon, CircleCheckIcon, CircleInfoIcon, CircleWarningIcon, + ExternalLinkIcon, ServerIcon, type IconComponent, } from "@/components/icons" import { setupAuditAtom } from "@/lib/services/atoms/audit-atoms" import { DocsLink, EmptyActions } from "@/components/common/docs-link" -import type { SettingsTab } from "@/components/settings/settings-nav" import { RelativeTime } from "@/components/common/relative-time" import { ErrorState } from "@/components/common/error-state" -import { SettingsSection } from "@/components/settings/settings-section" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" + +import { + type HealthSegment, + healthSegments, + orderFindings, + resolvedChecks, + type SetupHealthAction, + setupHealthAction, +} from "./setup-health" type Severity = V2SetupAuditCheck["severity"] type Category = V2SetupAuditCheck["category"] -const SEVERITY: Record = { - critical: { label: "Critical", icon: CircleWarningIcon, className: TONE_TEXT.crit }, - warn: { label: "Warning", icon: CircleWarningIcon, className: TONE_TEXT.warn }, - info: { label: "Info", icon: CircleInfoIcon, className: TONE_TEXT.info }, -} satisfies Record - -const SEVERITY_ORDER: ReadonlyArray = ["critical", "warn", "info"] - -/** - * Where a finding gets fixed, so it is one click away. Kept as separate shapes because TanStack - * Router types `search` per route; a widened `object` does not typecheck. - */ -type FixTarget = - | { tab: SettingsTab } - | { alertsTab: "rules" | "routing" } - | { to: "/alerts" | "/traces" | "/integrations" | "/dashboards" } - -type CategoryOpen = { label: string; target: FixTarget } +// Info is the quiet blue; green stays reserved for passing. +const SEVERITY = { + critical: { label: "Critical", icon: CircleWarningIcon, text: TONE_TEXT.crit }, + warn: { label: "Warning", icon: CircleWarningIcon, text: TONE_TEXT.warn }, + info: { label: "Info", icon: CircleInfoIcon, text: TONE_TEXT.done }, +} satisfies Record -/** `open: null` means the category has no single screen that fixes its findings. */ -const CATEGORY = { - alerting: { label: "Alerting", open: { label: "Open alerts", target: { to: "/alerts" } } }, - notifications: { label: "Notifications", open: null }, - ingestion: { label: "Ingestion", open: { label: "Open ingestion", target: { tab: "ingestion" } } }, - attributes: { label: "Attributes", open: { label: "Open ingestion", target: { tab: "ingestion" } } }, - traces: { label: "Traces", open: { label: "Open traces", target: { to: "/traces" } } }, - integrations: { - label: "Integrations", - open: { label: "Open integrations", target: { to: "/integrations" } }, - }, - data_platform: { - label: "Data platform", - open: { label: "Open data platform", target: { tab: "data-platform" } }, - }, -} satisfies Record +const SEGMENT = { + critical: { fill: TONE_FILL.crit, legend: (n: number) => `${n} critical` }, + warn: { fill: TONE_FILL.warn, legend: (n: number) => countLabel(n, "warning") }, + info: { fill: TONE_FILL.done, legend: (n: number) => `${n} info` }, + pass: { fill: TONE_FILL.ok, legend: (n: number) => `${n} passing` }, + skip: { fill: TONE_FILL.neutral, legend: (n: number) => `${n} skipped` }, +} satisfies Record string }> -/** - * Per-check overrides of the category target. `null` hides the Fix link: the fix has no UI yet and - * the hint names where it lives (MCP tool or API). - */ -const CHECK_TARGET = new Map([ - ["CFG-ALERT-01", { alertsTab: "routing" }], - ["CFG-ALERT-02", { alertsTab: "rules" }], - ["CFG-ALERT-03", { alertsTab: "rules" }], - ["CFG-ALERT-04", { alertsTab: "routing" }], - ["CFG-NOTIF-01", null], - ["CFG-ANOM-01", null], - ["CFG-DASH-01", { to: "/dashboards" }], -]) +const CATEGORY_LABEL = { + alerting: "Alerting", + notifications: "Notifications", + ingestion: "Ingestion", + attributes: "Attributes", + traces: "Traces", + integrations: "Integrations", + data_platform: "Data platform", +} satisfies Record -const fixTargetFor = (check: V2SetupAuditCheck): FixTarget | null => - CHECK_TARGET.has(check.id) - ? (CHECK_TARGET.get(check.id) ?? null) - : (CATEGORY[check.category].open?.target ?? null) +const actionClassName = cn(buttonVariants({ variant: "outline", size: "sm" }), "shrink-0 gap-1.5") -function FixLink({ target, label, className }: { target: FixTarget; label: string; className: string }) { - if ("tab" in target) { +function ActionButton({ action }: { action: SetupHealthAction }) { + if (action.kind === "docs") { return ( - - {label} - - ) - } - if ("alertsTab" in target) { - return ( - - {label} - + + {action.label} + + ) } return ( - - {label} + + {action.label} + ) } -const CATEGORY_ORDER: ReadonlyArray = [ - "alerting", - "notifications", - "ingestion", - "attributes", - "traces", - "integrations", - "data_platform", -] +const chipClassName = "text-2xs leading-4" +const linkedChipClassName = cn( + chipClassName, + "text-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground", +) -function SummaryPill({ count, label, className }: { count: number; label: string; className: string }) { +function AffectedChip({ entity }: { entity: V2SetupAuditCheck["affected"][number] }) { + const title = entity.note ?? undefined + if (entity.kind === "service") { + return ( + + {entity.name} + + ) + } return ( - - 0 && className)}>{count} {label} - + + {entity.name} + ) } -function CheckRow({ check }: { check: V2SetupAuditCheck }) { +function FindingRow({ check }: { check: V2SetupAuditCheck }) { const severity = SEVERITY[check.severity] - const fixTarget = fixTargetFor(check) - const isFinding = check.status === "fail" - const Icon = isFinding ? severity.icon : check.status === "pass" ? CircleCheckIcon : CircleInfoIcon + const action = setupHealthAction(check) + const Icon = severity.icon return ( -
- -
-
- {check.title} - - {check.id} - - {check.status === "skip" && ( - - Skipped - +
  • +
    + +
    +
    + {check.title} + + {CATEGORY_LABEL[check.category]} + +
    + {check.detail !== null && ( +

    {check.detail}

    + )} +

    {check.fix_hint}

    + {check.affected.length > 0 && ( +
    + {check.affected.map((entity, index) => ( + // Names repeat (two rules can share one), so key by id, else position. + + ))} + {check.affected_count > check.affected.length && ( + + +{check.affected_count - check.affected.length} more + + )} +
    )}
    - {check.detail !== null && ( -

    {check.detail}

    - )} - {check.affected.length > 0 && ( -
    - {check.affected.map((entity) => ( - - {entity.name} - - ))} - {check.affected_count > check.affected.length && ( - - +{check.affected_count - check.affected.length} more - - )} -
    - )} - {isFinding && ( -

    - {check.fix_hint} -

    - )}
    -
    - {isFinding && fixTarget !== null && ( - + {action !== null && ( +
    + +
    )} -
    +
  • ) } -function CategoryCard({ - category, - checks, -}: { - category: Category - checks: ReadonlyArray -}) { - const meta = CATEGORY[category] - const findings = checks.filter((check) => check.status === "fail").length - return ( - -
    -

    - {meta.label} - - {findings > 0 ? countLabel(findings, "finding") : "clear"} - -

    - {meta.open !== null && ( - - )} -
    - {checks.map((check) => ( - - ))} -
    - ) -} - -function Report({ audit, refreshing }: { audit: V2SetupAudit; refreshing: boolean }) { - const [showPassing, setShowPassing] = useState(false) +function SummaryPanel({ audit, refreshing }: { audit: V2SetupAudit; refreshing: boolean }) { const refresh = useAtomRefresh(setupAuditAtom) - - const grouped = useMemo(() => { - const visible = audit.checks.filter((check) => showPassing || check.status === "fail") - const bySeverity = (a: V2SetupAuditCheck, b: V2SetupAuditCheck) => - SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) || - a.id.localeCompare(b.id) - return CATEGORY_ORDER.map((category) => ({ - category, - checks: visible.filter((check) => check.category === category).sort(bySeverity), - })).filter((group) => group.checks.length > 0) - }, [audit.checks, showPassing]) - const { summary } = audit - const findingCount = summary.critical + summary.warn + summary.info - - if (audit.data_status === "no_data") { - return ( - - - - - - - No telemetry yet - - The audit runs once your first service reports in. Connect one from the Ingestion - tab and come back. - - - - - Go to Ingestion - - - - - - ) - } + const segments = healthSegments(summary) return ( -
    - - - - - · - - {summary.skip > 0 && ( - - )} + +
    +

    + {summary.pass} of {countLabel(audit.checks.length, "check")} passing +

    - + + Last run + refresh()} /> +
    + + ) } export function SetupAuditSection() { const result = useAtomValue(setupAuditAtom) const refresh = useAtomRefresh(setupAuditAtom) + const audit = Result.isSuccess(result) ? result.value : null return ( - - {Result.isFailure(result) ? ( - - ) : !Result.isSuccess(result) ? ( -
    - - -
    - ) : ( - + + + {Result.isFailure(result) ? ( + + ) : audit === null ? ( +
    + + +
    + ) : audit.data_status === "no_data" ? ( + + ) : ( + + )} +
    + {audit !== null && audit.data_status === "ok" && ( + <> + + + )} -
    + ) } diff --git a/apps/web/src/components/settings/setup-health.test.ts b/apps/web/src/components/settings/setup-health.test.ts new file mode 100644 index 0000000000..fad64430c9 --- /dev/null +++ b/apps/web/src/components/settings/setup-health.test.ts @@ -0,0 +1,102 @@ +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { SETUP_AUDIT_CHECKS } from "@maple/domain/setup-audit" +import { describe, expect, it } from "vitest" + +import { healthSegments, orderFindings, resolvedChecks, setupHealthAction } from "./setup-health" + +const RULE_ID = "3f1b7c02-9a44-4d1e-8b2f-0c5d6e7a8b91" + +describe("setupHealthAction", () => { + it("gives every check in the registry one specific action", () => { + for (const check of SETUP_AUDIT_CHECKS) { + const action = setupHealthAction({ id: check.id, affected: [] }) + expect(action, check.id).not.toBeNull() + expect(action?.label, check.id).not.toMatch(/CFG-|^Fix$/) + } + }) + + it("opens the affected alert rule in the editor by its internal id", () => { + const action = setupHealthAction({ + id: "CFG-ALERT-03", + affected: [ + { + kind: "alert_rule", + id: encodePublicId(PublicIdPrefixes.alertRule, RULE_ID), + name: "High error rate", + note: null, + }, + ], + }) + expect(action).toMatchObject({ + kind: "link", + link: { to: "/alerts/create", search: { ruleId: RULE_ID } }, + }) + }) + + it("falls back to the rules list when no rule id can be decoded", () => { + const action = setupHealthAction({ + id: "CFG-ALERT-05", + affected: [{ kind: "alert_rule", id: "not-a-public-id", name: "Old rule", note: null }], + }) + expect(action).toMatchObject({ kind: "link", link: { to: "/alerts", search: { tab: "rules" } } }) + }) + + it("sends telemetry checks to the matching guide section", () => { + const action = setupHealthAction({ id: "LOG-02", affected: [] }) + expect(action?.kind).toBe("docs") + expect(action?.kind === "docs" ? action.href : "").toMatch(/otel-conventions#trace-correlation$/) + }) + + it("reconnects the integration a revoked grant names", () => { + const action = setupHealthAction({ + id: "CFG-INTEG-01", + affected: [{ kind: "integration", id: null, name: "railway", note: null }], + }) + expect(action).toMatchObject({ link: { to: "/integrations", search: { integration: "railway" } } }) + }) + + it("returns null for a check this build does not know", () => { + expect(setupHealthAction({ id: "NEW-99", affected: [] })).toBeNull() + }) +}) + +describe("orderFindings", () => { + it("keeps only failures, most severe first, report order within a severity", () => { + const ordered = orderFindings([ + { id: "a", status: "fail", severity: "info" }, + { id: "b", status: "pass", severity: "critical" }, + { id: "c", status: "fail", severity: "warn" }, + { id: "d", status: "fail", severity: "critical" }, + { id: "e", status: "fail", severity: "warn" }, + { id: "f", status: "skip", severity: "warn" }, + ]) + expect(ordered.map((check) => check.id)).toEqual(["d", "c", "e", "a"]) + }) +}) + +describe("healthSegments", () => { + it("orders segments critical to passing and drops empty ones", () => { + expect(healthSegments({ critical: 0, warn: 2, info: 1, pass: 40, skip: 0 })).toEqual([ + { segment: "warn", count: 2 }, + { segment: "info", count: 1 }, + { segment: "pass", count: 40 }, + ]) + }) +}) + +describe("resolvedChecks", () => { + const checks = (statuses: ReadonlyArray<"pass" | "skip" | "fail">) => + statuses.map((status, index) => ({ id: `C-${index}`, status })) + + it("counts passed and skipped checks apart and leaves failures out", () => { + const result = resolvedChecks(checks(["pass", "skip", "fail", "pass", "skip", "skip"])) + expect(result.label).toBe("Passing (2) and skipped (3)") + expect(result.checks.map((check) => check.id)).toEqual(["C-0", "C-1", "C-3", "C-4", "C-5"]) + }) + + it("names only the group that has checks", () => { + expect(resolvedChecks(checks(["pass", "fail"])).label).toBe("Passing (1)") + expect(resolvedChecks(checks(["skip", "skip"])).label).toBe("Skipped (2)") + expect(resolvedChecks(checks(["fail"])).checks).toEqual([]) + }) +}) diff --git a/apps/web/src/components/settings/setup-health.ts b/apps/web/src/components/settings/setup-health.ts new file mode 100644 index 0000000000..372ac1e507 --- /dev/null +++ b/apps/web/src/components/settings/setup-health.ts @@ -0,0 +1,168 @@ +import { decodePublicId, PublicIdPrefixes, type V2SetupAuditCheck } from "@maple/domain/http/v2" + +import { docsUrl } from "@/lib/docs" + +type Check = Pick + +/** An in-app destination, one shape per route so TanStack Router can type its search. */ +export type SetupHealthLink = + | { readonly to: "/alerts"; readonly search: { readonly tab: "rules" | "routing" } } + | { readonly to: "/alerts/create"; readonly search: { readonly ruleId?: string } } + | { readonly to: "/anomalies" } + | { readonly to: "/dashboards/templates" } + | { readonly to: "/integrations"; readonly search: { readonly integration?: string } } + | { + readonly to: "/settings" + readonly search: { readonly tab: "ingestion" | "data-platform" } + readonly hash?: string + } + +/** The one thing to do about a finding: open the place that fixes it, or read the guide. */ +export type SetupHealthAction = + | { readonly kind: "link"; readonly label: string; readonly link: SetupHealthLink } + | { readonly kind: "docs"; readonly label: string; readonly href: string } + +const conventions = (anchor: string) => docsUrl("otelConventions", anchor) + +/** Telemetry checks are fixed in the customer's code; each gets the guide section that explains it. */ +const GUIDES = new Map([ + ["RES-01", { label: "Read service identity guide", href: conventions("service-identity") }], + ["ING-01", { label: "Read service identity guide", href: conventions("service-identity") }], + ["LOG-01", { label: "Read logs guide", href: conventions("logs") }], + ["LOG-02", { label: "Read trace correlation guide", href: conventions("trace-correlation") }], + ["LOG-03", { label: "Read logs guide", href: conventions("logs") }], + ["LOG-04", { label: "Read severity levels guide", href: conventions("severity-levels") }], + ["RES-06", { label: "Read service identity guide", href: conventions("service-identity") }], + ["RES-02", { label: "Read version guide", href: conventions("deployment-and-version-tracking") }], + ["RES-03", { label: "Read environment guide", href: conventions("deployment-and-version-tracking") }], + ["RES-04", { label: "Read source linking guide", href: conventions("deployment-and-version-tracking") }], + ["RES-05", { label: "Read version guide", href: conventions("deployment-and-version-tracking") }], + ["NAME-02", { label: "Read span naming guide", href: conventions("http-attributes") }], + ["NAME-03", { label: "Read reserved namespace guide", href: conventions("reserved-namespace") }], + ["PII-01", { label: "Read attribute conventions", href: docsUrl("otelConventions") }], + ["MET-01", { label: "Read metrics guide", href: conventions("metrics") }], + ["STAT-01", { label: "Read span status guide", href: conventions("span-status-codes") }], + ["STAT-02", { label: "Read errors guide", href: conventions("errors-and-exceptions") }], + ["STAT-04", { label: "Read span kinds guide", href: conventions("span-kinds") }], + ["MAP-02", { label: "Read database guide", href: conventions("2-database-nodes") }], + ["MAP-03", { label: "Read service map guide", href: conventions("4-pick-canonical-names") }], + ["TRC-01", { label: "Read service map guide", href: conventions("1-service-to-service-edges") }], + ["TRC-02", { label: "Read service map guide", href: conventions("service-map") }], + ["TRC-03", { label: "Read sampling guide", href: docsUrl("samplingThroughput") }], +]) + +const INTEGRATION_FOR_CHECK = new Map([ + ["CFG-INTEG-02", "cloudflare"], + ["CFG-INTEG-03", "github"], + ["CFG-INTEG-04", "railway"], + ["CFG-INTEG-05", "planetscale"], + ["CFG-INTEG-06", "cloudflare"], + ["CFG-SCRAPE-01", "prometheus"], +]) + +/** The first affected alert rule's internal id, for the rule editor's `ruleId`. */ +function firstAffectedRuleId(check: Check): string | null { + const ruleIds = check.affected.flatMap((entity) => + entity.kind === "alert_rule" && entity.id !== null + ? [decodePublicId(PublicIdPrefixes.alertRule, entity.id)] + : [], + ) + return ruleIds.find((ruleId) => ruleId !== null) ?? null +} + +function ruleAction(check: Check, label: string): SetupHealthAction { + const ruleId = firstAffectedRuleId(check) + return ruleId === null + ? { kind: "link", label: "Open alert rules", link: { to: "/alerts", search: { tab: "rules" } } } + : { kind: "link", label, link: { to: "/alerts/create", search: { ruleId } } } +} + +const link = (label: string, target: SetupHealthLink): SetupHealthAction => ({ + kind: "link", + label, + link: target, +}) + +/** The single, specific action for a check; null for a check id this build does not know. */ +export function setupHealthAction(check: Check): SetupHealthAction | null { + const guide = GUIDES.get(check.id) + if (guide !== undefined) return { kind: "docs", ...guide } + switch (check.id) { + case "CFG-ALERT-01": + case "CFG-ALERT-04": + return link("Open destinations", { to: "/alerts", search: { tab: "routing" } }) + case "CFG-ALERT-02": + return link("Create alert rule", { to: "/alerts/create", search: {} }) + case "CFG-ALERT-03": + return ruleAction(check, "Add a destination") + case "CFG-ALERT-05": + return ruleAction(check, "Open rule") + // No error notification or anomaly settings UI yet: the Alerts routing redesign will own them. + case "CFG-NOTIF-01": + return link("Open destinations", { to: "/alerts", search: { tab: "routing" } }) + case "CFG-ANOM-01": + return link("Open anomalies", { to: "/anomalies" }) + case "CFG-DASH-01": + return link("Browse templates", { to: "/dashboards/templates" }) + case "CFG-SAMP-01": + return { kind: "docs", label: "Read sampling guide", href: docsUrl("samplingThroughput") } + case "CFG-ATTR-01": + case "CFG-MAP-01": + case "CFG-MAP-02": + return link("Open ingestion", { to: "/settings", search: { tab: "ingestion" } }) + case "CFG-BYOC-01": + case "CFG-BYOC-02": + return link("Open data platform", { to: "/settings", search: { tab: "data-platform" } }) + case "CFG-INTEG-01": { + // Revoked grants name their provider; one link per finding goes to the first. + const provider = check.affected.find((entity) => entity.kind === "integration")?.name + return link("Reconnect", { + to: "/integrations", + search: provider === undefined ? {} : { integration: provider }, + }) + } + default: { + const integration = INTEGRATION_FOR_CHECK.get(check.id) + return integration === undefined + ? null + : link("Open integration", { to: "/integrations", search: { integration } }) + } + } +} + +type Severity = V2SetupAuditCheck["severity"] +const SEVERITY_RANK = { critical: 0, warn: 1, info: 2 } satisfies Record + +/** Failing checks, most severe first; the report's own order breaks ties. */ +export function orderFindings>( + checks: ReadonlyArray, +): ReadonlyArray { + return checks + .filter((check) => check.status === "fail") + .toSorted((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]) +} + +export type HealthSegment = "critical" | "warn" | "info" | "pass" | "skip" + +const SEGMENT_ORDER: ReadonlyArray = ["critical", "warn", "info", "pass", "skip"] + +/** The summary bar's segments in display order, empty ones dropped. */ +export function healthSegments( + summary: Record, +): ReadonlyArray<{ readonly segment: HealthSegment; readonly count: number }> { + return SEGMENT_ORDER.map((segment) => ({ segment, count: summary[segment] })).filter( + ({ count }) => count > 0, + ) +} + +/** Checks that did not fail, with a header that counts passed and skipped apart like the summary. */ +export function resolvedChecks>( + checks: ReadonlyArray, +): { readonly checks: ReadonlyArray; readonly label: string } { + const resolved = checks.filter((check) => check.status !== "fail") + const skipped = resolved.filter((check) => check.status === "skip").length + const passed = resolved.length - skipped + if (skipped === 0) return { checks: resolved, label: `Passing (${passed})` } + if (passed === 0) return { checks: resolved, label: `Skipped (${skipped})` } + return { checks: resolved, label: `Passing (${passed}) and skipped (${skipped})` } +} diff --git a/apps/web/src/lib/docs.ts b/apps/web/src/lib/docs.ts index 775f47af6d..19c6f022f3 100644 --- a/apps/web/src/lib/docs.ts +++ b/apps/web/src/lib/docs.ts @@ -7,6 +7,7 @@ export const DOCS = { instrumentation: "/docs/instrumentation", quickstart: "/docs/getting-started/quickstart", otelConventions: "/docs/concepts/otel-conventions", + samplingThroughput: "/docs/concepts/sampling-throughput", retention: "/docs/reference/retention", traces: "/docs/explore/traces", logs: "/docs/explore/logs", @@ -45,6 +46,7 @@ export type DocsPage = keyof typeof DOCS const DOCS_ORIGIN = "https://maple.dev" -export function docsUrl(page: DocsPage): string { - return `${DOCS_ORIGIN}${DOCS[page]}` +/** `anchor` is a heading slug on that page, e.g. `trace-correlation`. */ +export function docsUrl(page: DocsPage, anchor?: string): string { + return `${DOCS_ORIGIN}${DOCS[page]}${anchor === undefined ? "" : `#${anchor}`}` } diff --git a/apps/web/src/lib/services/atoms/audit-log-atoms.ts b/apps/web/src/lib/services/atoms/audit-log-atoms.ts index 1e1ccf3538..cb30e9fffd 100644 --- a/apps/web/src/lib/services/atoms/audit-log-atoms.ts +++ b/apps/web/src/lib/services/atoms/audit-log-atoms.ts @@ -5,47 +5,57 @@ import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" export const AUDIT_LOG_PAGE_LIMIT = 50 -const ACTOR_TYPES: ReadonlyArray = ["user", "api_key", "agent", "system"] -const OUTCOMES: ReadonlyArray = ["allowed", "denied"] - export interface AuditLogPageInput { readonly cursor?: string readonly actorType?: AuditActorType + readonly actorId?: string readonly outcome?: AuditOutcome + /** Lower bound on `occurred_at`, from the date range filter. */ + readonly since?: string /** - * Upper bound on `occurred_at`, pinned by the caller when it takes the first - * page. Pagination here is offset-based over a newest-first, append-only - * table, so an entry written mid-scroll shifts every later row down by one: - * without a frozen ceiling the next page repeats a row and skips another. + * Upper bound on `occurred_at`: the date range's end, or the ceiling the caller pins when it + * takes the first page. Pagination here is offset-based over a newest-first, append-only + * table, so an entry written mid-scroll would otherwise repeat a row and skip another. */ readonly until?: string } -// Actor types and outcomes never contain "|", nor does an ISO timestamp, and the -// cursor is the trailing segment — so splitting on the first three separators -// stays unambiguous even for exotic cursors. -const family = Atom.family((key: string) => { - const [actorRaw = "", outcomeRaw = "", until = ""] = key.split("|", 3) - const cursor = key.slice(actorRaw.length + outcomeRaw.length + until.length + 3) - const actorType = ACTOR_TYPES.find((type) => type === actorRaw) - const outcome = OUTCOMES.find((value) => value === outcomeRaw) +// Keyed by a plain struct (Effect's structural equality), so no value can bleed into another field. +interface AuditLogPageKey { + readonly cursor: string | undefined + readonly actorType: AuditActorType | undefined + readonly actorId: string | undefined + readonly outcome: AuditOutcome | undefined + readonly since: string | undefined + readonly until: string | undefined +} - return MapleApiV2AtomClient.runtime.atom( +const family = Atom.family(({ cursor, actorType, actorId, outcome, since, until }: AuditLogPageKey) => + MapleApiV2AtomClient.runtime.atom( Effect.gen(function* () { const client = yield* MapleApiV2AtomClient return yield* client.auditLog.list({ query: { limit: AUDIT_LOG_PAGE_LIMIT, - ...(cursor !== "" ? { cursor } : undefined), + ...(cursor !== undefined ? { cursor } : undefined), ...(actorType !== undefined ? { actor_type: actorType } : undefined), + ...(actorId !== undefined ? { actor_id: actorId } : undefined), ...(outcome !== undefined ? { outcome } : undefined), - ...(until !== "" ? { until } : undefined), + ...(since !== undefined ? { since } : undefined), + ...(until !== undefined ? { until } : undefined), }, }) }), - ) -}) + ), +) /** One page of the org's audit log, keyed by cursor + filters + the pinned ceiling. */ export const auditLogPageAtom = (input: AuditLogPageInput) => - family(`${input.actorType ?? ""}|${input.outcome ?? ""}|${input.until ?? ""}|${input.cursor ?? ""}`) + family({ + cursor: input.cursor, + actorType: input.actorType, + actorId: input.actorId, + outcome: input.outcome, + since: input.since, + until: input.until, + }) diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index eeced8a552..e6043be902 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -16,6 +16,7 @@ import { AutomationSection } from "@/components/settings/automation-section" import { OrgClickHouseSettingsSection } from "@/components/settings/org-clickhouse-settings-section" import { GeneralSection } from "@/components/settings/general-section" import { SetupAuditSection } from "@/components/settings/setup-audit-section" +import { AuditLogSearchFields, pickAuditLogSearch } from "@/components/settings/audit-log-filters" import { legacySettingsTabValues, resolveActiveSettingsTab, @@ -31,6 +32,7 @@ const SettingsSearch = Schema.Struct({ tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacySettingsTabValues])), // Stripe Checkout return marker — see `lib/billing/checkout-return.ts`. checkout: Schema.optional(Schema.Literal("complete")), + ...AuditLogSearchFields, }) export const Route = createFileRoute("/settings")({ @@ -62,7 +64,8 @@ function SettingsPage() { const activeTab = resolveActiveSettingsTab(search.tab, visibleItems) function handleTabSelect(tab: SettingsTab) { - navigate({ search: { tab } }) + // Audit log filters ride along, so leaving the tab and coming back keeps them. + void navigate({ search: { tab, ...pickAuditLogSearch(search) } }) } // `data-platform` is the only tab whose visibility depends on the billing diff --git a/packages/backend/src/services/audit/audit-actions.ts b/packages/backend/src/services/audit/audit-actions.ts index 73443af9f4..686f7e99eb 100644 --- a/packages/backend/src/services/audit/audit-actions.ts +++ b/packages/backend/src/services/audit/audit-actions.ts @@ -1,133 +1,8 @@ -import { encodePublicId, type PublicIdPrefix, PublicIdPrefixes } from "@maple/domain/http/v2" -import { ErrorIssueEventType } from "@maple/domain/http" +import { encodePublicId, type PublicIdPrefix } from "@maple/domain/http/v2" +import { type AuditAction, AuditResources, type AuditResourceType } from "@maple/domain/audit-actions" -/** - * Every audited action in Maple, grouped by the resource it acts on. - * - * The key is both the `resource_type` stored on the row and the `` - * half of the `.` action string, so the two can never disagree. - * `prefix` is the public-ID prefix the resource's internal ID is encoded with; - * resources that are org-singletons (`ingest_key`, `anomaly_settings`) or carry - * no resource at all (`api`) omit it, and passing a `resourceId` for one of - * those is a type error. - * - * Adding an entry here is what makes `record({ action: "." })` - * compile — a typo, or an action recorded before it is declared, fails the build. - */ -export const AuditResources = { - agent: { prefix: PublicIdPrefixes.actor, verbs: ["registered"] }, - /** Feedback about Maple sent by an agent; kind and agent type are in `metadata`. */ - agent_feedback: { prefix: PublicIdPrefixes.agentFeedback, verbs: ["submitted"] }, - /** Org-singleton OpenRouter key the org's AI turns run on; the key is never in `changes`. */ - ai_provider_key: { verbs: ["updated", "deleted"] }, - alert_destination: { - prefix: PublicIdPrefixes.alertDestination, - verbs: ["created", "updated", "deleted"], - }, - alert_rule: { prefix: PublicIdPrefixes.alertRule, verbs: ["created", "updated", "deleted"] }, - anomaly_incident: { prefix: PublicIdPrefixes.anomalyIncident, verbs: ["resolved"] }, - /** Org-singleton settings — no resource id. */ - anomaly_settings: { verbs: ["updated"] }, - /** Refused requests, recorded by the auth layers; the route is in `metadata`. */ - api: { verbs: ["request"] }, - api_key: { prefix: PublicIdPrefixes.apiKey, verbs: ["created", "rolled", "revoked"] }, - attribute_mapping: { - prefix: PublicIdPrefixes.attributeMapping, - verbs: ["created", "updated", "deleted"], - }, - dashboard: { - prefix: PublicIdPrefixes.dashboard, - verbs: ["created", "updated", "deleted", "version_restored"], - }, - /** - * Chat workspaces linked to the org. `install_started` is the admin action - * Maple sees; the platform's callback completes the link. `metadata` names - * the connector — the platform is a value here, never part of the action. - * - * `identity_*` are the per-member half: one person binding their own chat - * account to their Maple user, which is standing authority to act as them from - * the chat platform. `identity_link_started` records the member starting it, the - * platform's callback completes it, and neither carries a resource id — the - * subject of both is the actor. - */ - chat_integration: { - prefix: PublicIdPrefixes.chatWorkspace, - verbs: [ - "install_started", - "settings_updated", - "uninstalled", - "identity_link_started", - "identity_unlinked", - ], - }, - dashboard_share: { prefix: PublicIdPrefixes.dashboardShare, verbs: ["created", "rotated", "deleted"] }, - /** Verbs mirror the issue event types — `recordEvent` audits every one it attributes. */ - error_issue: { prefix: PublicIdPrefixes.errorIssue, verbs: ErrorIssueEventType.literals }, - /** One connected Google Cloud scope; the scope and what it collects are in `metadata`. */ - gcp_connector: { prefix: PublicIdPrefixes.gcpConnector, verbs: ["created", "updated", "deleted"] }, - /** Org-singleton public/private pair; which one rolled is in `metadata`. */ - ingest_key: { verbs: ["rolled"] }, - /** - * Every MCP tool invocation, whichever surface drove it (MCP transport, the - * in-app chat, workflows, internal RPC). The tool and its parameters are in - * `metadata`; a tool that also mutates a resource records that action too. - */ - mcp_tool: { verbs: ["called"] }, - investigation: { - prefix: PublicIdPrefixes.investigation, - verbs: ["created", "restarted", "status_changed"], - }, - /** Org-singleton credit; the amount is in `metadata`. */ - onboarding_reward: { verbs: ["claimed"] }, - /** - * Org-singleton connections. `*_started` is the admin action Maple sees; the - * OAuth round trip completes at the provider's callback. - */ - planetscale_integration: { - verbs: ["connect_started", "organization_selected", "metrics_token_set", "disconnected"], - }, - /** - * Org membership, learned from Clerk's webhook — the web app changes members - * in Clerk directly, so nothing reaches Maple's own API. The member is the - * entry's `affected_user`; no prefix, since Clerk IDs are already public. - */ - member: { verbs: ["added", "role_changed", "removed"] }, - /** - * The org itself. No prefix: every row already carries `org_id`, and a - * deleted org has no public ID left to resolve. - */ - organization: { verbs: ["deleted", "region_chosen"] }, - scrape_target: { prefix: PublicIdPrefixes.scrapeTarget, verbs: ["created", "updated", "deleted"] }, - /** - * Reads of recorded browser sessions — the surface most likely to carry - * end-user data. Recorded by the auth layers from the `AuditedRead` annotation. - */ - session_replay: { verbs: ["read"] }, - /** Org-singleton shared Slack channel; the invited email is in `metadata`. */ - support_channel: { verbs: ["created", "invite_sent"] }, - /** - * Reads of traces, logs, metrics and error events (`read`, from the - * `AuditedRead` annotation on the endpoint) and every raw SQL statement run - * against the warehouse (`sql_executed`, with the statement in `metadata`). - */ - telemetry: { verbs: ["read", "sql_executed"] }, - /** Org-singleton BYO-ClickHouse connection; holds warehouse credentials. */ - warehouse_settings: { verbs: ["updated", "deleted", "schema_applied"] }, - /** Short-lived device credentials for the mobile widget; keyed by installation. */ - widget_credential: { verbs: ["minted", "revoked"] }, -} as const satisfies Record - -interface AuditResourceDefinition { - readonly prefix?: PublicIdPrefix - readonly verbs: ReadonlyArray -} - -export type AuditResourceType = keyof typeof AuditResources - -/** `.` for every declared pair — the closed set of audit actions. */ -export type AuditAction = { - [K in AuditResourceType]: `${K}.${(typeof AuditResources)[K]["verbs"][number]}` -}[AuditResourceType] +// The catalog lives in `@maple/domain` so the web can label entries with it. +export { type AuditAction, AuditResources, type AuditResourceType } type ResourceOf = A extends `${infer R}.${string}` ? R extends AuditResourceType diff --git a/packages/backend/src/services/org/OnboardingChecklistService.test.ts b/packages/backend/src/services/org/OnboardingChecklistService.test.ts index a0a3234080..3553742a7c 100644 --- a/packages/backend/src/services/org/OnboardingChecklistService.test.ts +++ b/packages/backend/src/services/org/OnboardingChecklistService.test.ts @@ -1,5 +1,5 @@ import { afterEach, assert, describe, it } from "@effect/vitest" -import { ConfigProvider, Effect, Layer, Schema } from "effect" +import { ConfigProvider, Effect, Layer, Option, Schema } from "effect" import { TestClock } from "effect/testing" import { OrgId, UserId } from "@maple/domain/http" import { ONBOARDING_REWARD_WINDOW_MS } from "@maple/domain/onboarding-checklist" @@ -23,6 +23,7 @@ import { OnboardingChecklistService } from "@maple/backend/services/org/Onboardi import { OnboardingService } from "@maple/backend/services/org/OnboardingService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { OrganizationService } from "@maple/backend/services/org/OrganizationService" +import { readOrgCreatedAt } from "@maple/backend/services/org/org-created-at" import { SignalPresenceService } from "@maple/backend/services/org/SignalPresenceService" import { SupportChannelService } from "@maple/backend/services/support/SupportChannelService" @@ -376,6 +377,39 @@ describe("OnboardingChecklistService.read", () => { }) }) +describe("first-data stamping on an org without onboarding state", () => { + const DAY_AND_AN_HOUR_MS = 25 * 60 * 60 * 1000 + const stampFirstData = Effect.gen(function* () { + const onboarding = yield* OnboardingService + const organizations = yield* OrganizationService + return yield* onboarding.recordFirstDataReceived(ORG, readOrgCreatedAt(organizations, ORG)) + }) + + it.effect("dates the new row by the org's real creation time, so the window stays closed", () => { + const world = freshWorld() + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* TestClock.adjust(`${DAY_AND_AN_HOUR_MS} millis`) + assert.isTrue(yield* stampFirstData) + const row = yield* (yield* OnboardingService).findState(ORG) + assert.strictEqual(Option.getOrNull(row)?.createdAt, 0) + assert.strictEqual(Option.getOrNull(row)?.firstDataReceivedAt, DAY_AND_AN_HOUR_MS) + const report = yield* (yield* OnboardingChecklistService).read(tenant) + assert.strictEqual(report.status, "expired") + }).pipe(Effect.provide(makeLayer(world, testDb))) + }) + + it.effect("creates no row when the provider cannot date the org", () => { + const world = freshWorld() + world.orgLookupFails = true + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + assert.isFalse(yield* stampFirstData) + assert.isTrue(Option.isNone(yield* (yield* OnboardingService).findState(ORG))) + }).pipe(Effect.provide(makeLayer(world, testDb))) + }) +}) + describe("OnboardingChecklistService.claim", () => { it.effect("redeems the reward once and stamps the row", () => { const world = freshWorld() diff --git a/packages/backend/src/services/org/OnboardingChecklistService.ts b/packages/backend/src/services/org/OnboardingChecklistService.ts index 4a663763ca..66b810009b 100644 --- a/packages/backend/src/services/org/OnboardingChecklistService.ts +++ b/packages/backend/src/services/org/OnboardingChecklistService.ts @@ -26,6 +26,7 @@ import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" import { OnboardingService } from "@maple/backend/services/org/OnboardingService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { OrganizationService } from "@maple/backend/services/org/OrganizationService" +import { readOrgCreatedAt } from "@maple/backend/services/org/org-created-at" import { SignalPresenceService } from "@maple/backend/services/org/SignalPresenceService" import { SupportChannelService } from "@maple/backend/services/support/SupportChannelService" @@ -136,15 +137,7 @@ const make = Effect.gen(function* () { } } - const createdAt = yield* organizations.retrieve(tenant.orgId).pipe( - Effect.map((org) => org.createdAtMs), - Effect.catchCause((cause) => - Effect.logWarning("Onboarding checklist could not read the org's creation time").pipe( - Effect.annotateLogs({ orgId: tenant.orgId, cause }), - Effect.as(null), - ), - ), - ) + const createdAt = yield* readOrgCreatedAt(organizations, tenant.orgId) if (createdAt === null) return { orgCreatedAtMs: null, rewardClaimedAtMs: null } const row = yield* unavailable( diff --git a/packages/backend/src/services/org/OnboardingService.ts b/packages/backend/src/services/org/OnboardingService.ts index 398449d30c..c004110745 100644 --- a/packages/backend/src/services/org/OnboardingService.ts +++ b/packages/backend/src/services/org/OnboardingService.ts @@ -158,20 +158,46 @@ export class OnboardingService extends Context.Service()( return rowToResponse(row) }) - /** Stamp first-data time only if not already set. Returns true when newly stamped. */ + /** + * Stamp first-data time only if not already set. Returns true when newly stamped. + * A missing row is created with the org's real creation time (it dates the reward + * window); when `orgCreatedAt` yields null no row is created and nothing is stamped. + */ const recordFirstDataReceived = Effect.fn("OnboardingService.recordFirstDataReceived")(function* ( orgId: OrgId, + orgCreatedAt: Effect.Effect, ) { const now = yield* Clock.currentTimeMillis - const result = yield* dbExecute((db) => + const stampExisting = dbExecute((db) => db.run( PG.update(OrgOnboardingState) .set({ firstDataReceivedAt: now, updatedAt: now }) .where(($) => [$.orgId.eq(orgId), $.firstDataReceivedAt.isNull()]) .returning(($) => ({ id: $.orgId })), ), + ).pipe(Effect.map((rows) => rows.length > 0)) + + if (yield* stampExisting) return true + if ((yield* findRow(orgId)) !== undefined) return false + + const createdAt = yield* orgCreatedAt + if (createdAt === null) return false + const inserted = yield* dbExecute((db) => + db.run( + PG.insertInto(OrgOnboardingState) + .values({ + orgId, + demoDataRequested: false, + firstDataReceivedAt: now, + createdAt, + updatedAt: now, + }) + .onConflictDoNothing() + .returning(($) => ({ id: $.orgId })), + ), ) - return result.length > 0 + // Lost a race with another creator: stamp the row it made instead. + return inserted.length > 0 ? true : yield* stampExisting }) /** diff --git a/packages/backend/src/services/org/SetupAuditService.test.ts b/packages/backend/src/services/org/SetupAuditService.test.ts new file mode 100644 index 0000000000..06f96af258 --- /dev/null +++ b/packages/backend/src/services/org/SetupAuditService.test.ts @@ -0,0 +1,162 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { Effect, Layer, Schema } from "effect" +import { OrgId, UserId } from "@maple/domain/http" +import { SETUP_AUDIT_CHECKS } from "@maple/domain/setup-audit" +import { compiledQueryOf } from "@maple/query-engine/execution" +import { + cleanupTestDbs, + createTestDb, + executeSql, + queryFirstRow, + type TestDb, +} from "@maple/backend/platform/test-pglite" +import type { TenantContext } from "@maple/backend/services/auth/AuthService" +import { IntegrationInventoryService } from "@maple/backend/services/integrations/IntegrationInventoryService" +import { OnboardingService } from "@maple/backend/services/org/OnboardingService" +import { OrganizationService } from "@maple/backend/services/org/OrganizationService" +import { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" +import { SignalPresenceService } from "@maple/backend/services/org/SignalPresenceService" +import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { makeWarehouseServiceStub } from "@maple/backend/testing/warehouse-test-support" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeUnknownSync(OrgId)("org_setup_audit") +const USER = Schema.decodeUnknownSync(UserId)("user_setup_audit") +const tenant: TenantContext = { orgId: ORG, userId: USER, roles: ["org:admin"], authMode: "self_hosted" } + +type Probe = "present" | "absent" | "unavailable" + +const signalPresence = (probe: Probe) => + Layer.succeed(SignalPresenceService, { + read: () => + Effect.succeed({ + generatedAt: 0, + windowStart: 0, + windowEnd: 0, + warehouseAvailable: probe !== "unavailable", + signals: [ + { + signal: "traces", + status: probe === "unavailable" ? "unknown" : probe, + count: probe === "present" ? 1 : 0, + firstSeen: null, + lastSeen: null, + }, + { + signal: "sessions", + status: probe === "unavailable" ? "unknown" : "present", + count: 1, + firstSeen: null, + lastSeen: null, + }, + ], + }), + }) + +const warehouse = Layer.succeed( + WarehouseQueryService, + makeWarehouseServiceStub({ + compiledQuery: (_tenant, compiled) => compiledQueryOf(compiled).decodeRows([]).pipe(Effect.orDie), + }), +) + +const ORG_CREATED_AT = Date.UTC(2025, 0, 1) + +const organizations = Layer.succeed(OrganizationService, { + retrieve: (orgId) => + Effect.succeed({ id: orgId, name: null, slug: null, imageUrl: null, createdAtMs: ORG_CREATED_AT }), +}) + +const makeLayer = (testDb: TestDb, probe: Probe) => + Layer.effect(SetupAuditService, SetupAuditService.make).pipe( + Layer.provide( + Layer.mergeAll( + warehouse, + signalPresence(probe), + OnboardingService.layer, + organizations, + IntegrationInventoryService.layer, + ), + ), + Layer.provideMerge(testDb.layer), + ) + +const firstDataInDb = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow<{ first_data_received_at: Date | null; created_at: Date }>( + testDb, + `SELECT first_data_received_at, created_at FROM org_onboarding_state WHERE org_id = $1`, + [ORG], + ), + ) + +const seedUnstampedRow = (testDb: TestDb) => + Effect.promise(() => + executeSql( + testDb, + `INSERT INTO org_onboarding_state (org_id, demo_data_requested, created_at, updated_at) + VALUES ($1, false, now(), now())`, + [ORG], + ), + ) + +describe("SetupAuditService first-data stamping", () => { + it.effect("runs the audit and stamps the row when an unstamped org has telemetry", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* seedUnstampedRow(testDb) + const report = yield* SetupAuditService.use((audit) => audit.run(tenant)) + assert.strictEqual(report.dataStatus, "ok") + assert.isAbove(report.checks.length, 0) + const row = yield* firstDataInDb(testDb) + assert.isNotNull(row?.first_data_received_at ?? null) + }).pipe(Effect.provide(makeLayer(testDb, "present"))) + }) + + it.effect("creates the row dated by the org's creation time when the org has no onboarding state", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const report = yield* SetupAuditService.use((audit) => audit.run(tenant)) + assert.strictEqual(report.dataStatus, "ok") + const row = yield* firstDataInDb(testDb) + assert.isNotNull(row?.first_data_received_at ?? null) + assert.strictEqual(row?.created_at.getTime(), ORG_CREATED_AT) + }).pipe(Effect.provide(makeLayer(testDb, "present"))) + }) + + it.effect("keeps no_data and leaves the row unstamped when the org has no telemetry", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* seedUnstampedRow(testDb) + const report = yield* SetupAuditService.use((audit) => audit.run(tenant)) + assert.strictEqual(report.dataStatus, "no_data") + assert.strictEqual(report.checks.length, 0) + const row = yield* firstDataInDb(testDb) + assert.isNull(row?.first_data_received_at ?? null) + }).pipe(Effect.provide(makeLayer(testDb, "absent"))) + }) + + it.effect( + "runs the config checks, skips telemetry checks, and leaves the row unstamped when the probe cannot read the warehouse", + () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + yield* seedUnstampedRow(testDb) + const report = yield* SetupAuditService.use((audit) => audit.run(tenant)) + assert.strictEqual(report.dataStatus, "ok") + assert.isFalse(report.warehouseAvailable) + const skipped = report.checks.filter((row) => row.status === "skip").map((row) => row.id) + const needsWarehouse = SETUP_AUDIT_CHECKS.filter((row) => row.needsWarehouse === true) + assert.deepStrictEqual( + skipped, + needsWarehouse.map((row) => row.id), + ) + assert.isAbove(report.checks.length, skipped.length) + const row = yield* firstDataInDb(testDb) + assert.isNull(row?.first_data_received_at ?? null) + }).pipe(Effect.provide(makeLayer(testDb, "unavailable"))) + }, + ) +}) diff --git a/packages/backend/src/services/org/SetupAuditService.ts b/packages/backend/src/services/org/SetupAuditService.ts index 1c2aed5f51..c6beddaaeb 100644 --- a/packages/backend/src/services/org/SetupAuditService.ts +++ b/packages/backend/src/services/org/SetupAuditService.ts @@ -33,6 +33,10 @@ import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import { IntegrationInventoryService } from "@maple/backend/services/integrations/IntegrationInventoryService" +import { OnboardingService } from "@maple/backend/services/org/OnboardingService" +import { OrganizationService } from "@maple/backend/services/org/OrganizationService" +import { readOrgCreatedAt } from "@maple/backend/services/org/org-created-at" +import { SignalPresenceService } from "@maple/backend/services/org/SignalPresenceService" import * as Integrations from "@maple/query-engine-integrations" /** Telemetry lookback for the warehouse-backed checks — matches the recommendations reconcile. */ @@ -52,13 +56,22 @@ export interface SetupAuditServiceApi { readonly run: (tenant: TenantContext) => Effect.Effect } -type SetupAuditServiceRequirements = Database | WarehouseQueryService | IntegrationInventoryService +type SetupAuditServiceRequirements = + | Database + | WarehouseQueryService + | IntegrationInventoryService + | OnboardingService + | OrganizationService + | SignalPresenceService const make: Effect.Effect = Effect.gen( function* () { const database = yield* Database const warehouse = yield* WarehouseQueryService const inventory = yield* IntegrationInventoryService + const onboarding = yield* OnboardingService + const organizations = yield* OrganizationService + const signals = yield* SignalPresenceService const dbExecute = makeDbExecute( database, @@ -233,7 +246,7 @@ const make: Effect.Effect ({ id: rule.id, name: rule.name, @@ -488,21 +501,65 @@ const make: Effect.Effect + signal.status === "present" && + (signal.signal === "traces" || signal.signal === "logs" || signal.signal === "metrics"), + ) + const probe: ConfigAuditInputs["telemetry"] = report.warehouseAvailable + ? hasTelemetry + ? "present" + : "absent" + : "unavailable" + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "maple.audit.first_data_probe": probe }) + if (probe !== "present") return probe + + // A failed write must not hide the audit from an org that has data; the next run retries. + const stamped = yield* onboarding + .recordFirstDataReceived(tenant.orgId, readOrgCreatedAt(organizations, tenant.orgId)) + .pipe( + Effect.tapError((error) => + Effect.logWarning("Setup audit could not stamp first data received", error.message), + ), + Effect.orElseSucceed(() => false), + ) + yield* Effect.annotateCurrentSpan({ "maple.audit.first_data_stamped": stamped }) + return probe + }) + const run = Effect.fn("SetupAuditService.run")(function* (tenant: TenantContext) { - const config = yield* fetchConfigInputs(tenant.orgId) + const recorded = yield* fetchConfigInputs(tenant.orgId) + const config: ConfigAuditInputs = { + ...recorded, + telemetry: yield* resolveTelemetry(tenant, recorded.telemetry), + } // A warehouse outage must not take the whole audit down — the configuration half is the // part that most often explains "why didn't anything page me", and it is always readable. // `catchCause` rather than `Effect.option` deliberately: a driver-level defect should // degrade this diagnostic endpoint to skipped checks, not 500 it. - const warehouseInputs = yield* fetchWarehouseInputs(tenant).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Setup audit telemetry checks skipped — warehouse unavailable").pipe( - Effect.annotateLogs({ cause }), - Effect.as(undefined), - ), - ), - ) + // The presence probe already found the warehouse unreachable: skip the telemetry checks. + const warehouseInputs = + config.telemetry === "unavailable" + ? undefined + : yield* fetchWarehouseInputs(tenant).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "Setup audit telemetry checks skipped: warehouse unavailable", + ).pipe(Effect.annotateLogs({ cause }), Effect.as(undefined)), + ), + ) const now = yield* Clock.currentTimeMillis const report = runSetupAudit({ now, config, warehouse: warehouseInputs }) @@ -529,6 +586,14 @@ export class SetupAuditService extends Context.Service + organizations.retrieve(orgId).pipe( + Effect.map((org) => org.createdAtMs), + Effect.catchCause((cause) => + Effect.logWarning("Could not read the org's creation time").pipe( + Effect.annotateLogs({ orgId, cause }), + Effect.as(null), + ), + ), + ) diff --git a/packages/domain/package.json b/packages/domain/package.json index faf11c6601..cd6573f579 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -8,6 +8,7 @@ ".": "./src/index.ts", "./ai-traffic": "./src/ai-traffic.ts", "./anticipated-errors": "./src/anticipated-errors.ts", + "./audit-actions": "./src/audit-actions.ts", "./billing": "./src/billing.ts", "./billing-tax-ids": "./src/billing-tax-ids.ts", "./format": "./src/format.ts", diff --git a/packages/domain/src/audit-actions.test.ts b/packages/domain/src/audit-actions.test.ts new file mode 100644 index 0000000000..a46b575150 --- /dev/null +++ b/packages/domain/src/audit-actions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { auditActionLabel, auditActions, AuditResources, auditResourceLabel } from "./audit-actions" + +describe("audit action labels", () => { + it("labels every action in the catalog with a readable sentence, not the raw action", () => { + const actions = auditActions() + expect(actions.length).toBeGreaterThan(0) + for (const action of actions) { + const label = auditActionLabel(action) + expect(label, action).not.toBeNull() + expect(label, action).not.toContain("_") + expect(label, action).not.toContain(".") + expect(label?.[0], action).toBe(label?.[0]?.toUpperCase()) + } + }) + + it("labels every resource type in the catalog", () => { + for (const type of Object.keys(AuditResources)) { + expect(auditResourceLabel(type), type).not.toBeNull() + } + }) + + it("returns null for an action or resource this build does not declare", () => { + expect(auditActionLabel("dashboard.exploded")).toBeNull() + expect(auditActionLabel("toString")).toBeNull() + expect(auditResourceLabel("spaceship")).toBeNull() + }) +}) diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts new file mode 100644 index 0000000000..93d3453ef7 --- /dev/null +++ b/packages/domain/src/audit-actions.ts @@ -0,0 +1,266 @@ +import { ErrorIssueEventType } from "./http/errors" +import { type PublicIdPrefix, PublicIdPrefixes } from "./http/v2/public-id" + +/** + * Every audited action in Maple, grouped by the resource it acts on. + * + * The key is both the `resource_type` stored on the row and the `` + * half of the `.` action string, so the two can never disagree. + * `prefix` is the public-ID prefix the resource's internal ID is encoded with; + * resources that are org-singletons (`ingest_key`, `anomaly_settings`) or carry + * no resource at all (`api`) omit it, and passing a `resourceId` for one of + * those is a type error. + * + * Adding an entry here is what makes `record({ action: "." })` + * compile: a typo, or an action recorded before it is declared, fails the build. + */ +export const AuditResources = { + agent: { prefix: PublicIdPrefixes.actor, verbs: ["registered"] }, + /** Feedback about Maple sent by an agent; kind and agent type are in `metadata`. */ + agent_feedback: { prefix: PublicIdPrefixes.agentFeedback, verbs: ["submitted"] }, + /** Org-singleton OpenRouter key the org's AI turns run on; the key is never in `changes`. */ + ai_provider_key: { verbs: ["updated", "deleted"] }, + alert_destination: { + prefix: PublicIdPrefixes.alertDestination, + verbs: ["created", "updated", "deleted"], + }, + alert_rule: { prefix: PublicIdPrefixes.alertRule, verbs: ["created", "updated", "deleted"] }, + anomaly_incident: { prefix: PublicIdPrefixes.anomalyIncident, verbs: ["resolved"] }, + /** Org-singleton settings, no resource id. */ + anomaly_settings: { verbs: ["updated"] }, + /** Refused requests, recorded by the auth layers; the route is in `metadata`. */ + api: { verbs: ["request"] }, + api_key: { prefix: PublicIdPrefixes.apiKey, verbs: ["created", "rolled", "revoked"] }, + attribute_mapping: { + prefix: PublicIdPrefixes.attributeMapping, + verbs: ["created", "updated", "deleted"], + }, + dashboard: { + prefix: PublicIdPrefixes.dashboard, + verbs: ["created", "updated", "deleted", "version_restored"], + }, + /** + * Chat workspaces linked to the org. `install_started` is the admin action + * Maple sees; the platform's callback completes the link. `metadata` names + * the connector; the platform is a value here, never part of the action. + * + * `identity_*` are the per-member half: one person binding their own chat + * account to their Maple user, which is standing authority to act as them from + * the chat platform. `identity_link_started` records the member starting it, the + * platform's callback completes it, and neither carries a resource id: the + * subject of both is the actor. + */ + chat_integration: { + prefix: PublicIdPrefixes.chatWorkspace, + verbs: [ + "install_started", + "settings_updated", + "uninstalled", + "identity_link_started", + "identity_unlinked", + ], + }, + dashboard_share: { prefix: PublicIdPrefixes.dashboardShare, verbs: ["created", "rotated", "deleted"] }, + /** Verbs mirror the issue event types: `recordEvent` audits every one it attributes. */ + error_issue: { prefix: PublicIdPrefixes.errorIssue, verbs: ErrorIssueEventType.literals }, + /** One connected Google Cloud scope; the scope and what it collects are in `metadata`. */ + gcp_connector: { prefix: PublicIdPrefixes.gcpConnector, verbs: ["created", "updated", "deleted"] }, + /** Org-singleton public/private pair; which one rolled is in `metadata`. */ + ingest_key: { verbs: ["rolled"] }, + /** + * Every MCP tool invocation, whichever surface drove it (MCP transport, the + * in-app chat, workflows, internal RPC). The tool and its parameters are in + * `metadata`; a tool that also mutates a resource records that action too. + */ + mcp_tool: { verbs: ["called"] }, + investigation: { + prefix: PublicIdPrefixes.investigation, + verbs: ["created", "restarted", "status_changed"], + }, + /** Org-singleton credit; the amount is in `metadata`. */ + onboarding_reward: { verbs: ["claimed"] }, + /** + * Org-singleton connections. `*_started` is the admin action Maple sees; the + * OAuth round trip completes at the provider's callback. + */ + planetscale_integration: { + verbs: ["connect_started", "organization_selected", "metrics_token_set", "disconnected"], + }, + /** + * Org membership, learned from Clerk's webhook; the web app changes members + * in Clerk directly, so nothing reaches Maple's own API. The member is the + * entry's `affected_user`; no prefix, since Clerk IDs are already public. + */ + member: { verbs: ["added", "role_changed", "removed"] }, + /** + * The org itself. No prefix: every row already carries `org_id`, and a + * deleted org has no public ID left to resolve. + */ + organization: { verbs: ["deleted", "region_chosen"] }, + scrape_target: { prefix: PublicIdPrefixes.scrapeTarget, verbs: ["created", "updated", "deleted"] }, + /** + * Reads of recorded browser sessions, the surface most likely to carry + * end-user data. Recorded by the auth layers from the `AuditedRead` annotation. + */ + session_replay: { verbs: ["read"] }, + /** Org-singleton shared Slack channel; the invited email is in `metadata`. */ + support_channel: { verbs: ["created", "invite_sent"] }, + /** + * Reads of traces, logs, metrics and error events (`read`, from the + * `AuditedRead` annotation on the endpoint) and every raw SQL statement run + * against the warehouse (`sql_executed`, with the statement in `metadata`). + */ + telemetry: { verbs: ["read", "sql_executed"] }, + /** Org-singleton BYO-ClickHouse connection; holds warehouse credentials. */ + warehouse_settings: { verbs: ["updated", "deleted", "schema_applied"] }, + /** Short-lived device credentials for the mobile widget; keyed by installation. */ + widget_credential: { verbs: ["minted", "revoked"] }, +} as const satisfies Record + +interface AuditResourceDefinition { + readonly prefix?: PublicIdPrefix + readonly verbs: ReadonlyArray +} + +export type AuditResourceType = keyof typeof AuditResources + +/** `.` for every declared pair: the closed set of audit actions. */ +export type AuditAction = { + [K in AuditResourceType]: `${K}.${(typeof AuditResources)[K]["verbs"][number]}` +}[AuditResourceType] + +/** What each kind of resource is called in the UI. */ +export const AuditResourceLabels = { + agent: "Agent", + agent_feedback: "Agent feedback", + ai_provider_key: "AI provider key", + alert_destination: "Alert destination", + alert_rule: "Alert rule", + anomaly_incident: "Anomaly", + anomaly_settings: "Anomaly detection", + api: "API", + api_key: "API key", + attribute_mapping: "Attribute mapping", + dashboard: "Dashboard", + chat_integration: "Chat integration", + dashboard_share: "Dashboard share", + error_issue: "Issue", + gcp_connector: "Google Cloud connector", + ingest_key: "Ingest key", + mcp_tool: "MCP tool", + investigation: "Investigation", + onboarding_reward: "Onboarding reward", + planetscale_integration: "PlanetScale integration", + member: "Member", + organization: "Organization", + scrape_target: "Scrape target", + session_replay: "Session replay", + support_channel: "Support channel", + telemetry: "Telemetry", + warehouse_settings: "Warehouse connection", + widget_credential: "Widget credential", +} as const satisfies Record + +/** A sentence-case label per audited action; the type makes a missing one a build error. */ +export const AuditActionLabels = { + "agent.registered": "Registered agent", + "agent_feedback.submitted": "Sent feedback", + "ai_provider_key.updated": "Updated AI provider key", + "ai_provider_key.deleted": "Removed AI provider key", + "alert_destination.created": "Created alert destination", + "alert_destination.updated": "Updated alert destination", + "alert_destination.deleted": "Deleted alert destination", + "alert_rule.created": "Created alert rule", + "alert_rule.updated": "Updated alert rule", + "alert_rule.deleted": "Deleted alert rule", + "anomaly_incident.resolved": "Resolved anomaly", + "anomaly_settings.updated": "Updated anomaly detection", + "api.request": "Made API request", + "api_key.created": "Created API key", + "api_key.rolled": "Rolled API key", + "api_key.revoked": "Revoked API key", + "attribute_mapping.created": "Created attribute mapping", + "attribute_mapping.updated": "Updated attribute mapping", + "attribute_mapping.deleted": "Deleted attribute mapping", + "dashboard.created": "Created dashboard", + "dashboard.updated": "Updated dashboard", + "dashboard.deleted": "Deleted dashboard", + "dashboard.version_restored": "Restored dashboard version", + "chat_integration.install_started": "Started chat integration install", + "chat_integration.settings_updated": "Updated chat integration", + "chat_integration.uninstalled": "Uninstalled chat integration", + "chat_integration.identity_link_started": "Started linking chat account", + "chat_integration.identity_unlinked": "Unlinked chat account", + "dashboard_share.created": "Shared dashboard", + "dashboard_share.rotated": "Rotated dashboard share link", + "dashboard_share.deleted": "Removed dashboard share link", + "error_issue.created": "Created issue", + "error_issue.state_change": "Changed issue status", + "error_issue.assignment": "Assigned issue", + "error_issue.claim": "Claimed issue", + "error_issue.release": "Released issue", + "error_issue.lease_expired": "Issue claim expired", + "error_issue.comment": "Commented on issue", + "error_issue.agent_note": "Added agent note to issue", + "error_issue.fix_proposed": "Proposed fix for issue", + "error_issue.regression": "Issue regressed", + "error_issue.snooze": "Snoozed issue", + "error_issue.unsnooze": "Unsnoozed issue", + "error_issue.ai_triage": "Triaged issue with AI", + "error_issue.anomaly_linked": "Linked anomaly to issue", + "error_issue.severity_change": "Changed issue severity", + "error_issue.pr_linked": "Linked pull request to issue", + "error_issue.pr_unlinked": "Unlinked pull request from issue", + "error_issue.pr_merged": "Merged linked pull request", + "error_issue.verification_started": "Started fix verification", + "error_issue.verification_verdict": "Recorded fix verification result", + "gcp_connector.created": "Connected Google Cloud", + "gcp_connector.updated": "Updated Google Cloud connector", + "gcp_connector.deleted": "Removed Google Cloud connector", + "ingest_key.rolled": "Rolled ingest key", + "mcp_tool.called": "Called MCP tool", + "investigation.created": "Started investigation", + "investigation.restarted": "Restarted investigation", + "investigation.status_changed": "Changed investigation status", + "onboarding_reward.claimed": "Claimed onboarding reward", + "planetscale_integration.connect_started": "Started PlanetScale connection", + "planetscale_integration.organization_selected": "Selected PlanetScale organization", + "planetscale_integration.metrics_token_set": "Set PlanetScale metrics token", + "planetscale_integration.disconnected": "Disconnected PlanetScale", + "member.added": "Added member", + "member.role_changed": "Changed member role", + "member.removed": "Removed member", + "organization.deleted": "Deleted organization", + "organization.region_chosen": "Chose data region", + "scrape_target.created": "Created scrape target", + "scrape_target.updated": "Updated scrape target", + "scrape_target.deleted": "Deleted scrape target", + "session_replay.read": "Viewed session replay", + "support_channel.created": "Created support channel", + "support_channel.invite_sent": "Sent support channel invite", + "telemetry.read": "Read telemetry", + "telemetry.sql_executed": "Ran SQL query", + "warehouse_settings.updated": "Updated warehouse connection", + "warehouse_settings.deleted": "Disconnected warehouse", + "warehouse_settings.schema_applied": "Applied warehouse schema", + "widget_credential.minted": "Issued widget credential", + "widget_credential.revoked": "Revoked widget credential", +} as const satisfies Record + +/** Every declared `.`, in catalog order. */ +export const auditActions = (): ReadonlyArray => + Object.entries(AuditResources).flatMap(([resource, { verbs }]) => + verbs.map((verb) => `${resource}.${verb}`), + ) + +const isAuditAction = (action: string): action is AuditAction => Object.hasOwn(AuditActionLabels, action) +const isAuditResourceType = (type: string): type is AuditResourceType => + Object.hasOwn(AuditResourceLabels, type) + +/** Label for an action string read off a row; null for one this build does not know. */ +export const auditActionLabel = (action: string): string | null => + isAuditAction(action) ? AuditActionLabels[action] : null + +/** Label for a `resource_type` read off a row; null for one this build does not know. */ +export const auditResourceLabel = (type: string): string | null => + isAuditResourceType(type) ? AuditResourceLabels[type] : null diff --git a/packages/domain/src/setup-audit.test.ts b/packages/domain/src/setup-audit.test.ts index 497b320153..26643134fe 100644 --- a/packages/domain/src/setup-audit.test.ts +++ b/packages/domain/src/setup-audit.test.ts @@ -14,7 +14,7 @@ const HOUR = 60 * 60_000 /** A fully healthy org — every check passes. Each test perturbs exactly one field. */ const healthyConfig: ConfigAuditInputs = { - firstDataReceivedAt: NOW - 30 * 24 * HOUR, + telemetry: "present", alertRules: [ { id: "rule-1", @@ -314,12 +314,21 @@ describe("runSetupAudit — baseline", () => { it("short-circuits to no_data before any check runs when telemetry never arrived", () => { // Deliberately also broken elsewhere: the gate must win over every other finding. - const report = runSetupAudit(inputs({ firstDataReceivedAt: null, alertDestinations: [] })) + const report = runSetupAudit(inputs({ telemetry: "absent", alertDestinations: [] })) expect(report.dataStatus).toBe("no_data") expect(report.checks).toEqual([]) expect(report.summary).toEqual({ critical: 0, warn: 0, info: 0, pass: 0, skip: 0 }) }) + it("runs the config checks when whether telemetry ever arrived is unknown", () => { + const report = runSetupAudit( + inputsWith(undefined, { telemetry: "unavailable", alertDestinations: [] }), + ) + expect(report.dataStatus).toBe("ok") + expect(check(report, "CFG-ALERT-01").status).toBe("fail") + expect(check(report, "CFG-MAP-01").status).toBe("skip") + }) + it("skips warehouse checks instead of failing them when the warehouse is unavailable", () => { const report = runSetupAudit(inputsWith(undefined)) expect(report.warehouseAvailable).toBe(false) diff --git a/packages/domain/src/setup-audit.ts b/packages/domain/src/setup-audit.ts index f909c94408..ee705241a5 100644 --- a/packages/domain/src/setup-audit.ts +++ b/packages/domain/src/setup-audit.ts @@ -157,8 +157,11 @@ export interface AuditAttributeMapping { } export interface ConfigAuditInputs { - /** Null when the org has never received telemetry — gates the whole report. */ - readonly firstDataReceivedAt: number | null + /** + * Whether the org has ever received telemetry; gates the whole report. `absent` short-circuits to + * `no_data`; `unavailable` (the warehouse could not answer) runs the config checks only. + */ + readonly telemetry: "present" | "absent" | "unavailable" readonly alertRules: ReadonlyArray readonly alertRuleStates: ReadonlyArray readonly alertDestinations: ReadonlyArray @@ -1655,7 +1658,7 @@ const emptySummary: SetupAuditSummary = { critical: 0, warn: 0, info: 0, pass: 0 export function runSetupAudit(inputs: SetupAuditInputs): SetupAuditReport { const warehouseAvailable = inputs.warehouse !== undefined - if (inputs.config.firstDataReceivedAt === null) { + if (inputs.config.telemetry === "absent") { return { generatedAt: inputs.now, dataStatus: "no_data",