diff --git a/apps/local-ui/src/components/service-map-panel.tsx b/apps/local-ui/src/components/service-map-panel.tsx index 60a7fb08c2..df3c5e5650 100644 --- a/apps/local-ui/src/components/service-map-panel.tsx +++ b/apps/local-ui/src/components/service-map-panel.tsx @@ -9,7 +9,8 @@ import { Button } from "@maple/ui/components/ui/button" import { ScrollArea } from "@maple/ui/components/ui/scroll-area" import { formatErrorRate, formatLatency } from "@maple/ui/lib/format" import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" -import { formatRate, getHealthDotClass } from "@maple/ui/components/service-map/service-map-node" +import { getHealthDotClass } from "@maple/ui/components/service-map/service-map-node" +import { formatRate } from "@maple/ui/lib/format" import { getServiceColor } from "@maple/ui/lib/colors" import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { cn } from "@maple/ui/lib/utils" diff --git a/apps/web/perf/check-bundle-budget.ts b/apps/web/perf/check-bundle-budget.ts index 888164ebac..f5fcd7d0f7 100644 --- a/apps/web/perf/check-bundle-budget.ts +++ b/apps/web/perf/check-bundle-budget.ts @@ -120,8 +120,11 @@ const forbidden = [...staticGraph].filter((key) => console.log( `Initial static JS: ${(gzipBytes / 1024).toFixed(1)} KB gzip across ${chunks.length} chunks (budget: ${budgetLabel})`, ) -for (const chunk of chunks.sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, 10)) { - console.log(` ${(chunk.gzipBytes / 1024).toFixed(1).padStart(7)} KB ${chunk.file}`) +// Over budget, list every chunk with its source key so the culprit is in the CI log. +const overBudget = gzipBytes > maxGzipBytes +for (const chunk of chunks.sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, overBudget ? undefined : 10)) { + const label = overBudget ? `${chunk.file} ${chunk.key}` : chunk.file + console.log(` ${(chunk.gzipBytes / 1024).toFixed(1).padStart(7)} KB ${label}`) } if (forbidden.length > 0) { diff --git a/apps/web/src/components/account/account-errors.ts b/apps/web/src/components/account/account-errors.ts index 418bd3d74c..4d9389d320 100644 --- a/apps/web/src/components/account/account-errors.ts +++ b/apps/web/src/components/account/account-errors.ts @@ -21,3 +21,23 @@ export function toastAccountError(err: unknown, fallback: string) { if (isReverificationCancelledError(err)) return toastManager.add({ title: accountErrorMessage(err, fallback), type: "error" }) } + +/** + * Settles a Clerk call into a boolean: toasts `success` (when given) or Clerk's own failure + * message, so call sites never wrap Clerk promises in try/catch. + */ +export function settleClerk( + promise: Promise, + options: { readonly success?: string; readonly error: string }, +): Promise { + return promise.then( + () => { + if (options.success !== undefined) toastManager.add({ title: options.success, type: "success" }) + return true + }, + (err: unknown) => { + toastAccountError(err, options.error) + return false + }, + ) +} diff --git a/apps/web/src/components/account/account-section-skeleton.tsx b/apps/web/src/components/account/account-section-skeleton.tsx index f75893fee5..68bd586892 100644 --- a/apps/web/src/components/account/account-section-skeleton.tsx +++ b/apps/web/src/components/account/account-section-skeleton.tsx @@ -1,21 +1,23 @@ import type { ReactNode } from "react" -import { Card, CardContent, CardHeader } from "@maple/ui/components/ui/card" +import { Panel } from "@maple/ui/components/ui/panel" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SettingsSections } from "@/components/settings/settings-section" + /** - * Placeholder a settings or account section renders while its Clerk resource loads. - * `children` replaces the default single-input body. + * Placeholder a settings or account section renders while its Clerk resource loads, in the + * `SettingsSection` shape. `children` replaces the default single-input body. */ export function AccountSectionSkeleton({ children }: { children?: ReactNode }) { return ( -
- - - + +
+
+ - - {children ?? } - -
+
+ {children ?? } +
+ ) } diff --git a/apps/web/src/components/account/active-sessions-section.tsx b/apps/web/src/components/account/active-sessions-section.tsx index 657014a7f4..dbc8a03339 100644 --- a/apps/web/src/components/account/active-sessions-section.tsx +++ b/apps/web/src/components/account/active-sessions-section.tsx @@ -1,18 +1,19 @@ import { useState } from "react" import { useReverification, useSession, useUser } from "@clerk/clerk-react" import type { SessionWithActivities } from "@/components/account/account-types" -import { toastManager } from "@maple/ui/components/ui/toast" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { ComputerIcon, MobileIcon } from "@/components/icons" import { RelativeTime } from "@/components/common/relative-time" import { useMountEffect } from "@/hooks/use-mount-effect" -import { accountErrorMessage, toastAccountError } from "@/components/account/account-errors" +import { accountErrorMessage, settleClerk } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { useAsyncAction } from "@/hooks/use-mutation-action" type LoadState = @@ -41,120 +42,111 @@ export function ActiveSessionsSection() { const revokeSession = useReverification((session: SessionWithActivities) => session.revoke()) - // `user.getSessions()` is imperative — Clerk exposes no hook for other-device sessions — so + // `user.getSessions()` is imperative (Clerk exposes no hook for other-device sessions), so // this is the sanctioned mount-effect escape hatch rather than a `useEffect`. useMountEffect(() => { void load() }) - async function load() { - if (!user) return - try { - const sessions = await user.getSessions() - setState({ status: "ready", sessions }) - } catch (err) { - setState({ - status: "error", - message: accountErrorMessage(err, "Failed to load your sessions"), - }) - } + function load() { + if (!user) return Promise.resolve() + return user.getSessions().then( + (sessions) => setState({ status: "ready", sessions }), + (err: unknown) => + setState({ + status: "error", + message: accountErrorMessage(err, "Failed to load your sessions"), + }), + ) } function handleRevoke() { if (!pendingRevoke) return + const session = pendingRevoke return withRevoking(async () => { - try { - await revokeSession(pendingRevoke) - setPendingRevoke(null) - toastManager.add({ title: "Session signed out", type: "success" }) - // `revoke()` returns only the one session, so refetch rather than patching state and - // risking a list that disagrees with Clerk about what is still active. - await load() - } catch (err) { - toastAccountError(err, "Failed to sign out that session") - } + const ok = await settleClerk(revokeSession(session), { + success: "Session signed out", + error: "Failed to sign out that session", + }) + if (!ok) return + setPendingRevoke(null) + // `revoke()` returns only the one session, so refetch rather than patching state and + // risking a list that disagrees with Clerk about what is still active. + await load() }) } return ( -
- - - Active Sessions - - Devices currently signed in to your account. Sign out any you do not recognise. - - - - {!isLoaded || state.status === "loading" ? ( - - ) : state.status === "error" ? ( -
-

{state.message}

- -
- ) : ( - - - - Device - Location - Last active - - - - - {state.sessions.map((session) => { - const isCurrent = session.id === currentSession?.id - const DeviceIcon = session.latestActivity.isMobile - ? MobileIcon - : ComputerIcon + + + {!isLoaded || state.status === "loading" ? ( + + ) : state.status === "error" ? ( +
+

{state.message}

+ +
+ ) : ( +
+ + + Device + Location + Last active + + + + + {state.sessions.map((session) => { + const isCurrent = session.id === currentSession?.id + const DeviceIcon = session.latestActivity.isMobile ? MobileIcon : ComputerIcon - return ( - - -
- - - {describeDevice(session)} - - {isCurrent && ( - This device - )} -
-
- - {describeLocation(session)} - - - - - - {/* Revoking the current session would sign the user out from + return ( + + +
+ + + {describeDevice(session)} + + {isCurrent && This device} +
+
+ + {describeLocation(session)} + + + + + + {/* Revoking the current session would sign the user out from inside the page they are using; use Log out for that. */} - {!isCurrent && ( - - )} - -
- ) - })} -
-
- )} -
-
+ {!isCurrent && ( + + )} + + + ) + })} + + + )} + void handleRevoke()} /> -
+ ) } diff --git a/apps/web/src/components/account/connected-accounts-section.tsx b/apps/web/src/components/account/connected-accounts-section.tsx index e1a050b716..ced43cf4f0 100644 --- a/apps/web/src/components/account/connected-accounts-section.tsx +++ b/apps/web/src/components/account/connected-accounts-section.tsx @@ -5,11 +5,11 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { SettingRow } from "@maple/ui/components/ui/setting-row" import { GithubIcon, GoogleIcon, type IconComponent } from "@/components/icons" -import { toastAccountError } from "@/components/account/account-errors" +import { settleClerk, toastAccountError } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" interface Provider { @@ -43,144 +43,134 @@ export function ConnectedAccountsSection() { * the browser has to visit so the provider can run its own consent screen. Coming back to * `/account?tab=connections` is what makes the new row appear. */ - async function handleConnect(provider: Provider) { - if (!user) return - setBusyProvider(provider.id) - try { - const account = await user.createExternalAccount({ - strategy: provider.strategy, - redirectUrl: "/account?tab=connections", - }) - const redirect = account.verification?.externalVerificationRedirectURL - if (!redirect) { - toastManager.add({ title: `${provider.label} did not return a sign-in URL`, type: "error" }) - setBusyProvider(null) - return - } - window.location.href = redirect.toString() - } catch (err) { - toastAccountError(err, `Failed to connect ${provider.label}`) + /** Sends the browser to the provider's consent screen, or clears the busy row when there is none. */ + function followRedirect(provider: Provider, redirect: URL | string | null | undefined) { + if (!redirect) { + toastManager.add({ title: `${provider.label} did not return a sign-in URL`, type: "error" }) setBusyProvider(null) + return } + window.location.href = redirect.toString() + } + + function handleConnect(provider: Provider) { + if (!user) return + setBusyProvider(provider.id) + return user + .createExternalAccount({ strategy: provider.strategy, redirectUrl: "/account?tab=connections" }) + .then( + (account) => followRedirect(provider, account.verification?.externalVerificationRedirectURL), + (err: unknown) => { + toastAccountError(err, `Failed to connect ${provider.label}`) + setBusyProvider(null) + }, + ) } /** An account that came back unverified needs the provider round trip run again. */ - async function handleRetry(provider: Provider, account: ExternalAccount) { + function handleRetry(provider: Provider, account: ExternalAccount) { setBusyProvider(provider.id) - try { - const reauthorized = await account.reauthorize({ redirectUrl: "/account?tab=connections" }) - const redirect = reauthorized.verification?.externalVerificationRedirectURL - if (!redirect) { - toastManager.add({ title: `${provider.label} did not return a sign-in URL`, type: "error" }) + return account.reauthorize({ redirectUrl: "/account?tab=connections" }).then( + (reauthorized) => + followRedirect(provider, reauthorized.verification?.externalVerificationRedirectURL), + (err: unknown) => { + toastAccountError(err, `Failed to reconnect ${provider.label}`) setBusyProvider(null) - return - } - window.location.href = redirect.toString() - } catch (err) { - toastAccountError(err, `Failed to reconnect ${provider.label}`) - setBusyProvider(null) - } + }, + ) } async function handleDisconnect() { if (!pendingRemoval) return - setBusyProvider(pendingRemoval.account.provider) - try { - await destroyExternalAccount(pendingRemoval.account) - setPendingRemoval(null) - toastManager.add({ title: `${pendingRemoval.label} disconnected`, type: "success" }) - } catch (err) { - toastAccountError(err, `Failed to disconnect ${pendingRemoval.label}`) - } finally { - setBusyProvider(null) - } + const { account, label } = pendingRemoval + setBusyProvider(account.provider) + const ok = await settleClerk(destroyExternalAccount(account), { + success: `${label} disconnected`, + error: `Failed to disconnect ${label}`, + }) + if (ok) setPendingRemoval(null) + setBusyProvider(null) } return ( -
- - - Connected Accounts - - Sign in to Maple with a provider you already use. Disconnecting one removes it as a - sign-in method. - - - -
- {PROVIDERS.map((provider) => { - const account = user.externalAccounts.find((a) => a.provider === provider.id) - const isVerified = account?.verification?.status === "verified" - const isBusy = busyProvider === provider.id + + +
+ {PROVIDERS.map((provider) => { + const account = user.externalAccounts.find((a) => a.provider === provider.id) + const isVerified = account?.verification?.status === "verified" + const isBusy = busyProvider === provider.id - return ( - - -
- } - label={ - - {provider.label} - {account && !isVerified && ( - - Incomplete - - )} - - } - description={ - - {account - ? account.emailAddress || account.username || "Connected" - : `Connect your ${provider.label} account`} - - } - control={ - account ? ( - <> - {!isVerified && ( - - )} + return ( + + +
+ } + label={ + + {provider.label} + {account && !isVerified && ( + + Incomplete + + )} + + } + description={ + + {account + ? account.emailAddress || account.username || "Connected" + : `Connect your ${provider.label} account`} + + } + control={ + account ? ( + <> + {!isVerified && ( - - ) : ( + )} - ) - } - /> - ) - })} -
- - + + ) : ( + + ) + } + /> + ) + })} + + void handleDisconnect()} /> - + ) } diff --git a/apps/web/src/components/account/email-addresses-section.browser.test.tsx b/apps/web/src/components/account/email-addresses-section.browser.test.tsx index 7c74af534d..9bf31f4110 100644 --- a/apps/web/src/components/account/email-addresses-section.browser.test.tsx +++ b/apps/web/src/components/account/email-addresses-section.browser.test.tsx @@ -45,7 +45,7 @@ describe("EmailAddressesSection", () => { await mount(user) // Only the non-primary verified row offers the action, so this is unambiguous. - fireEvent.click(screen.getAllByRole("button", { name: "" }).at(-1) ?? document.body) + fireEvent.click(screen.getAllByRole("button", { name: /^Actions for/ }).at(-1) ?? document.body) fireEvent.click(await screen.findByText("Set as primary")) // Clerk has no `emailAddress.setPrimary()`; the id goes up to `user.update`. @@ -61,7 +61,7 @@ describe("EmailAddressesSection", () => { update: vi.fn(), }) - fireEvent.click(screen.getAllByRole("button", { name: "" })[0] ?? document.body) + fireEvent.click(screen.getAllByRole("button", { name: /^Actions for/ })[0] ?? document.body) const remove = await screen.findByText("Remove") // Clerk requires one verified address; an enabled control here would only ever 4xx. expect(remove.closest("[data-disabled], [aria-disabled='true']")).not.toBeNull() diff --git a/apps/web/src/components/account/email-addresses-section.tsx b/apps/web/src/components/account/email-addresses-section.tsx index 323debbc1d..b82e29a8ff 100644 --- a/apps/web/src/components/account/email-addresses-section.tsx +++ b/apps/web/src/components/account/email-addresses-section.tsx @@ -1,47 +1,27 @@ import { useState } from "react" import { useReverification, useUser } from "@clerk/clerk-react" import type { EmailAddress } from "@/components/account/account-types" -import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@maple/ui/components/ui/card" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Input } from "@maple/ui/components/ui/input" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogTitle, -} from "@maple/ui/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" -import { CircleCheckIcon, DotsVerticalIcon, EnvelopeIcon, PlusIcon, TrashIcon } from "@/components/icons" -import { toastAccountError } from "@/components/account/account-errors" +import { CircleCheckIcon, EnvelopeIcon, PlusIcon, TrashIcon } from "@/components/icons" +import { settleClerk, toastAccountError } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { CodeField } from "@/components/account/code-field" import { useAsyncAction } from "@/hooks/use-mutation-action" /** * The add flow is a two-step dialog: create the address, then verify the emailed code. The - * pending `EmailAddress` is carried in state between the steps because that resource — - * not the user — is what `attemptVerification` is called on. + * pending `EmailAddress` is carried in state between the steps because that resource + * (not the user) is what `attemptVerification` is called on. */ type AddState = | { step: "closed" } @@ -67,265 +47,194 @@ export function EmailAddressesSection() { if (add.step !== "email") return const email = add.value.trim() if (email.length === 0) return - return withBusy(async () => { - try { - const address = await createEmailAddress(email) - if (!address) return - await address.prepareVerification({ strategy: "email_code" }) - setAdd({ step: "code", address, code: "" }) - } catch (err) { - toastAccountError(err, "Failed to add email address") - } - }) + return withBusy(() => + createEmailAddress(email) + .then(async (address) => { + if (!address) return + await address.prepareVerification({ strategy: "email_code" }) + setAdd({ step: "code", address, code: "" }) + }) + .catch((err: unknown) => toastAccountError(err, "Failed to add email address")), + ) } function handleVerify(code: string) { if (add.step !== "code" || code.length < 6) return + const address = add.address return withBusy(async () => { - try { - await add.address.attemptVerification({ code }) - setAdd({ step: "closed" }) - toastManager.add({ title: "Email address verified", type: "success" }) - } catch (err) { - toastAccountError(err, "That code did not match") - } + const ok = await settleClerk(address.attemptVerification({ code }), { + success: "Email address verified", + error: "That code did not match", + }) + if (ok) setAdd({ step: "closed" }) }) } /** Re-open the code step for an address added earlier but never verified. */ function handleResendVerification(address: EmailAddress) { return withBusy(async () => { - try { - await address.prepareVerification({ strategy: "email_code" }) - setAdd({ step: "code", address, code: "" }) - } catch (err) { - toastAccountError(err, "Failed to send a verification code") - } + const ok = await settleClerk(address.prepareVerification({ strategy: "email_code" }), { + error: "Failed to send a verification code", + }) + if (ok) setAdd({ step: "code", address, code: "" }) }) } /** - * Primary is a property of the *user*, not of the address — Clerk has no + * Primary is a property of the *user*, not of the address: Clerk has no * `emailAddress.setPrimary()`. */ function handleSetPrimary(address: EmailAddress) { if (!user) return return withBusy(async () => { - try { - await user.update({ primaryEmailAddressId: address.id }) - toastManager.add({ - title: `${address.emailAddress} is now your primary email`, - type: "success", - }) - } catch (err) { - toastAccountError(err, "Failed to change your primary email") - } + await settleClerk(user.update({ primaryEmailAddressId: address.id }), { + success: `${address.emailAddress} is now your primary email`, + error: "Failed to change your primary email", + }) }) } function handleRemove() { if (!pendingRemoval) return + const address = pendingRemoval return withBusy(async () => { - try { - await destroyEmailAddress(pendingRemoval) - setPendingRemoval(null) - toastManager.add({ title: "Email address removed", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to remove email address") - } + const ok = await settleClerk(destroyEmailAddress(address), { + success: "Email address removed", + error: "Failed to remove email address", + }) + if (ok) setPendingRemoval(null) }) } return ( -
- - - Email Addresses - - Your primary address receives sign-in codes, alerts and digests. Others can be used to - sign in. - - - - - - - - - - Email - Status - - - - - {emails.map((address) => { - const isPrimary = address.id === user.primaryEmailAddressId - const isVerified = address.verification.status === "verified" - // Clerk requires at least one verified address, so the last one is undeletable. - const canRemove = !isPrimary && !(isVerified && verifiedCount === 1) - - return ( - - - {address.emailAddress} - - -
- {isPrimary && Primary} - {isVerified ? ( - - - Verified - - ) : ( - - Unverified - - )} -
-
- - - - } + + setAdd({ step: "email", value: "" })}> + + Add email + + } + > +
+ + + Email + Status + + + + + {emails.map((address) => { + const isPrimary = address.id === user.primaryEmailAddressId + const isVerified = address.verification.status === "verified" + // Clerk requires at least one verified address, so the last one is undeletable. + const canRemove = !isPrimary && !(isVerified && verifiedCount === 1) + + return ( + + + {address.emailAddress} + + +
+ {isPrimary && Primary} + {isVerified ? ( + + + Verified + + ) : ( + + Unverified + + )} +
+
+ + + {!isVerified && ( + void handleResendVerification(address)} > - - - - {!isVerified && ( - - void handleResendVerification(address) - } - > - - Verify - - )} - {!isPrimary && isVerified && ( - void handleSetPrimary(address)} - > - - Set as primary - - )} - setPendingRemoval(address)} - > - - Remove - - - - -
- ) - })} -
-
-
-
- - + Verify + + )} + {!isPrimary && isVerified && ( + void handleSetPrimary(address)} + > + + Set as primary + + )} + setPendingRemoval(address)} + > + + Remove + + + + + ) + })} + + + + + { if (!open) setAdd({ step: "closed" }) }} + pending={isBusy} + {...(add.step === "code" + ? { + title: `Verify ${add.address.emailAddress}`, + description: "Enter the six-digit code we just emailed to that address.", + submitLabel: "Verify", + submitDisabled: add.code.length < 6, + onSubmit: () => void handleVerify(add.code), + } + : { + title: "Add an email address", + description: "We will send a six-digit code to confirm you own it.", + submitLabel: "Send code", + submitDisabled: add.step !== "email" || add.value.trim().length === 0, + onSubmit: () => void handleCreate(), + })} > - - {add.step === "code" ? ( - <> - - Verify {add.address.emailAddress} - - Enter the six-digit code we just emailed to that address. - - - - setAdd({ ...add, code })} - onValueComplete={(code) => void handleVerify(code)} - label="Verification code" - /> - - - - - - - ) : ( - <> - - Add an email address - - We will send a six-digit code to confirm you own it. - - - - - Email address - setAdd({ step: "email", value: e.target.value })} - disabled={isBusy} - /> - - - - - - - - )} - - + {add.step === "code" ? ( + setAdd({ ...add, code })} + onValueComplete={(code) => void handleVerify(code)} + label="Verification code" + /> + ) : ( + + Email address + setAdd({ step: "email", value: e.target.value })} + disabled={isBusy} + /> + + )} + void handleRemove()} /> -
+ ) } diff --git a/apps/web/src/components/account/passkeys-section.tsx b/apps/web/src/components/account/passkeys-section.tsx index a86bc0968a..286f27fda4 100644 --- a/apps/web/src/components/account/passkeys-section.tsx +++ b/apps/web/src/components/account/passkeys-section.tsx @@ -1,39 +1,21 @@ import { useState } from "react" import { useReverification, useUser } from "@clerk/clerk-react" import type { Passkey } from "@/components/account/account-types" -import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { Button } from "@maple/ui/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@maple/ui/components/ui/card" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Input } from "@maple/ui/components/ui/input" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogTitle, -} from "@maple/ui/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" -import { DotsVerticalIcon, FingerprintIcon, PencilIcon, PlusIcon, TrashIcon } from "@/components/icons" -import { toastAccountError } from "@/components/account/account-errors" +import { FingerprintIcon, PencilIcon, PlusIcon, TrashIcon } from "@/components/icons" +import { settleClerk } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatDateInTimezone } from "@/lib/timezone-format" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { useAsyncAction } from "@/hooks/use-mutation-action" @@ -43,14 +25,13 @@ import { useAsyncAction } from "@/hooks/use-mutation-action" */ const isWebAuthnSupported = () => typeof window !== "undefined" && "PublicKeyCredential" in window -const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }) - export function PasskeysSection() { const { user, isLoaded } = useUser() const [withBusy, isBusy] = useAsyncAction((task: () => Promise) => task()) const [renaming, setRenaming] = useState<{ passkey: Passkey; name: string } | null>(null) const [pendingRemoval, setPendingRemoval] = useState(null) + const { effectiveTimezone } = useTimezonePreference() const createPasskey = useReverification(() => user?.createPasskey()) const deletePasskey = useReverification((passkey: Passkey) => passkey.delete()) @@ -61,15 +42,10 @@ export function PasskeysSection() { const passkeys = user.passkeys function handleCreate() { + // `createPasskey()` takes no name: Clerk derives one from the authenticator, and it is + // renamed afterwards if the user wants something clearer. return withBusy(async () => { - try { - // `createPasskey()` takes no name — Clerk derives one from the authenticator, and it is - // renamed afterwards if the user wants something clearer. - await createPasskey() - toastManager.add({ title: "Passkey added", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to add a passkey") - } + await settleClerk(createPasskey(), { success: "Passkey added", error: "Failed to add a passkey" }) }) } @@ -77,185 +53,146 @@ export function PasskeysSection() { if (!renaming) return const name = renaming.name.trim() if (name.length === 0) return + const passkey = renaming.passkey return withBusy(async () => { - try { - await renaming.passkey.update({ name }) - setRenaming(null) - toastManager.add({ title: "Passkey renamed", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to rename passkey") - } + const ok = await settleClerk(passkey.update({ name }), { + success: "Passkey renamed", + error: "Failed to rename passkey", + }) + if (ok) setRenaming(null) }) } function handleRemove() { if (!pendingRemoval) return + const passkey = pendingRemoval return withBusy(async () => { - try { - await deletePasskey(pendingRemoval) - setPendingRemoval(null) - toastManager.add({ title: "Passkey removed", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to remove passkey") - } + const ok = await settleClerk(deletePasskey(passkey), { + success: "Passkey removed", + error: "Failed to remove passkey", + }) + if (ok) setPendingRemoval(null) }) } + const formatDate = (date: Date) => formatDateInTimezone(date, { timeZone: effectiveTimezone }) + return ( -
- - - Passkeys - - Sign in with Touch ID, Windows Hello, a phone or a hardware key instead of a password. - - - + } + > + {!supported ? ( +

+ This browser does not support passkeys. Open Maple in a recent version of Chrome, + Safari, Edge or Firefox over HTTPS to register one. +

+ ) : passkeys.length === 0 ? ( + + + + + + No passkeys + Add one to sign in without typing a password. + + -
-
- - {!supported ? ( -

- This browser does not support passkeys. Open Maple in a recent version of Chrome, - Safari, Edge or Firefox over HTTPS to register one. -

- ) : passkeys.length === 0 ? ( - - - - - - No passkeys - - Add one to sign in without typing a password. - - - - - ) : ( - - - - Name - Added - Last used - + + ) : ( +
+ + + Name + Added + Last used + + + + + {passkeys.map((passkey) => ( + + +
+ + + {passkey.name ?? "Unnamed passkey"} + +
+
+ + {formatDate(passkey.createdAt)} + + + {passkey.lastUsedAt ? formatDate(passkey.lastUsedAt) : "Never"} + + + + + setRenaming({ + passkey, + name: passkey.name ?? "", + }) + } + > + + Rename + + setPendingRemoval(passkey)} + > + + Remove + + +
- - - {passkeys.map((passkey) => ( - - -
- - - {passkey.name ?? "Unnamed passkey"} - -
-
- - {dateFormat.format(passkey.createdAt)} - - - {passkey.lastUsedAt - ? dateFormat.format(passkey.lastUsedAt) - : "Never"} - - - - - } - > - - - - - setRenaming({ - passkey, - name: passkey.name ?? "", - }) - } - > - - Rename - - setPendingRemoval(passkey)} - > - - Remove - - - - -
- ))} -
-
- )} -
-
+ ))} + + + )} + - { if (!open) setRenaming(null) }} + title="Rename passkey" + description='Give it a name you will recognise, like "MacBook Touch ID".' + onSubmit={() => void handleRename()} + submitLabel="Save" + pending={isBusy} + submitDisabled={(renaming?.name.trim().length ?? 0) === 0} > - - - Rename passkey - - Give it a name you will recognise, like "MacBook Touch ID". - - - - - Name - - setRenaming(renaming ? { ...renaming, name: e.target.value } : null) - } - disabled={isBusy} - autoComplete="off" - /> - - - - - - - - + + Name + setRenaming(renaming ? { ...renaming, name: e.target.value } : null)} + disabled={isBusy} + autoComplete="off" + /> + + void handleRemove()} /> -
+ ) } diff --git a/apps/web/src/components/account/password-section.tsx b/apps/web/src/components/account/password-section.tsx index 17eeed0298..34b614c136 100644 --- a/apps/web/src/components/account/password-section.tsx +++ b/apps/web/src/components/account/password-section.tsx @@ -1,14 +1,13 @@ import { useState } from "react" import { useReverification, useUser } from "@clerk/clerk-react" -import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldError, FieldLabel } from "@maple/ui/components/ui/field" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { Input } from "@maple/ui/components/ui/input" import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" -import { toastAccountError } from "@/components/account/account-errors" +import { settleClerk } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import type { UpdatePasswordParams } from "@/components/account/account-types" import { useAsyncAction } from "@/hooks/use-mutation-action" @@ -40,100 +39,96 @@ export function PasswordSection() { function handleSubmit() { if (!user || !canSubmit) return return withSaving(async () => { - try { - await updatePassword({ + const ok = await settleClerk( + updatePassword({ newPassword, // Clerk rejects `currentPassword` on an account that has none yet. ...(hasPassword ? { currentPassword } : undefined), signOutOfOtherSessions, - }) - setCurrentPassword("") - setNewPassword("") - setConfirmPassword("") - toastManager.add({ - title: hasPassword ? "Password changed" : "Password set", - type: "success", - }) - } catch (err) { - toastAccountError(err, "Failed to update your password") - } + }), + { + success: hasPassword ? "Password changed" : "Password set", + error: "Failed to update your password", + }, + ) + if (!ok) return + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") }) } return ( -
- - - {hasPassword ? "Change Password" : "Set a Password"} - - {hasPassword - ? "Use at least 8 characters. Changing your password can sign you out of your other devices." - : "You signed up with a social account or an email code. Adding a password gives you a second way in."} - - - -
- {hasPassword && ( - - Current password - setCurrentPassword(e.target.value)} - disabled={isSaving} - /> - - )} + + +
+ {hasPassword && ( - New password + Current password setNewPassword(e.target.value)} + autoComplete="current-password" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} disabled={isSaving} - aria-invalid={tooShort} /> - {tooShort && ( - Use at least {MIN_PASSWORD_LENGTH} characters. - )} - - Confirm new password - setConfirmPassword(e.target.value)} + )} + + New password + setNewPassword(e.target.value)} + disabled={isSaving} + aria-invalid={tooShort} + /> + {tooShort && ( + Use at least {MIN_PASSWORD_LENGTH} characters. + )} + + + Confirm new password + setConfirmPassword(e.target.value)} + disabled={isSaving} + aria-invalid={mismatch} + /> + {mismatch && Passwords do not match.} + + - {mismatch && Passwords do not match.} - - - } - /> -
- -
+ } + /> +
+
- - -
+
+ + ) } diff --git a/apps/web/src/components/account/profile-section.tsx b/apps/web/src/components/account/profile-section.tsx index 20e9b019c0..95d2ada133 100644 --- a/apps/web/src/components/account/profile-section.tsx +++ b/apps/web/src/components/account/profile-section.tsx @@ -1,15 +1,15 @@ import { useState } from "react" import { useClerk, useReverification, useUser } from "@clerk/clerk-react" -import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { Panel } from "@maple/ui/components/ui/panel" import { Input } from "@maple/ui/components/ui/input" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { ImageDropzone, TypeToConfirmField } from "@/components/common/image-dropzone" import { UserAvatar, userInitials } from "@/components/dashboard/user-avatar" -import { toastAccountError } from "@/components/account/account-errors" +import { settleClerk, toastAccountError } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { useAsyncAction } from "@/hooks/use-mutation-action" @@ -31,27 +31,27 @@ export function ProfileSection() { const [draft, setDraft] = useState(null) const [deleteOpen, setDeleteOpen] = useState(false) const [confirmText, setConfirmText] = useState("") - const [isDeleting, setIsDeleting] = useState(false) const deleteAccount = useReverification(() => user?.delete()) - const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task()) + const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task()) // `null` removes the picture. const [setAvatar, isSavingAvatar] = useAsyncAction(async (file: File | null) => { if (!user) return - try { - await user.setProfileImage({ file }) - toastManager.add({ - title: file ? "Profile picture updated" : "Profile picture removed", - type: "success", - }) - } catch (err) { - toastAccountError( - err, - file ? "Failed to update profile picture" : "Failed to remove profile picture", - ) - } + await settleClerk(user.setProfileImage({ file }), { + success: file ? "Profile picture updated" : "Profile picture removed", + error: file ? "Failed to update profile picture" : "Failed to remove profile picture", + }) + }) + + const [handleDelete, isDeleting] = useAsyncAction(() => { + if (!user) return Promise.resolve() + // The session is gone with the user, so sign out rather than leaving a dead session + // behind; `afterSignOutUrl` on ClerkProvider takes it to the sign-in page. + return deleteAccount() + .then(() => signOut()) + .catch((err: unknown) => toastAccountError(err, "Failed to delete account")) }) if (!isLoaded || !user) return @@ -79,131 +79,108 @@ export function ProfileSection() { function handleSaveName() { if (!user || !nameDirty) return return withSavingName(async () => { - try { - await user.update({ firstName: trimmedFirst, lastName: trimmedLast }) - setDraft(null) - toastManager.add({ title: "Profile updated", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to update profile") - } + const ok = await settleClerk(user.update({ firstName: trimmedFirst, lastName: trimmedLast }), { + success: "Profile updated", + error: "Failed to update profile", + }) + if (ok) setDraft(null) + return ok }) } - async function handleDelete() { - if (!user || !confirmMatches) return - setIsDeleting(true) - try { - await deleteAccount() - // The session is gone with the user, so sign out rather than leaving a dead session - // behind; `afterSignOutUrl` on ClerkProvider takes it to the sign-in page. - await signOut() - } catch (err) { - setIsDeleting(false) - toastAccountError(err, "Failed to delete account") - } - } - function handleDialogChange(open: boolean) { setDeleteOpen(open) if (!open) setConfirmText("") } return ( -
- - - Profile - - Your name and picture as they appear to other members of your organizations. - - - -
+ + +
+ + Profile picture + + } + onFile={(file) => { + if (!isSavingAvatar) void setAvatar(file) + }} + onRemove={ + user.hasImage + ? () => { + if (!isSavingAvatar) void setAvatar(null) + } + : undefined + } + uploading={isSavingAvatar} + targetLabel="Change profile picture" + changeLabel="Change picture" + /> + +
- Profile picture - - } - onFile={(file) => { - if (!isSavingAvatar) void setAvatar(file) - }} - onRemove={ - user.hasImage - ? () => { - if (!isSavingAvatar) void setAvatar(null) - } - : undefined - } - uploading={isSavingAvatar} - targetLabel="Change profile picture" - changeLabel="Change picture" + First name + updateDraft({ firstName: e.target.value })} + disabled={isSavingName} + autoComplete="given-name" + placeholder="First name" /> -
- - First name - updateDraft({ firstName: e.target.value })} - disabled={isSavingName} - autoComplete="given-name" - placeholder="First name" - /> - - - Last name - updateDraft({ lastName: e.target.value })} - disabled={isSavingName} - autoComplete="family-name" - placeholder="Last name" - /> - -
-
- -
+ + Last name + updateDraft({ lastName: e.target.value })} + disabled={isSavingName} + autoComplete="family-name" + placeholder="Last name" + /> + +
+
+
- - +
+
{user.deleteSelfEnabled && ( - - - Danger Zone - - Permanently delete your Maple account. You are removed from every organization you - belong to. Organizations you own, and the telemetry in them, are not deleted — - hand them over or delete them first. - - - -
-
- Delete {email || "your account"} and sign out everywhere. -
- -
-
-
+ + +

+ Delete {email || "your account"} and sign out everywhere. +

+ +
+
)} void handleDelete()} + onConfirm={() => { + if (confirmMatches) void handleDelete() + }} > -
+ ) } diff --git a/apps/web/src/components/account/two-factor-section.browser.test.tsx b/apps/web/src/components/account/two-factor-section.browser.test.tsx index 739d9619b8..4c648ccb26 100644 --- a/apps/web/src/components/account/two-factor-section.browser.test.tsx +++ b/apps/web/src/components/account/two-factor-section.browser.test.tsx @@ -104,7 +104,7 @@ describe("TwoFactorSection", () => { createTOTP: vi.fn(), verifyTOTP: vi.fn(), }) - expect(screen.queryByText("Backup Codes")).toBeNull() + expect(screen.queryByText("Backup codes")).toBeNull() cleanup() @@ -115,7 +115,7 @@ describe("TwoFactorSection", () => { createTOTP: vi.fn(), verifyTOTP: vi.fn(), }) - expect(screen.getByText("Backup Codes")).toBeTruthy() + expect(screen.getByText("Backup codes")).toBeTruthy() expect(screen.getByRole("button", { name: "Regenerate codes" })).toBeTruthy() }, 30_000) }) diff --git a/apps/web/src/components/account/two-factor-section.tsx b/apps/web/src/components/account/two-factor-section.tsx index 2600483202..50b477356d 100644 --- a/apps/web/src/components/account/two-factor-section.tsx +++ b/apps/web/src/components/account/two-factor-section.tsx @@ -4,7 +4,6 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { CopyableField } from "@maple/ui/components/ui/copyable-field" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { @@ -18,7 +17,8 @@ import { } from "@maple/ui/components/ui/dialog" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { CircleCheckIcon, ShieldIcon } from "@/components/icons" -import { toastAccountError } from "@/components/account/account-errors" +import { settleClerk, toastAccountError } from "@/components/account/account-errors" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { CodeField } from "@/components/account/code-field" import { QrCode } from "@/components/account/qr-code" @@ -53,134 +53,113 @@ export function TwoFactorSection() { function handleStartEnrollment() { return withBusy(async () => { - try { - const totp = await createTOTP() - if (!totp?.uri || !totp.secret) { - toastManager.add({ title: "Clerk did not return a setup key", type: "error" }) - return - } - setEnroll({ step: "scan", uri: totp.uri, secret: totp.secret }) - } catch (err) { + const totp = await createTOTP().catch((err: unknown) => { toastAccountError(err, "Failed to start two-factor setup") + return null + }) + if (totp === null) return + if (!totp?.uri || !totp.secret) { + toastManager.add({ title: "Clerk did not return a setup key", type: "error" }) + return } + setEnroll({ step: "scan", uri: totp.uri, secret: totp.secret }) }) } function handleVerify(code: string) { if (enroll.step !== "verify" || !user || code.length < 6) return return withBusy(async () => { - try { - const result = await user.verifyTOTP({ code }) - // Backup codes are minted alongside the first second factor and shown exactly once. - // If Clerk returns none (backup codes disabled instance-wide), close out instead of - // showing an empty list. - const codes = result.backupCodes ?? [] - if (codes.length > 0) { - setEnroll({ step: "codes", codes }) - } else { - setEnroll({ step: "closed" }) - } - toastManager.add({ title: "Two-factor authentication enabled", type: "success" }) - } catch (err) { - toastAccountError(err, "That code did not match") - } + await settleClerk( + user.verifyTOTP({ code }).then((result) => { + // Backup codes are minted alongside the first second factor and shown exactly once. + // If Clerk returns none (backup codes disabled instance-wide), close out instead of + // showing an empty list. + const codes = result.backupCodes ?? [] + setEnroll(codes.length > 0 ? { step: "codes", codes } : { step: "closed" }) + }), + { success: "Two-factor authentication enabled", error: "That code did not match" }, + ) }) } function handleRegenerateBackupCodes() { return withBusy(async () => { - try { - const result = await createBackupCode() - if (!result) return - setEnroll({ step: "codes", codes: result.codes }) - toastManager.add({ title: "New backup codes generated", type: "success" }) - } catch (err) { + const result = await createBackupCode().catch((err: unknown) => { toastAccountError(err, "Failed to generate backup codes") - } + return null + }) + if (!result) return + setEnroll({ step: "codes", codes: result.codes }) + toastManager.add({ title: "New backup codes generated", type: "success" }) }) } function handleDisable() { return withBusy(async () => { - try { - await disableTOTP() - setDisableOpen(false) - toastManager.add({ title: "Two-factor authentication disabled", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to disable two-factor authentication") - } + const ok = await settleClerk(disableTOTP(), { + success: "Two-factor authentication disabled", + error: "Failed to disable two-factor authentication", + }) + if (ok) setDisableOpen(false) }) } return ( -
- - -
- Authenticator App - {totpEnabled ? ( - - - Enabled - - ) : ( - - Not set up - - )} -
- - Require a six-digit code from an authenticator app — 1Password, Authy, Google - Authenticator — in addition to your password when you sign in. - -
- - {totpEnabled ? ( -
-

- Your authenticator app is registered for this account. -

- -
+ + + + Enabled + ) : ( - - )} -
-
- - {totpEnabled && ( - - -
- Backup Codes - {backupCodeEnabled && Active} -
- - Single-use codes for signing in when you cannot reach your authenticator. - Generating a new set invalidates the old one. - -
- + + Not set up + + ) + } + > + {totpEnabled ? ( +
+

+ Your authenticator app is registered for this account. +

- - +
+ ) : ( + + )} + + + {totpEnabled && ( + Active : undefined} + > + + )} Save your backup codes - Each code works once. This is the only time they are shown — store them + Each code works once. This is the only time they are shown, so store them somewhere safe before closing. @@ -315,6 +294,6 @@ export function TwoFactorSection() { pending={isBusy} onConfirm={() => void handleDisable()} /> -
+ ) } diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 4efb36410b..775d76deb5 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -14,18 +14,19 @@ import type { AiSessionSortDir, AiSessionSortKey } from "@maple/domain/http" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { badgeVariants } from "@maple/ui/components/ui/badge" -import { LoadingMoreRow } from "@maple/ui/components/ui/list-footer" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { TableSkeleton } from "@maple/ui/components/ui/table-skeleton" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" +import { EMPTY_VALUE, countLabel, formatNumber } from "@maple/ui/lib/format" import { formatCount } from "@maple/ui/components/filters/range-filter-section" import { cn } from "@maple/ui/lib/utils" import { FaceRobotIcon, GearIcon, PixelSparkleIcon, type IconComponent } from "@/components/icons" import { ServicePills } from "@/components/common/service-pills" import { SignalEmptyState } from "@/components/common/signal-empty-state" import { SortableHeader } from "@/components/common/sortable-header" +import { VirtualTableBody } from "@/components/common/virtual-table-body" import { useDetectedModels } from "@/hooks/use-detected-models" import { usePageScrollMargin } from "@/hooks/use-page-scroll-margin" import { useTimezonePreference } from "@/hooks/use-timezone-preference" @@ -92,8 +93,6 @@ function absoluteTs(startTime: string, timeZone: string): string { return Number.isNaN(parsed) ? startTime : formatTimestampInTimezone(parsed, { timeZone, withYear: true }) } -const plural = (count: number, noun: string) => `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}` - /** The row's buckets under the detail page's keys, so one palette serves both. */ function rowTokenBuckets(session: AgentSessionRow): Record { return { @@ -319,7 +318,7 @@ export function AgentSessionsList({ cell: ({ row }) => ( {formatSessionDuration(row.original.durationMs)} @@ -418,7 +417,6 @@ export function AgentSessionsList({ overscan: 10, scrollMargin, }) - const virtualItems = virtualizer.getVirtualItems() if (sessions.length === 0) { return ( @@ -434,9 +432,6 @@ export function AgentSessionsList({ ) } - const firstItem = virtualItems[0] - const lastItem = virtualItems[virtualItems.length - 1] - return (
@@ -447,7 +442,7 @@ export function AgentSessionsList({ {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( ))} - - {firstItem && ( - - - )} - {virtualItems.map((virtualRow) => { - const row = rows[virtualRow.index]! + { const session = row.original return ( navigate({ to: "/agent-sessions/$sessionId", @@ -510,27 +502,8 @@ export function AgentSessionsList({ ))} ) - })} - {lastItem && ( - - - )} - {loadingMore && ( - - - - )} - + }} + />
-
-
- -
@@ -538,8 +511,8 @@ export function AgentSessionsList({ {isCapped && (

- Showing the {sessions.length.toLocaleString()} most recent sessions — filter the list to - see older ones + Showing the {formatNumber(sessions.length)} most recent sessions — filter the list to see + older ones

)}
@@ -685,7 +658,7 @@ function WorkCount({ "inline-flex items-center gap-1 text-xs tabular-nums", count > 0 ? tone : "text-muted-foreground", )} - content={plural(count, noun)} + content={countLabel(count, noun)} > {formatCount(count)} @@ -734,7 +707,7 @@ function TokenBar({ session }: { session: AgentSessionRow }) { return ( {formatCount(total)} @@ -752,7 +725,7 @@ function TokenBar({ session }: { session: AgentSessionRow }) { className="flex flex-col gap-0.5 font-mono text-xs tabular-nums" content={
- {plural(total, "token")} + {countLabel(total, "token")} {sides.map((side) => (
@@ -777,7 +750,7 @@ function TokenBar({ session }: { session: AgentSessionRow }) { side.count > 0 ? side.tone : "text-muted-foreground/50", )} > - {side.count > 0 ? formatCount(side.count) : "—"} + {side.count > 0 ? formatCount(side.count) : EMPTY_VALUE} {side.label} @@ -808,7 +781,7 @@ function ErrorChips({ session }: { session: AgentSessionRow }) { icon={FaceRobotIcon} count={session.turnErrorCount} noun="turn" - hint={`${plural(session.turnErrorCount, "failed turn")} — a model call or agent turn errored`} + hint={`${countLabel(session.turnErrorCount, "failed turn")} — a model call or agent turn errored`} className="border-severity-error/30 bg-severity-error/10 text-severity-error" /> )} @@ -817,7 +790,7 @@ function ErrorChips({ session }: { session: AgentSessionRow }) { icon={GearIcon} count={session.toolErrorCount} noun="tool" - hint={`${plural(session.toolErrorCount, "failed tool call")} — the agent may have recovered`} + hint={`${countLabel(session.toolErrorCount, "failed tool call")} — the agent may have recovered`} className="border-severity-warn/40 bg-severity-warn/10 text-severity-warn" /> )} @@ -825,7 +798,7 @@ function ErrorChips({ session }: { session: AgentSessionRow }) { )} diff --git a/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx b/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx index 028bb95be8..0fc5cc6b26 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-toolbar.tsx @@ -68,7 +68,7 @@ export function AgentSessionsToolbar({ With errors diff --git a/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx b/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx index c8c79011c5..346af2b27b 100644 --- a/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx @@ -79,7 +79,7 @@ export function ViewSwitch({ ) } -const SEGMENT_CLASS = "font-mono text-[10px] uppercase tracking-[0.08em] sm:text-[10px]" +const SEGMENT_CLASS = "font-mono text-3xs uppercase tracking-[0.08em] sm:text-3xs" // The switch sits inside clickable rows; a segment click must not toggle the row. const stopPropagation = (event: { stopPropagation: () => void }) => event.stopPropagation() diff --git a/apps/web/src/components/agent-sessions/session-detail/session-checks.tsx b/apps/web/src/components/agent-sessions/session-detail/session-checks.tsx index 05c4e69066..5411bddde2 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-checks.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-checks.tsx @@ -11,11 +11,12 @@ import type { } from "@maple/agent-sessions" import { ArrowRightIcon, CheckIcon } from "@/components/icons" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Button } from "@maple/ui/components/ui/button" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" + +import { SectionHeading } from "@/components/common/section-heading" import { TONE_TEXT, type Tone } from "@maple/ui/lib/tone" /** One tone per status, for the dot beside a name and the text of a label. */ @@ -74,9 +75,7 @@ export function SessionChecks({
- - Needs attention - + {clean ? (

diff --git a/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx b/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx index 6667fcf461..a2e86345df 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx @@ -17,7 +17,7 @@ import "@xyflow/react/dist/style.css" import type { AiSessionSpan } from "@maple/domain/http" import { CircleXmarkIcon, MaximizeIcon, MinusIcon, PlusIcon } from "@/components/icons" import { EmptyMessage } from "@maple/ui/components/ui/empty" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { formatDuration, formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -396,37 +396,20 @@ function FlowControls({ zoom }: { zoom: number }) { return (

- - + {/* A deliberate reset rather than `fitView`: fit clamps against the 0.5 floor on any real session and lands somewhere unpredictable, while "top of the session at 1:1" is the one place the reader can always name. (`fitView` also queues behind the flow's own render when called from outside it, and this button lives in the floor.) */} - +
) } @@ -493,14 +476,14 @@ const StepNode = memo(function StepNode({ data }: NodeProps & { data: StepData } {shortTarget(node.title)} {node.count > 1 && ( - + ×{node.count} )} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-header.tsx b/apps/web/src/components/agent-sessions/session-detail/session-header.tsx index 0d637b0924..376c8968f5 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-header.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-header.tsx @@ -1,7 +1,5 @@ -import type { ReactNode } from "react" - import { Badge } from "@maple/ui/components/ui/badge" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { shortId } from "@maple/ui/lib/ids" import { traceSessionTraceId } from "@maple/domain/gen-ai" @@ -48,16 +46,19 @@ export function SessionHeader({ sessionId, summary }: { sessionId: string; summa {summary.failed && Failed}
-
- + + - + {summary.serviceNames.length > 0 && ( - + {firstPlusRest(summary.serviceNames)} - + )} - + - -
+ +
) } @@ -101,27 +102,6 @@ export function sessionIdentity(summary: Pick - - {label} - -
{children}
-
- ) -} - /** "claude-sonnet-5 +1": the first name, the rest as a count — the full list * goes in the `title`. */ function firstPlusRest(names: readonly string[]): string { diff --git a/apps/web/src/components/agent-sessions/session-detail/session-load-indicator.tsx b/apps/web/src/components/agent-sessions/session-detail/session-load-indicator.tsx index 6c23e64b21..280d9032a8 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-load-indicator.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-load-indicator.tsx @@ -2,6 +2,7 @@ import type { GetAiSessionSummaryResponse } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" import { Spinner } from "@maple/ui/components/ui/spinner" import { cn } from "@maple/ui/lib/utils" +import { Meter } from "@maple/ui/components/ui/meter" import type { SessionLoadProgress } from "@/hooks/use-session-spans" @@ -26,7 +27,6 @@ export function SessionLoadIndicator({ className?: string }) { const { phase } = progress - const format = (value: number) => value.toLocaleString("en-US") if (phase === "failed") { return ( @@ -58,19 +58,12 @@ export function SessionLoadIndicator({
- Loading {format(loaded)} - {total !== undefined && ` of ${format(total)}`} {noun} + Loading {loaded.toLocaleString("en-US")} + {total !== undefined && ` of ${total.toLocaleString("en-US")}`} {noun}
-
-
-
+ {/* No total yet: a fixed third, so the bar still says "in progress". */} +
) } diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index ea3ca5c0ce..9f7ef56ad9 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -6,11 +6,15 @@ import type { GetAiSessionSummaryResponse } from "@maple/domain/http" import { ArrowRightIcon } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Meter, SegmentedBar } from "@maple/ui/components/ui/meter" import { Separator } from "@maple/ui/components/ui/separator" import { formatNumber, formatPercent } from "@maple/ui/lib/format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { cn } from "@maple/ui/lib/utils" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" + +import { SectionHeading } from "@/components/common/section-heading" import { buildSessionChecks, @@ -266,7 +270,7 @@ function Rail({ summary }: { summary: SessionSummary }) { {/* Cost is only ever what an instrumentation stamped on a span — Maple prices nothing itself, and saying so is the difference between a figure and a bill. */} -

+

{summary.cost === undefined ? "No span reported a cost. Maple does not price tokens itself." : "As reported by the instrumentation. Not a bill."} @@ -304,7 +308,7 @@ function Rail({ summary }: { summary: SessionSummary }) {

))} {summary.tokenReporting === "session-level" && ( -

+

Reported once for the whole session

)} @@ -328,7 +332,7 @@ function Rail({ summary }: { summary: SessionSummary }) { )} - - Tools - + {summary.tools.length > 0 && (
- - - + + + {formatNumber(summary.tools.length)} + + + {formatNumber(calls)} + + + {formatToolDuration(toolMs)} + + {failed > 0 && ( {failed} - failed + failed )}
@@ -437,17 +447,6 @@ function ToolLedgerHeader({ summary }: { summary: SessionSummary }) { ) } -function LedgerStat({ label, value, tone }: { label: string; value: string; tone?: string }) { - return ( - - {label} - - {value} - - - ) -} - /** The ledger's lanes, shared by the column header and every row: a column is * read downwards, so the widths are fixed rather than content-driven. */ const LEDGER_NAME = "w-44 min-w-0 shrink-0" @@ -470,7 +469,7 @@ function ToolLedgerColumns({ axis }: { axis: SessionAxis }) { // The last tick anchors to the axis end rather than centring on // it: a centred one would hang off the column. className={cn( - "absolute top-0 whitespace-nowrap font-mono font-normal text-[10px] normal-case tracking-normal", + "absolute top-0 whitespace-nowrap font-mono font-normal text-3xs normal-case tracking-normal", tick.fraction < 0.92 && "-translate-x-1/2", )} style={ @@ -525,17 +524,14 @@ function ToolLedgerRow({ )} > - + {tool.name} - + ) : ( - + {tool.name} - + )} {tool.calls} -
- - {title} - - {aside} -
+ {children} ) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx index f2fa3346b8..3f9cf09332 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx @@ -8,8 +8,11 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Button } from "@maple/ui/components/ui/button" import { Spinner } from "@maple/ui/components/ui/spinner" import { CopyButton } from "@maple/ui/components/ui/copy-button" -import { formatBytes, formatDuration } from "@maple/ui/lib/format" +import { countLabel, formatBytes, formatDuration } from "@maple/ui/lib/format" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { cn } from "@maple/ui/lib/utils" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" import { Badge } from "@maple/ui/components/ui/badge" import { TONE_SOFT } from "@maple/ui/lib/tone" @@ -68,11 +71,11 @@ const BODY = "min-w-0 max-w-[900px] grow pl-3" /** Who is speaking — a name, set as one, not a category label. */ const SPEAKER = "shrink-0 font-semibold text-[13px] text-foreground" /** The clock beside a speaker's name, where a chat puts it. */ -const CLOCK = "shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums" +const CLOCK = "shrink-0 font-mono text-2xs text-muted-foreground tabular-nums" /** Secondary facts and controls: there for the reader who points at the row. */ const ON_HOVER = "opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100" const LABEL = "shrink-0 font-medium text-xs" -const META = "min-w-0 truncate font-mono text-[11px] text-muted-foreground" +const META = "min-w-0 truncate font-mono text-2xs text-muted-foreground" /** One lane of nesting; the hairline is what makes a lane's extent visible. */ const INDENT = "flex w-6 shrink-0 justify-center" /** Past this the prose column is narrower than the gutters framing it, and a @@ -515,18 +518,22 @@ function UserBlock({
{row.earlierCount > 0 && ( - + + } + onClick={() => onToggleRow(historyKey)} + aria-expanded={showHistory} + className={cn( + "shrink-0 cursor-pointer text-2xs text-muted-foreground hover:text-foreground", + !showHistory && ON_HOVER, + )} + > + {showHistory ? "hide history" : `+${row.earlierCount} re-sent`} + + + {`${countLabel(row.earlierCount, "earlier message")} re-sent with this one, shown once`} + + )} +

The whole history this call re-sent, as captured.

{row.history.map((message, index) => { @@ -711,7 +718,7 @@ function AssistantBlock({ {row.failed && } {clockOf(row.startMs, timeZone)} {row.span.genAi.errorType !== undefined && ( - + {row.span.genAi.errorType} )} @@ -904,7 +911,7 @@ function ToolBlock({
{formatDuration(row.span.durationMs)} )} {row.failed && row.span.genAi.errorType !== undefined && ( - + {row.span.genAi.errorType} )} @@ -1002,7 +1009,7 @@ function PayloadSection({
{label} - + {[ meta, formatBytes(payload.byteLength), @@ -1048,7 +1055,7 @@ function PayloadSection({ /> )} {payload.truncatedByEmitter && ( -

+

Cut off here by the instrumentation, not by Maple — the tail was never recorded.

)} @@ -1082,9 +1089,7 @@ function LaneOpen({
{/* An agent name is emitter input: capped and truncating so a long one cannot widen the page. */} - - {row.agentName} - + {row.agentName} {clockOf(row.startMs, timeZone)} {row.laneKind === "subagent" @@ -1174,9 +1179,7 @@ function ParallelRule({ label, range }: { label: string; range: string }) { {/* The label joins agent names, which are emitter input: it truncates rather than widening the page. */} - - {label} - + {label} {range}
@@ -1275,16 +1278,13 @@ function StructureRow({ aria-pressed={selected} className={cn( "-mx-1.5 flex min-w-0 grow cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-muted/40", - selected && "bg-primary/6", + rowSelectedClass(selected), )} > - + {row.label} - + {meta.shown} {meta.onHover} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx index f9a17ab399..f53a6396a3 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx @@ -8,9 +8,10 @@ import { ChevronDownIcon, ChevronRightIcon, CircleXmarkIcon } from "@/components import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { SegmentedBar } from "@maple/ui/components/ui/meter" -import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatDuration, formatNumber } from "@maple/ui/lib/format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { cn } from "@maple/ui/lib/utils" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" import { Badge } from "@maple/ui/components/ui/badge" import { TONE_SOFT } from "@maple/ui/lib/tone" @@ -276,7 +277,7 @@ export function SessionWaterfall({ {axis.removedGapCount > 0 && ( -

+

Axis shows active time. {formatSessionDuration(axis.removedMs)} of idle removed across{" "} {axis.removedGapCount} gap{axis.removedGapCount === 1 ? "" : "s"}.

@@ -526,7 +527,7 @@ function TurnHeader({ )}
- {turn.agentName ?? "—"} + {turn.agentName ?? EMPTY_VALUE} @@ -614,8 +615,8 @@ function SpanRow({ focused && "bg-accent/60", // Louder than the open row's mark on purpose: nothing is on screen // saying which span the reader crossed views for except this row. - revealed && "border-l-2 border-l-primary bg-primary/12", - selected && "border-l-2 border-l-primary bg-primary/5", + rowSelectedClass(selected || revealed), + revealed && "bg-primary/12", )} > {span.statusMessage} )} {errored && {span.genAi.errorType ?? "Error"}} - {isDelegation(span, spansById) && ( - - Subagent - - )} + {isDelegation(span, spansById) && Subagent} ) : ( - (target ?? "—") + (target ?? EMPTY_VALUE) )} @@ -722,7 +719,7 @@ function SpanBar({ function GapRow({ gap }: { gap: IdleGap }) { return ( -
+
idle {formatSessionDuration(gap.durationMs)}
@@ -859,10 +856,10 @@ function TokenCell({ tokens, errored }: { tokens: SessionTokenTotals | undefined ) } -/** The crit-toned uppercase pill marking a failed turn or span. */ +/** The crit-toned uppercase tag marking a failed turn or span. */ export function FailedBadge({ children = "Failed" }: { children?: ReactNode }) { return ( - + {children} ) diff --git a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx index e1feda3112..13849b3b17 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx @@ -6,6 +6,7 @@ import { SpanId, TraceId } from "@maple/domain" import type { AiSessionSpan } from "@maple/domain/http" import { ErrorSection } from "@maple/ui/components/error-section" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Button } from "@maple/ui/components/ui/button" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -13,6 +14,7 @@ import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-gro import { formatDuration, formatNumber } from "@maple/ui/lib/format" import { shortId } from "@maple/ui/lib/ids" import { cn } from "@maple/ui/lib/utils" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { Badge } from "@maple/ui/components/ui/badge" import { TONE_SOFT } from "@maple/ui/lib/tone" @@ -147,7 +149,7 @@ export function SpanExpansion({ function TabCount({ count }: { count: number }) { if (count === 0) return null - return {count} + return {count} } function CopySpanJsonButton({ span }: { span: AiSessionSpan }) { @@ -219,12 +221,12 @@ function MetaStrip({ span }: { span: AiSessionSpan }) { {pairs.map(([label, value]) => value === undefined ? null : ( - {label} + {label} {value} ), )} - + span {span.spanId} @@ -347,7 +349,7 @@ function MessageBlock({ message, span }: { message: SpanMessage; span: AiSession {/* Output messages are what this call produced, so the call's own response facts belong on them and on nothing else. */} {message.origin === "output" && ( - + {callMetaLine(span)} )} @@ -476,17 +478,17 @@ function ToolCallCard({ tool {call.name !== undefined && ( - + {call.name} - + )} {call.id !== undefined && ( - {call.id} - + )}
{call.description !== undefined && ( @@ -530,7 +532,7 @@ function PayloadCard({ {name} )} {meta !== undefined && ( - + {meta} )} @@ -744,18 +746,17 @@ function IdentityRows({ span }: { span: AiSessionSpan }) { ] return ( -
+ {rows.map(([label, value]) => value === undefined ? null : ( -
- {label} + - {value} + {value} -
+ ), )} -
+ ) } diff --git a/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx b/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx index 6e83b74073..b4129ab121 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-popover.tsx @@ -1,5 +1,6 @@ import type { AiSessionSpan } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Dialog, DialogPopup } from "@maple/ui/components/ui/dialog" import { formatDuration } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -125,13 +126,13 @@ function TitleRow({ {subtitle !== "" && {subtitle}}
{onOpenTraceView !== undefined && ( - )} - +
) diff --git a/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx b/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx index 1e54e4ab38..f84f50a50a 100644 --- a/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx @@ -39,7 +39,7 @@ export interface ToolIoPayload { const IN_CLAMP: ClampLines = 8 const OUT_CLAMP: ClampLines = 14 -const META = "font-mono text-[10px] text-muted-foreground" +const META = "font-mono text-3xs text-muted-foreground" /** Fixed, so the two markers and every body below them share one lane. */ const GUTTER = "flex w-8 shrink-0 flex-col items-center gap-1.5 pt-2.5" @@ -131,7 +131,7 @@ export function ToolIoSummary({ result: ToolIoPayload | undefined }) { return ( - + {args === undefined ? "not captured" : formatBytes(args.byteLength)} @@ -232,7 +232,7 @@ function IoHalf({ /> )} {payload.truncatedByEmitter && ( -

+

Cut off here by the instrumentation, not by Maple — the tail was never recorded.

)} diff --git a/apps/web/src/components/agent-sessions/tools/agent-sessions-tabs.tsx b/apps/web/src/components/agent-sessions/tools/agent-sessions-tabs.tsx index d744f1e66f..b4c00c1bb7 100644 --- a/apps/web/src/components/agent-sessions/tools/agent-sessions-tabs.tsx +++ b/apps/web/src/components/agent-sessions/tools/agent-sessions-tabs.tsx @@ -3,6 +3,8 @@ import { Link } from "@tanstack/react-router" import { formatToolCount } from "@/lib/agent-sessions/tool-analytics" import { cn } from "@maple/ui/lib/utils" +import { underlineTabClass } from "@/components/common/underline-link-tabs" + import { GearIcon, LayersIcon } from "@/components/icons" import { pickTimeRangeSearch, type TimeRangeSearch } from "@/components/time-range-picker/search" @@ -80,10 +82,9 @@ function TabLink({ search={search} aria-current={active ? "page" : undefined} className={cn( - "flex h-9 items-center gap-[7px] border-b-2 px-3 font-mono text-[12.5px] transition-colors first:pl-0.5", - active - ? "border-primary font-medium text-foreground [&_svg]:text-primary" - : "border-transparent text-muted-foreground hover:text-foreground [&_svg]:text-muted-foreground", + underlineTabClass(active), + "px-3 font-mono text-xs first:pl-0.5", + active ? "[&_svg]:text-primary" : "[&_svg]:text-muted-foreground", )} > {icon} @@ -91,7 +92,7 @@ function TabLink({ {count === undefined ? null : ( diff --git a/apps/web/src/components/agent-sessions/tools/agent-tools-view.browser.test.tsx b/apps/web/src/components/agent-sessions/tools/agent-tools-view.browser.test.tsx index 7f1ad3d3cf..791561d45c 100644 --- a/apps/web/src/components/agent-sessions/tools/agent-tools-view.browser.test.tsx +++ b/apps/web/src/components/agent-sessions/tools/agent-tools-view.browser.test.tsx @@ -140,7 +140,7 @@ describe("AgentToolsView", () => { // `model` is also the toolbar's select label — the chip is the second. expect(screen.getAllByText("model").length).toBeGreaterThan(1) - fireEvent.click(screen.getByTitle("Remove model claude-opus-5")) + fireEvent.click(screen.getByRole("button", { name: "Remove model claude-opus-5" })) expect(onSearchChange).toHaveBeenCalledWith({ model: undefined }) }) @@ -419,7 +419,7 @@ describe("ToolErrorModal", () => { const heading = screen.getByRole("heading") expect(heading.textContent).toContain("Invalid tool input: Missing key") expect(heading.textContent).toContain('["evidence"][*]["traceIds"]') - expect(screen.getByText(/^\d+% of failures$/)).toBeTruthy() + expect(screen.getByText(/^[\d.]+% of failures$/)).toBeTruthy() expect(screen.getByText("error.type tool_error")).toBeTruthy() }) diff --git a/apps/web/src/components/agent-sessions/tools/agent-tools-view.tsx b/apps/web/src/components/agent-sessions/tools/agent-tools-view.tsx index eeaa412f5c..d8318be6c7 100644 --- a/apps/web/src/components/agent-sessions/tools/agent-tools-view.tsx +++ b/apps/web/src/components/agent-sessions/tools/agent-tools-view.tsx @@ -127,8 +127,8 @@ export function AgentToolsView({ // Volume, whatever the chart is drawing: the spark is the row's shape // over the window, and a row's shape is how much of it there was. for (const [key, points] of byTool) sparkByTool.set(key, metricSpark(points, "calls", percentile)) - for (const [key, token] of toolSeriesColors(rankSeriesKeys(data.series))) { - colorByTool.set(key, `var(${token})`) + for (const [key, color] of toolSeriesColors(rankSeriesKeys(data.series))) { + colorByTool.set(key, color) } } return { sparkByTool, colorByTool } diff --git a/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx b/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx index 1bf6344a7c..6f26ad2fe9 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx @@ -4,15 +4,18 @@ import { refreshingClass } from "@maple/ui/lib/refreshing" import { Link } from "@tanstack/react-router" import { cn } from "@maple/ui/lib/utils" +import { Eyebrow, eyebrowVariants } from "@maple/ui/components/ui/eyebrow" import { Meter } from "@maple/ui/components/ui/meter" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" +import { PlotSparkline } from "@maple/ui/components/plot" import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { ERROR_RATE_FILL, ERROR_RATE_TEXT, errorRateLevel, formatErrorRate } from "@maple/ui/lib/error-rate" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { useTableSort, type SortDir } from "@/components/common/data-table" import { ArrowUpDownIcon, ChevronRightIcon } from "@/components/icons" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" import type { ToolDetailLinkSearch } from "@/lib/agent-sessions/tool-search" import { breakdownKeyLabel, @@ -39,7 +42,7 @@ import { * `w-0 flex-1` name lane contributes nothing to that measure, so the table is * exactly as wide as its numbers need and the name takes whatever is left. */ -export function Table({ children, className }: { children: ReactNode; className?: string }) { +export function ToolTable({ children, className }: { children: ReactNode; className?: string }) { return (
{children}
@@ -48,7 +51,7 @@ export function Table({ children, className }: { children: ReactNode; className? } /** A column head row: 30px, hairline above and below. The body scrolls under it. */ -export function TableHead({ children, className }: { children: ReactNode; className?: string }) { +export function ToolTableHead({ children, className }: { children: ReactNode; className?: string }) { return (
({ hidden?: string }) { const active = sortKey !== undefined && currentKey === sortKey - const text = "font-mono text-[10.5px] uppercase leading-3.5 tracking-[0.07em] transition-colors" + const text = cn(eyebrowVariants({ variant: "mono" }), "transition-colors") return (
{sortKey !== undefined ? ( @@ -96,7 +99,7 @@ export function Th({ className={cn( "inline-flex items-center gap-1", text, - active ? "text-foreground" : "text-muted-foreground/80 hover:text-foreground", + active ? "text-foreground" : "hover:text-foreground", )} > {label} @@ -110,7 +113,7 @@ export function Th({ /> ) : ( - {label} + {label} )}
) @@ -118,7 +121,7 @@ export function Th({ /** Rows scroll inside this once a list outgrows it, so one long table never * pushes the sections under it off the page. */ -export function TableBody({ +export function ToolTableBody({ maxHeight = 460, waiting, children, @@ -143,17 +146,20 @@ export function TableBody({ * only a selected row paints — so rows never shift sideways as a selection * moves. The same mark the metric strip uses. */ -export const ROW = - "relative flex h-[38px] w-full shrink-0 items-center gap-3 border-b border-border/50 px-2.5 text-left transition-colors last:border-0 before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:transition-colors focus-visible:outline-none" -export const ROW_SELECTED = "bg-muted/50 before:bg-primary" -export const ROW_IDLE = "before:bg-transparent hover:bg-muted/30 focus-visible:bg-muted/30" +const ROW = + "flex h-[38px] w-full shrink-0 items-center gap-3 border-b border-border/50 px-2.5 text-left transition-colors last:border-0 focus-visible:outline-none" -export function TableEmpty({ children }: { children: ReactNode }) { +/** A row's classes: the shared selection lane, with a hover tint only while unselected. */ +export function toolRowClass(selected: boolean): string { + return cn(ROW, rowSelectedClass(selected), !selected && "hover:bg-muted/30 focus-visible:bg-muted/30") +} + +export function ToolTableEmpty({ children }: { children: ReactNode }) { return
{children}
} /** The table's closing line: what is on screen, then the totals behind it. */ -export function TableFooter({ subject, detail }: { subject: string; detail: string }) { +export function ToolTableFooter({ subject, detail }: { subject: string; detail: string }) { return (
{subject} @@ -198,7 +204,7 @@ export function ShareCell({ max={max} minVisible={3} fillClassName={tone.bar} - className="hidden w-[60px] shrink-0 rounded-[2px] bg-muted @min-[560px]/panel:block" + className="hidden w-[60px] shrink-0 rounded-xs bg-muted @min-[560px]/panel:block" /> {label} @@ -207,30 +213,6 @@ export function ShareCell({ ) } -/** A 110×22 line: the row's call volume over the window, in the primary. */ -export function LineSpark({ values, className }: { values: ReadonlyArray; className?: string }) { - if (values.length < 2) return - const max = Math.max(...values, 0.0001) - const step = 110 / (values.length - 1) - const points = values - .map((value, index) => { - const safe = Number.isFinite(value) && value >= 0 ? value : 0 - return `${(index * step).toFixed(1)},${(20 - (safe / max) * 18).toFixed(1)}` - }) - .join(" ") - return ( - - - - ) -} - type ToolSortKey = "key" | "calls" | "p50" | "p90" | "p95" | "errorRate" | "errors" | "sessions" | "lastSeen" interface ToolsTableProps { @@ -276,7 +258,6 @@ export function ToolsTable({ failure, waiting, }: ToolsTableProps) { - const { effectiveTimezone } = useTimezonePreference() const prepared = useMemo(() => rows.map((row) => ({ ...row, errorRate: errorRate(row) })), [rows]) const maxErrorRate = useMemo( () => prepared.reduce((max, row) => Math.max(max, row.errorRate), 0), @@ -303,14 +284,14 @@ export function ToolsTable({ return (
- Tools - + Tools + {formatToolCount(rows.length)}
- - + + label="Tool" width="w-0 flex-1 min-w-0" @@ -397,15 +378,15 @@ export function ToolsTable({ hidden="hidden @min-[800px]/panel:flex" /> - + - + {failure !== undefined ? ( ) : loading ? ( ) : sorted.length === 0 ? ( - No tool calls in the selected window. + No tool calls in the selected window. ) : ( sorted.map((row) => { const color = colorFor(row.key) @@ -415,28 +396,32 @@ export function ToolsTable({ - {breakdownKeyLabel(row.key)} - + - - + {/* Display utilities go on a wrapper: PlotFrame merges its own `flex` over them. */} + + + + {formatToolCount(row.calls)} {duration( @@ -467,9 +452,12 @@ export function ToolsTable({ {formatToolCount(row.sessions)} - - {formatRelativeTimeOrDate(row.lastSeen, undefined, effectiveTimezone)} - + {row.key === "" ? null : } @@ -479,7 +467,7 @@ export function ToolsTable({ // The unattributed row has no name to route on: a tool call the // index could not name has no page of its own. return row.key === "" ? ( -
+
{cells}
) : ( @@ -488,19 +476,19 @@ export function ToolsTable({ to="/agent-sessions/tools/$toolName" params={{ toolName: row.key }} search={detailSearch} - className={cn(ROW, row.key === selected ? ROW_SELECTED : ROW_IDLE)} + className={toolRowClass(row.key === selected)} > {cells} ) }) )} - -
+ + {/* Totals of rows that have not arrived (or never will) are not totals. */} {failure === undefined && !loading ? ( - + ) : null}
) diff --git a/apps/web/src/components/agent-sessions/tools/tool-detail-charts.tsx b/apps/web/src/components/agent-sessions/tools/tool-detail-charts.tsx index ae5f275187..b16f12dfdf 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-detail-charts.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-detail-charts.tsx @@ -5,24 +5,23 @@ import { scaleLinear } from "@tanstack/charts-scales/linear" import { formatWarehouseDateTime } from "@maple/query-engine" import { - PlotFrame, - PlotTooltipBody, + CursorPlot, UNBOUNDED_FOCUS_DISTANCE, - createTooltipFocusStore, - cursorTooltip, DASHED_Y_GRID, + bucketDate, focusCrosshair, linearYDomain, + makeBucketAxis, niceLinearDomain, - usePlotChromeColors, - useResolvedSeriesColors, - type PlotTooltipSeries, + useCursorPlot, + type CursorPlotSeries, } from "@maple/ui/components/plot" import { ChartEmpty } from "@maple/ui/components/charts" import { cn } from "@maple/ui/lib/utils" import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" -import { CHART_EMPTY_MESSAGE, bucketDate, makeBucketAxis } from "@/components/infra/chart-utils" +import { CHART_EMPTY_MESSAGE, ChartCard } from "@/components/common/chart-card" +import { SeriesLegend } from "@/components/common/series-legend" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { errorRate, formatDurationNs, type ToolSeriesPoint } from "@/lib/agent-sessions/tool-analytics" @@ -133,12 +132,19 @@ function Cell({ series: ReadonlyArray format: (value: number) => string }) { - const focusStore = useMemo(() => createTooltipFocusStore(), []) const { effectiveTimezone } = useTimezonePreference() - const chromeColors = usePlotChromeColors() - // Canvas strokes cannot read `var()`; the lines take resolved colors. - const colorTokens = useMemo(() => new Map(series.map((entry) => [entry.key, entry.color])), [series]) - const colors = useResolvedSeriesColors(colorTokens, chromeColors.border) + const cursorSeries = useMemo[]>( + () => + series.map((entry) => ({ + key: entry.key, + label: entry.label, + color: entry.color, + value: (cell: PlotCell) => valueAt(cell.row, entry.key), + format, + })), + [series, format], + ) + const plot = useCursorPlot(cursorSeries) const axis = useMemo( () => @@ -152,7 +158,7 @@ function Cell({ const definition = useMemo(() => { const yDomain = niceLinearDomain(linearYDomain({ rows, keys: series.map((entry) => entry.key) })) return defineChart({ - marks: [...seriesLines(rows, series, colors, chromeColors), focusCrosshair(chromeColors)], + marks: [...seriesLines(rows, series, plot.colors, plot.chrome), focusCrosshair(plot.chrome)], scales: { x: axis.x, y: { @@ -178,63 +184,29 @@ function Cell({ // Sparse buckets sit far apart; keep the whole column live between them. maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE, focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }) - }, [rows, series, axis, format, focusStore, colors, chromeColors]) - - const tooltipSeries = useMemo[]>( - () => - series.map((entry) => ({ - label: entry.label, - color: entry.color, - value: (cell: PlotCell) => valueAt(cell.row, entry.key), - format, - })), - [series, format], - ) + }, [rows, series, axis, format, plot]) return ( -
-
-

- {title} -

- - {series.length > 1 ? ( - - {series.map((entry) => ( - - - {entry.label} - - ))} - - ) : null} -
- + 1 ? : null} + className="gap-3.5 border-b border-border px-6 pt-[22px] pb-5 @min-[900px]/page:[&:nth-child(odd)]:border-r" + > {rows.length === 0 ? ( {CHART_EMPTY_MESSAGE} ) : ( -
- ( - axis.heading(cell.row.bucket)} - /> - )} - /> -
+ axis.heading(cell.row.bucket)} + ariaLabel={title} + height={PLOT_HEIGHT} + /> )} -
+ ) } diff --git a/apps/web/src/components/agent-sessions/tools/tool-detail-sessions.tsx b/apps/web/src/components/agent-sessions/tools/tool-detail-sessions.tsx index e3b5007cf7..b1936a1a01 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-detail-sessions.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-detail-sessions.tsx @@ -5,18 +5,16 @@ import { refreshingClass } from "@maple/ui/lib/refreshing" import { ErrorCountPill } from "@/components/replays/session-pills" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { SkeletonList } from "@maple/ui/components/ui/skeleton" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { countLabel, formatNumber } from "@maple/ui/lib/format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { ExternalLinkIcon } from "@/components/icons" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" import type { AgentSessionRow } from "@/components/agent-sessions/agent-sessions-list" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { sessionLinkWindow, sessionRowId } from "@maple/agent-sessions" import { vendorLabel } from "@/lib/agent-sessions/vendor-label" -const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}` - /** * The sessions that ran this tool, newest first — the way back down from an * aggregate to something that actually happened. @@ -46,8 +44,6 @@ export function ToolDetailSessions({ failure?: unknown waiting?: boolean }) { - const { effectiveTimezone } = useTimezonePreference() - return (
- Sessions running {tool} - - {plural(rows.length, "session")} + Sessions running {tool} + + {countLabel(rows.length, "session")}
Open in Sessions @@ -124,8 +120,8 @@ export function ToolDetailSessions({ {formatSessionDuration(session.durationMs)} - {plural(session.traceCount, "trace")} ·{" "} - {plural(session.spanCount, "span")} + {countLabel(session.traceCount, "trace")} ·{" "} + {countLabel(session.spanCount, "span")} @@ -133,13 +129,11 @@ export function ToolDetailSessions({ {hasErrors ? : null} - - {formatRelativeTimeOrDate( - session.startTime, - undefined, - effectiveTimezone, - )} - + ) }) @@ -148,7 +142,7 @@ export function ToolDetailSessions({ {capped ? (

- Showing the {rows.length.toLocaleString()} most recent sessions — narrow the time range to + Showing the {formatNumber(rows.length)} most recent sessions — narrow the time range to see older ones

) : null} diff --git a/apps/web/src/components/agent-sessions/tools/tool-detail-view.tsx b/apps/web/src/components/agent-sessions/tools/tool-detail-view.tsx index e4488e11a4..d1f536c5b9 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-detail-view.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-detail-view.tsx @@ -1,8 +1,10 @@ import type { ReactNode } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { pluralize } from "@maple/ui/lib/format" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { PageLayout } from "@maple/ui/components/ui/page-layout" import type { AgentSessionRow } from "@/components/agent-sessions/agent-sessions-list" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatTimestampInTimezone } from "@/lib/timezone-format" @@ -83,7 +85,7 @@ export function ToolDetailView({ // Bounded by the window, so this is "first seen in this range" — which is // what a page whose every other number is windowed should say. const subtitle = [ - `${formatToolCount(data.totals.calls)} call${data.totals.calls === 1 ? "" : "s"} in ${formatToolCount(data.totals.sessions)} session${data.totals.sessions === 1 ? "" : "s"}`, + `${formatToolCount(data.totals.calls)} ${pluralize(data.totals.calls, "call")} in ${formatToolCount(data.totals.sessions)} ${pluralize(data.totals.sessions, "session")}`, data.firstSeen > 0 ? `first seen ${formatTimestampInTimezone(data.firstSeen, { timeZone: effectiveTimezone })}` : undefined, @@ -97,12 +99,12 @@ export function ToolDetailView({ return (
-
+
-

+ {tool} -

+ {data.description !== undefined && ( {/* `w-0 flex-1`: fills the row beside the name without its own @@ -110,7 +112,7 @@ export function ToolDetailView({ } tabIndex={0} - className="w-0 min-w-0 flex-1 cursor-default truncate text-[13px] leading-[18px] text-muted-foreground" + className="w-0 min-w-0 flex-1 cursor-default truncate text-[13px] text-muted-foreground" > {data.description} @@ -118,9 +120,7 @@ export function ToolDetailView({ )}
-

- {subtitle.join(" · ")} -

+

{subtitle.join(" · ")}

{headerControls ? (
{headerControls}
diff --git a/apps/web/src/components/agent-sessions/tools/tool-error-modal.tsx b/apps/web/src/components/agent-sessions/tools/tool-error-modal.tsx index 8726bf71cc..547f68fc6a 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-error-modal.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-error-modal.tsx @@ -1,14 +1,17 @@ +import { MiniBars } from "@maple/ui/components/ui/mini-bars" import { useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react" import { Link } from "@tanstack/react-router" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" +import { Meter } from "@maple/ui/components/ui/meter" import { Dialog, DialogPopup } from "@maple/ui/components/ui/dialog" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatWarehouseDateTime } from "@maple/query-engine" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { formatPercent, pluralize } from "@maple/ui/lib/format" import { CheckIcon, @@ -21,6 +24,7 @@ import { XmarkIcon, } from "@/components/icons" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" import { useMountEffect } from "@/hooks/use-mount-effect" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatTimestampInTimezone } from "@/lib/timezone-format" @@ -48,7 +52,7 @@ import { type VariantDifference, } from "@/lib/agent-sessions/tool-error-display" -import { ErrorTextHeading, FailureStatusLine, TrendBars, windowRangeLabel } from "./tool-error-parts" +import { ErrorTextHeading, FailureStatusLine, windowRangeLabel } from "./tool-error-parts" import type { PreparedToolError, ToolErrorsWindow } from "./tool-errors-table" /** One group's facts, read once per group — they do not page. */ @@ -162,7 +166,7 @@ export function ToolErrorModal({ return new Map(detail.variants.map((row, index) => [row.message, found[index]!] as const)) }, [detail.variants]) - const share = toolFailures > 0 ? Math.round((group.calls / toolFailures) * 100) : 0 + const share = formatPercent(toolFailures > 0 ? group.calls / toolFailures : 0, { floor: 0.01 }) const samplesTotal = variant !== undefined && session === undefined ? (detail.variants.find((row) => row.message === variant)?.calls ?? samples.occurrences.length) @@ -202,7 +206,7 @@ export function ToolErrorModal({
-
+
{tool} / errors @@ -211,7 +215,7 @@ export function ToolErrorModal({ {position.index + 1} of {position.total} {group.errorType === "" ? null : ( - + error.type {group.errorType} )} @@ -234,14 +238,14 @@ export function ToolErrorModal({ ) : ( )} -
+
{formatToolCount(group.calls)} failed call {group.calls === 1 ? "" : "s"} , - `${share}% of failures`, + `${share} of failures`, `${formatToolCount(group.sessions)} session${group.sessions === 1 ? "" : "s"}`, `first seen ${formatTimestampInTimezone(group.firstSeen, { timeZone: effectiveTimezone })}`, `last seen ${formatTimestampInTimezone(group.lastSeen, { timeZone: effectiveTimezone })}`, @@ -253,6 +257,8 @@ export function ToolErrorModal({
onStep(-1)} @@ -260,6 +266,8 @@ export function ToolErrorModal({ = position.total - 1} onClick={() => onStep(1)} @@ -267,6 +275,8 @@ export function ToolErrorModal({ Open in Sessions Failed calls / {bucket.unit} - + max {formatToolCount(Math.max(0, ...group.spark))}
- -
+ 12 ? 0.3 : 3.5} + className="h-11 w-[340px] shrink-0" + /> +
{windowRangeLabel(range.startMs, range.startMs, effectiveTimezone)} @@ -404,33 +422,7 @@ function Facts({ items }: { items: ReadonlyArray }) { ) } -function IconButton({ - label, - disabled, - onClick, - children, -}: { - label: string - disabled?: boolean - onClick: () => void - children: ReactNode -}) { - return ( - - ) -} - -const railTitle = "font-mono text-[12.5px] font-medium text-foreground" +const railTitle = "font-mono text-xs font-medium text-foreground" /** The raw texts a group folded, where there is more than one. Picking one * narrows the samples to it. */ @@ -453,7 +445,7 @@ function VariantsSection({
Variants - + {formatToolCount(total)} raw messages
@@ -491,19 +483,20 @@ function VariantsSection({ )} - - - + {formatToolCount(row.calls)} ) })} -

+

{onlyIndex ? "Grouped because only the array index differs." : "Grouped because only masked values differ."}{" "} @@ -571,12 +564,13 @@ function Breakdown({ > {key === "" ? blank : key} - - - + void loading?: boolean }) { - const { effectiveTimezone } = useTimezonePreference() - const relative = (ms: number) => formatRelativeTimeOrDate(ms, undefined, effectiveTimezone) const rowClass = (isSelected: boolean) => cn( - "group relative flex w-full shrink-0 items-center gap-3 border-b border-l-2 border-border/50 pr-4 pl-3.5 text-left transition-colors", - isSelected ? "border-l-primary bg-primary/10" : "border-l-transparent hover:bg-muted/30", + "group flex w-full shrink-0 items-center gap-3 border-b border-border/50 pr-4 pl-4 text-left transition-colors", + rowSelectedClass(isSelected), + !isSelected && "hover:bg-muted/30", ) return (

Sessions - + {formatToolCount(total)}
@@ -684,13 +677,16 @@ function SessionsSection({ onClick={() => onSelect(undefined)} className={cn(rowClass(selected === undefined), "h-10")} > - All sessions + All sessions {formatToolCount(calls)} - - {relative(lastSeen)} - + {loading && rows.length === 0 ? (
@@ -717,7 +713,7 @@ function SessionsSection({ sessionId={row.sessionId} className="max-w-full font-mono text-xs" /> - + {row.service === "" ? row.agentName === "" ? row.vendorId @@ -728,9 +724,12 @@ function SessionsSection({ {formatToolCount(row.hits)} - - {relative(row.lastSeen)} - +
) }) @@ -789,36 +788,38 @@ function SamplesPane({
- Samples - - {formatToolCount(total)} failed call{total === 1 ? "" : "s"} · newest first + Samples + + {formatToolCount(total)} failed {pluralize(total, "call")} · newest first
- j / k - + {rows.length === 0 ? 0 : current + 1} of {formatToolCount(total)} - +
@@ -923,7 +924,7 @@ function Sample({ className="absolute inset-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" /> - {time} + {time} session )} {variant === undefined || variant === "" ? null : ( - + variant {variant} )} {open || row.argumentsBytes === 0 ? null : ( - + args {formatToolCount(row.argumentsBytes)} B )} @@ -963,7 +964,7 @@ function Sample({ // The call's own timestamp narrows the trace read to one partition; // without it the detail page scans every retained day. search={{ spanId: row.spanId, t: formatWarehouseDateTime(row.timestamp) }} - className="relative flex shrink-0 items-center gap-[5px] text-[11.5px] text-muted-foreground transition-colors hover:text-foreground" + className="relative flex shrink-0 items-center gap-[5px] text-2xs text-muted-foreground transition-colors hover:text-foreground" > Open trace @@ -985,9 +986,9 @@ function SampleBody({ row }: { row: ToolErrorOccurrenceRow }) {
{hint === undefined ? null : (
- + What's wrong - + {hint.subject} {hint.text} @@ -997,7 +998,7 @@ function SampleBody({ row }: { row: ToolErrorOccurrenceRow }) {
- + The tool reported no error detail @@ -1029,7 +1030,7 @@ function PayloadHead({ return (
{label} - + {attribute} {bytes > 0 ? ` · ${formatToolCount(bytes)} B` : null} @@ -1043,7 +1044,7 @@ function NotRecorded({ what }: { what: "arguments" | "result" }) { return (
Not recorded - + The span has no {what} attribute.
@@ -1099,7 +1100,7 @@ function ArgumentsBlock({ () => setCopy("failed"), ) } - className="font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground" + className="font-mono text-2xs text-muted-foreground transition-colors hover:text-foreground" > {copy === "failed" ? "Copy failed" : copy === "copied" ? "Copied" : "Copy"} @@ -1116,7 +1117,7 @@ function ArgumentsBlock({ ))}
)} -
+
{truncatedByRead ? `The first ${formatToolCount(row.arguments.length)} characters of ${formatToolCount(row.argumentsBytes)} B` @@ -1149,7 +1150,7 @@ function PayloadLineView({ line }: { line: PayloadLine }) { style={{ paddingLeft: 14 + line.depth * 16 - (line.highlight ? 2 : 0) }} > {line.missingKey !== undefined ? ( - + "{line.missingKey}"missing, required ) : ( @@ -1167,15 +1168,15 @@ function PayloadLineView({ line }: { line: PayloadLine }) { )} {line.folded === undefined ? null : ( - + {line.folded} )} {line.note === undefined ? null : ( -
+
↑ {line.note.text} {line.note.hiddenBytes > 0 ? ( - + +{formatToolCount(line.note.hiddenBytes)} B not shown ) : null} @@ -1199,7 +1200,7 @@ function ResultBlock({ row }: { row: ToolErrorOccurrenceRow }) { )} {source === undefined ? null : ( - + Error message read from {source} )} diff --git a/apps/web/src/components/agent-sessions/tools/tool-error-parts.tsx b/apps/web/src/components/agent-sessions/tools/tool-error-parts.tsx index 2f8a9428d2..28737aa932 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-error-parts.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-error-parts.tsx @@ -19,7 +19,7 @@ export function MaskChip({ label, raw, className }: { label: string; raw: string @@ -178,65 +178,6 @@ export function ErrorTextHeading({ tokens }: { tokens: ReadonlyArray - width: number - height: number - radius?: number - className?: string -}) { - const gap = counts.length > 12 ? 1 : width > 200 ? 12 : 4 - const bar = Math.max(1, (width - gap * (counts.length - 1)) / Math.max(counts.length, 1)) - const max = Math.max(1, ...counts) - return ( - - {counts.map((count, index) => { - const x = index * (bar + gap) - if (count === 0) { - return ( - - ) - } - const barHeight = Math.max(3, Math.round((count / max) * height)) - return ( - - ) - })} - - ) -} - /** "Sep 4 – 11": the window as the trend column's head names it. */ export function windowRangeLabel(startMs: number, endMs: number, timeZone: string): string { const monthDay = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone }) @@ -320,7 +261,7 @@ export function FailureStatusLine({ : `· failed in ${activeBuckets.active} of ${activeBuckets.total} ${activeBuckets.unit === "day" ? "days" : `${activeBuckets.unit} buckets`}`, ] return ( - + {lead} {detail === undefined ? null : {detail}} diff --git a/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx b/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx index 2b19942545..d9a942564c 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx @@ -5,11 +5,14 @@ import { DisclosureChevron } from "@/components/common/disclosure-chevron" import { cn } from "@maple/ui/lib/utils" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { MiniBars } from "@maple/ui/components/ui/mini-bars" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { formatPercent, pluralize } from "@maple/ui/lib/format" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { useTableSort } from "@/components/common/data-table" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" import { ChevronRightIcon } from "@/components/icons" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { @@ -27,17 +30,8 @@ import { type ErrorTextToken, } from "@/lib/agent-sessions/tool-error-display" -import { - ROW, - ROW_IDLE, - ROW_SELECTED, - ShareCell, - Table, - TableBody, - TableHead, - Th, -} from "./tool-breakdown-tables" -import { ErrorTextLine, FailureStatusLine, MaskChip, TrendBars, windowRangeLabel } from "./tool-error-parts" +import { ShareCell, Th, ToolTable, ToolTableBody, ToolTableHead, toolRowClass } from "./tool-breakdown-tables" +import { ErrorTextLine, FailureStatusLine, MaskChip, windowRangeLabel } from "./tool-error-parts" type ErrorSortKey = "calls" | "sessions" | "lastSeen" @@ -108,8 +102,6 @@ export function prepareToolErrors( } } -const shareLabel = (share: number) => (share > 0 && share < 0.005 ? "<1%" : `${Math.round(share * 100)}%`) - /** * How one tool fails, one row per distinct error — the tool detail page's * answer to the error rate above it. @@ -210,14 +202,14 @@ export function ToolErrorsTable({ >
- Errors + Errors {loading ? ( ) : ( - - {formatToolCount(failures)} failed call{failures === 1 ? "" : "s"} + + {formatToolCount(failures)} failed {pluralize(failures, "call")} {rows.length > 0 - ? ` · ${formatToolCount(rows.length)} error${rows.length === 1 ? "" : "s"}` + ? ` · ${formatToolCount(rows.length)} ${pluralize(rows.length, "error")}` : null} )} @@ -235,7 +227,7 @@ export function ToolErrorsTable({ {empty ? (
- + No failed calls between{" "} {windowRangeLabel(window.startMs, window.startMs, effectiveTimezone)} and{" "} @@ -243,19 +235,17 @@ export function ToolErrorsTable({ {toolCalls > 0 - ? `All ${formatToolCount(toolCalls)} ${tool} call${toolCalls === 1 ? "" : "s"} in this range succeeded.` + ? `All ${formatToolCount(toolCalls)} ${tool} ${pluralize(toolCalls, "call")} in this range succeeded.` : `${tool} was not called in this range.`}
) : ( - - -
+ + +
Error {headHint === "" ? null : ( - - {headHint} - + {headHint} )}
+ )}
) diff --git a/apps/web/src/components/agent-sessions/tools/tool-filter-toolbar.tsx b/apps/web/src/components/agent-sessions/tools/tool-filter-toolbar.tsx index 8f7f55cf11..3c82047658 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-filter-toolbar.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-filter-toolbar.tsx @@ -1,20 +1,18 @@ import type { ReactNode } from "react" -import { TONE_FILL } from "@maple/ui/lib/tone" import { refreshingClass } from "@maple/ui/lib/refreshing" -import { ToolbarSearch } from "@maple/ui/components/toolbar" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Toolbar, ToolbarSearch } from "@maple/ui/components/toolbar" +import { FilterChip } from "@maple/ui/components/ui/filter-chip" import { cn } from "@maple/ui/lib/utils" +import { FacetSelect } from "@/components/common/facet-select" + /** One option in the service / env selects, with the sessions behind it. */ export interface ToolFilterOption { readonly name: string readonly count: number } -/** Base UI selects need a real value for "no filter"; this is it, and it never reaches the URL. */ -const ALL = "__all__" - interface ToolFilterToolbarProps { /** The tool-name search. Absent on the tool detail page, which is one tool. */ nameSearch?: { query: string; onSearch: (value: string | undefined) => void } @@ -44,7 +42,7 @@ interface ToolFilterToolbarProps { * second list to rank. It still shows up in the scope band as a removable chip, * because the band is where everything narrowing the page is stated at once. * - * Every control is the same 30px pill, and a control with a value set is drawn + * Every control is a compact pill, and a control with a value set is drawn * in the primary tint so the toolbar reads as "what is narrowing this page" at * a glance. */ @@ -65,7 +63,7 @@ export function ToolFilterToolbar({ actions, }: ToolFilterToolbarProps) { return ( -
+ {nameSearch ? ( - +
{actions ?
{actions}
: null} -
- ) -} - -function FacetSelect({ - label, - value, - options, - onChange, -}: { - /** The dimension, drawn small beside the value: `service All`. */ - label: string - value: string | undefined - options: ReadonlyArray - onChange: (value: string | undefined) => void -}) { - const set = value !== undefined - return ( - + ) } diff --git a/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx b/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx index a860ab5d5b..6a20d44ad8 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx @@ -1,11 +1,10 @@ import { cn } from "@maple/ui/lib/utils" import { formatPercent } from "@maple/ui/lib/format" import { Delta } from "@maple/ui/components/ui/delta" -import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" -import { StatRailItem } from "@/components/common/stat-rail" +import { StatRailItem, StatRailItemSkeleton } from "@/components/common/stat-rail" import { TOOL_PERCENTILES, formatToolCount, @@ -182,7 +181,7 @@ function DurationTile({ {candidate} @@ -211,16 +210,9 @@ function ToolDeltaValue({ delta }: { delta: ToolDelta }) { export function ToolMetricStripLoading() { return ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- -
- - -
- -
+
+ {Array.from({ length: 4 }, (_, index) => ( + ))}
) diff --git a/apps/web/src/components/agent-sessions/tools/tool-scope-row.tsx b/apps/web/src/components/agent-sessions/tools/tool-scope-row.tsx index 08c5f243b0..002a8407f2 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-scope-row.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-scope-row.tsx @@ -1,5 +1,9 @@ +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" +import { XmarkIcon } from "@/components/icons" + /** One removable piece of the current scope. */ export interface ToolScopeChip { readonly kind: "tool" | "model" @@ -46,37 +50,41 @@ export function ToolScopeRow({ )} >
- SCOPE + Scope {chips.length === 0 ? ( - all tools · all models + all tools · all models ) : ( <> {chips.map((chip) => ( - + + } + aria-label={`Remove ${chip.kind} ${chip.value}`} + onClick={() => onRemove(chip)} + className="inline-flex h-[22px] max-w-64 items-center gap-[5px] rounded-sm border border-primary/35 bg-primary/10 px-2 transition-colors hover:bg-primary/20" + > + {chip.kind} + {chip.value} + + + {`Remove ${chip.kind} ${chip.value}`} + ))} )}
- + {count} {rest.join(" ")} diff --git a/apps/web/src/components/agent-sessions/tools/tool-series-chart.tsx b/apps/web/src/components/agent-sessions/tools/tool-series-chart.tsx index 987e66fd71..c5af910fc2 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-series-chart.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-series-chart.tsx @@ -5,25 +5,23 @@ import { scaleLinear } from "@tanstack/charts-scales/linear" import { formatWarehouseDateTime } from "@maple/query-engine" import { - PlotFrame, - PlotTooltipBody, + CursorPlot, UNBOUNDED_FOCUS_DISTANCE, - createTooltipFocusStore, - cursorTooltip, DASHED_Y_GRID, + bucketDate, focusCrosshair, linearYDomain, + makeBucketAxis, niceLinearDomain, - usePlotChromeColors, - useResolvedSeriesColors, - type PlotTooltipSeries, + useCursorPlot, + type CursorPlotSeries, } from "@maple/ui/components/plot" -import { ChartEmpty } from "@maple/ui/components/charts" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ChartEmpty, ChartLoading } from "@maple/ui/components/charts" import { cn } from "@maple/ui/lib/utils" +import { CHART_EMPTY_MESSAGE, ChartCard } from "@/components/common/chart-card" import { ErrorState } from "@/components/common/error-state" -import { CHART_EMPTY_MESSAGE, bucketDate, makeBucketAxis } from "@/components/infra/chart-utils" +import { SeriesLegend } from "@/components/common/series-legend" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatToolMetric, @@ -98,9 +96,7 @@ export function ToolSeriesChart({ modelLabel, waiting, }: ToolSeriesChartProps) { - const focusStore = useMemo(() => createTooltipFocusStore(), []) const { effectiveTimezone } = useTimezonePreference() - const chromeColors = usePlotChromeColors() const plotted = useMemo>( () => @@ -109,9 +105,18 @@ export function ToolSeriesChart({ : [{ key: metric, label: toolMetricLabel(metric, percentile), color: "var(--primary)" }], [metric, percentile], ) - // Canvas strokes cannot read `var()`; the lines take resolved colors. - const colorTokens = useMemo(() => new Map(plotted.map((entry) => [entry.key, entry.color])), [plotted]) - const colors = useResolvedSeriesColors(colorTokens, chromeColors.border) + const cursorSeries = useMemo[]>( + () => + plotted.map((entry) => ({ + key: entry.key, + label: entry.label, + color: entry.color, + value: (cell: PlotCell) => valueAt(cell.row, entry.key), + format: (value: number) => formatToolMetric(value, metric), + })), + [plotted, metric], + ) + const plot = useCursorPlot(cursorSeries) const rows = useMemo>( () => @@ -140,21 +145,10 @@ export function ToolSeriesChart({ [rows, effectiveTimezone], ) - const tooltipSeries = useMemo[]>( - () => - plotted.map((entry) => ({ - label: entry.label, - color: entry.color, - value: (cell: PlotCell) => valueAt(cell.row, entry.key), - format: (value: number) => formatToolMetric(value, metric), - })), - [plotted, metric], - ) - const definition = useMemo(() => { const yDomain = niceLinearDomain(linearYDomain({ rows, keys: plotted.map((entry) => entry.key) })) return defineChart({ - marks: [...seriesLines(rows, plotted, colors, chromeColors), focusCrosshair(chromeColors)], + marks: [...seriesLines(rows, plotted, plot.colors, plot.chrome), focusCrosshair(plot.chrome)], scales: { x: axis.x, y: { @@ -179,9 +173,9 @@ export function ToolSeriesChart({ // Sparse buckets sit far apart; keep the whole column live between them. maxFocusDistance: UNBOUNDED_FOCUS_DISTANCE, focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }) - }, [rows, plotted, axis, metric, focusStore, colors, chromeColors]) + }, [rows, plotted, axis, metric, plot]) const title = toolChartTitle({ metric, @@ -199,70 +193,48 @@ export function ToolSeriesChart({ const bucketMs = axis.stepMs ?? null return ( -
( + + + {part} + + ))} + legend={ + <> + {/* Only where there is more than one series to tell apart: a single + series is already named by the head. */} + {plotted.length > 1 ? : null} + + {bucketMs === null ? null : `${bucketLabel(bucketMs)} buckets`} + + + } aria-busy={waiting || undefined} className={cn( - "flex flex-col gap-4 border-b border-border px-6 pt-[22px] pb-5", + "gap-4 border-b border-border px-6 pt-[22px] pb-5", refreshingClass(waiting ?? false), )} > -
-

- {toolMetricLabel(metric, percentile)} -

- {scopeParts.map((part) => ( - - - {part} - - ))} - - - {bucketMs === null ? null : `${bucketLabel(bucketMs)} buckets`} - -
- - {/* Only where there is more than one series to tell apart — a single - series is already named by the head. */} - {plotted.length > 1 ? ( -
- {plotted.map((entry) => ( - - - {entry.label} - - ))} -
- ) : null} - {failure !== undefined ? ( ) : loading && rows.length === 0 ? ( - + ) : rows.length === 0 ? ( {CHART_EMPTY_MESSAGE} ) : ( -
- ( - axis.heading(cell.row.bucket)} - /> - )} - /> -
+ axis.heading(cell.row.bucket)} + ariaLabel={title} + height={PLOT_HEIGHT} + /> )} -
+ ) } diff --git a/apps/web/src/components/ai-elements/entity-ref.tsx b/apps/web/src/components/ai-elements/entity-ref.tsx index 4300032e08..5e6b5f78b9 100644 --- a/apps/web/src/components/ai-elements/entity-ref.tsx +++ b/apps/web/src/components/ai-elements/entity-ref.tsx @@ -22,7 +22,6 @@ export function TraceRef({ traceId, className }: { traceId: string; className?: params={{ traceId }} target="_blank" rel="noreferrer" - title={traceId} className={cn("font-mono text-foreground", REF_CLASS, className)} > {traceId} @@ -37,7 +36,6 @@ export function ServiceRef({ name, className }: { name: string; className?: stri params={{ serviceName: name }} target="_blank" rel="noreferrer" - title={name} className={cn("text-foreground", REF_CLASS, className)} > {name} diff --git a/apps/web/src/components/ai-elements/inline/inline-card.tsx b/apps/web/src/components/ai-elements/inline/inline-card.tsx index dee65f5eb6..d546118e7a 100644 --- a/apps/web/src/components/ai-elements/inline/inline-card.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-card.tsx @@ -98,14 +98,14 @@ export function InlineServiceChips({ services }: { services: readonly string[] } {shown.map((service) => ( {service} ))} {services.length > shown.length ? ( - + +{services.length - shown.length} ) : null} diff --git a/apps/web/src/components/ai-elements/inline/inline-log.tsx b/apps/web/src/components/ai-elements/inline/inline-log.tsx index 19674525b3..a3607b519d 100644 --- a/apps/web/src/components/ai-elements/inline/inline-log.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-log.tsx @@ -2,8 +2,9 @@ import { Link } from "@tanstack/react-router" import type { InlineLogData } from "@maple/domain/chat-annotations" import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" import { TruncatedText } from "@maple/ui/components/ui/truncated-text" -import { normalizeTimestampInput } from "@/lib/timezone-format" +import { formatTimeInTimezone, normalizeTimestampInput } from "@/lib/timezone-format" import { INLINE_CARD_ROW, inlineCardClass } from "./inline-card" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" /** * One log line. The card is not itself a link — the trace link inside it is the @@ -11,6 +12,7 @@ import { INLINE_CARD_ROW, inlineCardClass } from "./inline-card" * the one element that navigates. */ export function InlineLog({ data }: { data: InlineLogData }) { + const { effectiveTimezone } = useTimezonePreference() return (
@@ -24,8 +26,11 @@ export function InlineLog({ data }: { data: InlineLogData }) { {data.body} {data.timestamp && ( - - {new Date(normalizeTimestampInput(data.timestamp)).toLocaleTimeString()} + + {formatTimeInTimezone(normalizeTimestampInput(data.timestamp), { + timeZone: effectiveTimezone, + withSeconds: true, + })} )} {data.traceId && ( @@ -35,7 +40,7 @@ export function InlineLog({ data }: { data: InlineLogData }) { search={data.timestamp ? { t: data.timestamp } : undefined} target="_blank" rel="noreferrer" - className="shrink-0 rounded-sm bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + className="shrink-0 rounded-sm bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" title={data.traceId} > trace diff --git a/apps/web/src/components/ai-elements/inline/inline-trace.tsx b/apps/web/src/components/ai-elements/inline/inline-trace.tsx index 6f1a7e512b..de7e205f4f 100644 --- a/apps/web/src/components/ai-elements/inline/inline-trace.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-trace.tsx @@ -58,7 +58,7 @@ export function InlineTrace({ data }: { data: InlineTraceData }) { value={data.id} kind="trace" length={12} - className="shrink-0 text-[11px] text-muted-foreground/70" + className="shrink-0 text-2xs text-muted-foreground/70" />
diff --git a/apps/web/src/components/ai-elements/markdown-chart.tsx b/apps/web/src/components/ai-elements/markdown-chart.tsx index 84296409ea..274aa29625 100644 --- a/apps/web/src/components/ai-elements/markdown-chart.tsx +++ b/apps/web/src/components/ai-elements/markdown-chart.tsx @@ -69,7 +69,7 @@ function ChartFrame({ title, children }: { title: string | null; children: React return (
{title ? ( -
{title}
+
{title}
) : null} {children}
diff --git a/apps/web/src/components/ai-elements/markdown-table.tsx b/apps/web/src/components/ai-elements/markdown-table.tsx index 0f9e792cd8..05b6d410b8 100644 --- a/apps/web/src/components/ai-elements/markdown-table.tsx +++ b/apps/web/src/components/ai-elements/markdown-table.tsx @@ -9,6 +9,7 @@ import { cn } from "@maple/ui/lib/utils" import { ServiceRef, TraceRef } from "./entity-ref" import { useKnownServices } from "./known-services" import { classifyCell, columnRole, type CellValue } from "./table-cell-value" +import { Panel } from "@maple/ui/components/ui/panel" /** * The table Streamdown renders for a markdown table in an assistant reply. @@ -72,11 +73,11 @@ export function MarkdownTable({ children, className, node: _node, ...props }: Ma {/* The frame lives on a wrapper because `Table` gives its own scroll container no class hook, and the radius has to clip the header fill. */} -
+ {children}
-
+
) } diff --git a/apps/web/src/components/ai-elements/renderers/components/data-table.tsx b/apps/web/src/components/ai-elements/renderers/components/data-table.tsx index 24a0ed95cf..5509482ddb 100644 --- a/apps/web/src/components/ai-elements/renderers/components/data-table.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/data-table.tsx @@ -21,7 +21,7 @@ export function DataTable({ props }: RendererComponentProps) { return (
- {title &&

{title}

} + {title &&

{title}

}
diff --git a/apps/web/src/components/ai-elements/renderers/components/error-list.tsx b/apps/web/src/components/ai-elements/renderers/components/error-list.tsx index 7b8dd3082f..0d25a99bb1 100644 --- a/apps/web/src/components/ai-elements/renderers/components/error-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/error-list.tsx @@ -23,7 +23,7 @@ export function ErrorList({ props }: RendererComponentProps) { return (
{err.errorType} @@ -36,12 +36,12 @@ export function ErrorList({ props }: RendererComponentProps) { ))} {err.affectedServices.length > 2 && ( - + +{err.affectedServices.length - 2} )}
- {timeAgo} + {timeAgo} ) })} diff --git a/apps/web/src/components/ai-elements/renderers/components/log-list.tsx b/apps/web/src/components/ai-elements/renderers/components/log-list.tsx index da15582677..44328dffef 100644 --- a/apps/web/src/components/ai-elements/renderers/components/log-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/log-list.tsx @@ -1,6 +1,8 @@ import type { RendererComponentProps } from "./types" import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" -import { normalizeTimestampInput } from "@/lib/timezone-format" +import { formatTimeInTimezone, normalizeTimestampInput } from "@/lib/timezone-format" +import { formatNumber } from "@maple/ui/lib/format" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" interface LogListProps { logs: ReadonlyArray<{ @@ -16,26 +18,28 @@ interface LogListProps { export function LogList({ props }: RendererComponentProps) { const { logs, totalCount } = props + const { effectiveTimezone } = useTimezonePreference() return (
{totalCount != null && ( -

- {totalCount.toLocaleString()} total logs +

+ {formatNumber(totalCount)} total logs {totalCount > logs.length && ` (showing ${logs.length})`}

)}
{logs.map((log) => { - const time = new Date(normalizeTimestampInput(log.timestamp)).toLocaleTimeString() + const time = formatTimeInTimezone(normalizeTimestampInput(log.timestamp), { + timeZone: effectiveTimezone, + withSeconds: true, + }) return (
- - {time} - + {time} {log.serviceName} {log.body} diff --git a/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx b/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx index db2a274a3f..99fbb86930 100644 --- a/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx @@ -26,7 +26,7 @@ export function MetricsList({ props }: RendererComponentProps) {summary.length > 0 && (
{summary.map((s) => ( -
+
{s.metricType} diff --git a/apps/web/src/components/ai-elements/renderers/components/query-chart.tsx b/apps/web/src/components/ai-elements/renderers/components/query-chart.tsx index d1bd1e4efc..d8d414e978 100644 --- a/apps/web/src/components/ai-elements/renderers/components/query-chart.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/query-chart.tsx @@ -34,7 +34,7 @@ export function QueryChart({ props }: RendererComponentProps) { if (rows.length === 0) { return ( -
+
No data points
) @@ -42,7 +42,7 @@ export function QueryChart({ props }: RendererComponentProps) { return (
-

{metric}

+

{metric}

{/* * `legend="hidden"`: the Recharts original drew no ``. Nothing * opens a legend slot around an agent message either, so the series are diff --git a/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx b/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx index 57c41b5fba..1a0b0fa4c6 100644 --- a/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx @@ -25,7 +25,7 @@ function SpanNode({ span, isLast, depth }: { span: SpanNode; isLast: boolean; de return (
-
+
) { return (
-
+
Trace{" "} ) {
{cards.map((card) => (
-

{card.label}

+

{card.label}

{formatValue(card.value, card.format)}

))} diff --git a/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx b/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx index b36dd2f093..f8cbe25733 100644 --- a/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx @@ -2,6 +2,7 @@ import type { RendererComponentProps } from "./types" import { cn } from "@maple/ui/lib/utils" import { LatencyValue } from "@maple/ui/components/latency-value" import { formatDuration } from "@maple/ui/lib/format" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Badge } from "@maple/ui/components/ui/badge" @@ -33,12 +34,12 @@ export function TraceList({ props }: RendererComponentProps) { return (
{stats && ( -
+
- P50: + P50: - P95: + P95: Min: {formatDuration(stats.minMs)} Max: {formatDuration(stats.maxMs)} @@ -75,8 +76,7 @@ export function TraceList({ props }: RendererComponentProps) { 1000 && "text-severity-warn", - trace.durationMs > 5000 && "text-severity-error", + latencyToneClass(trace.durationMs, "p95"), )} > {formatDuration(trace.durationMs)} @@ -92,7 +92,7 @@ export function TraceList({ props }: RendererComponentProps) { ))} {trace.services.length > 3 && ( - + +{trace.services.length - 3} )} diff --git a/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx b/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx index fb1123e27e..bfd79cc3e0 100644 --- a/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx +++ b/apps/web/src/components/ai-elements/renderers/tool-renderer.tsx @@ -1,6 +1,6 @@ import type { StructuredToolOutput } from "@maple/domain" import type { ReactNode } from "react" -import { formatErrorRate } from "@maple/ui/lib/format" +import { countLabel, EMPTY_VALUE, formatErrorRate, formatLatency, formatPercent } from "@maple/ui/lib/format" import { DataTable } from "./components/data-table" import { ErrorList } from "./components/error-list" import { LogList } from "./components/log-list" @@ -179,10 +179,10 @@ export function ToolRenderer({ data: output }: { data: StructuredToolOutput }) { edge.targetService, String(edge.callCount), String(edge.errorCount), - `${edge.avgDurationMs.toFixed(1)}ms`, - `${edge.maxDurationMs.toFixed(1)}ms`, + formatLatency(edge.avgDurationMs), + formatLatency(edge.maxDurationMs), ]), - title: `Service Map (${output.data.serviceCount} services, ${output.data.edges.length} edges)`, + title: `Service Map (${countLabel(output.data.serviceCount, "service")}, ${countLabel(output.data.edges.length, "edge")})`, }} /> ) @@ -215,7 +215,9 @@ export function ToolRenderer({ data: output }: { data: StructuredToolOutput }) { incident.severity, incident.status, incident.signalType, - incident.lastObservedValue != null ? String(incident.lastObservedValue) : "—", + incident.lastObservedValue != null + ? String(incident.lastObservedValue) + : EMPTY_VALUE, incident.firstTriggeredAt.slice(0, 19), ]), title: @@ -280,8 +282,12 @@ export function ToolRenderer({ data: output }: { data: StructuredToolOutput }) { {output.data.breakdown ? ( @@ -539,9 +548,7 @@ export function ToolRenderer({ data: output }: { data: StructuredToolOutput }) { rows: output.data.breakdown.groups.map((group) => [ group.group === "" ? "(none)" : group.group, ...group.counts.map((count) => String(count)), - group.conversion === null - ? "—" - : `${(group.conversion * 100).toFixed(1)}%`, + group.conversion === null ? EMPTY_VALUE : formatPercent(group.conversion), ]), title: `By ${output.data.breakdown.by}`, }} diff --git a/apps/web/src/components/ai-elements/tool.tsx b/apps/web/src/components/ai-elements/tool.tsx index cdd4e1f661..086b037193 100644 --- a/apps/web/src/components/ai-elements/tool.tsx +++ b/apps/web/src/components/ai-elements/tool.tsx @@ -282,7 +282,7 @@ export const ToolRow = memo(function ToolRow(props: ToolProps) { {label} {summary ? ( - + {summary} ) : ( @@ -353,7 +353,7 @@ export const ToolRow = memo(function ToolRow(props: ToolProps) { export function RunningClock() { const elapsed = formatElapsed(useElapsedSeconds()) if (!elapsed) return null - return {elapsed} + return {elapsed} } /** A tool call standing on its own — same line, no shell around it. */ diff --git a/apps/web/src/components/ai-triage/breach.ts b/apps/web/src/components/ai-triage/breach.ts index 6187ce8330..e345831b23 100644 --- a/apps/web/src/components/ai-triage/breach.ts +++ b/apps/web/src/components/ai-triage/breach.ts @@ -1,4 +1,5 @@ import type { AlertComparator, AlertSignalType } from "@maple/domain/http" +import { formatPercent } from "@maple/ui/lib/format" import { formatSignalValue } from "@/lib/alerts/form-utils" /** @@ -70,7 +71,7 @@ export function formatBreach( } const magnitude = Math.abs(value - threshold) / Math.abs(threshold) - const pct = Math.round(magnitude * 100) + const pct = formatPercent(magnitude) switch (comparator) { case "gt": @@ -79,7 +80,7 @@ export function formatBreach( return { observed, threshold: thresholdLabel, - delta: `${over ? "+" : "−"}${pct}% ${over ? "over" : "under"}`, + delta: `${over ? "+" : "−"}${pct} ${over ? "over" : "under"}`, exceedsThreshold: over, } } @@ -89,7 +90,7 @@ export function formatBreach( return { observed, threshold: thresholdLabel, - delta: `${under ? "−" : "+"}${pct}% ${under ? "under" : "over"}`, + delta: `${under ? "−" : "+"}${pct} ${under ? "under" : "over"}`, exceedsThreshold: under, } } diff --git a/apps/web/src/components/alerts/alert-create-form-surface.tsx b/apps/web/src/components/alerts/alert-create-form-surface.tsx index bcd3e7d917..440f53cc11 100644 --- a/apps/web/src/components/alerts/alert-create-form-surface.tsx +++ b/apps/web/src/components/alerts/alert-create-form-surface.tsx @@ -36,7 +36,7 @@ import { signalLabels, type RuleFormState, } from "@/lib/alerts/form-utils" -import { getExitErrorMessage } from "@/lib/error-toast" +import { toastExit } from "@/lib/error-toast" import { applyTemplate } from "@/lib/alerts/templates" import type { WidgetAlertPrefillNotice } from "@/lib/alerts/widget-prefill" import { Result, useAtomSet } from "@/lib/effect-atom" @@ -157,12 +157,10 @@ export function AlertCreateFormSurface({ }) : await createRule({ payload, reactivityKeys: ["alertRules"] }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: editingRule ? "Rule updated" : "Rule created", type: "success" }) + const success = editingRule ? "Rule updated" : "Rule created" + if (toastExit(result, { success, error: "Failed to save rule" })) { if (!editingRule) trackProduct("alert_rule_created", { signal: ruleForm.signalType }) navigate({ to: "/alerts" }) - } else { - toastManager.add({ title: getExitErrorMessage(result, "Failed to save rule"), type: "error" }) } }) @@ -179,6 +177,8 @@ export function AlertCreateFormSurface({ payload: buildRuleTestParamsV2(ruleForm, sendNotification), reactivityKeys: ["alertDeliveryEvents"], }) + const success = sendNotification ? "Preview ran and sent a test notification" : "Preview updated" + if (!toastExit(result, { success, error: "Failed to preview rule" })) return if (Exit.isSuccess(result)) { setPreviewResult({ key: testedKey, @@ -187,12 +187,6 @@ export function AlertCreateFormSurface({ sampleCount: result.value.sample_count, reason: result.value.reason, }) - toastManager.add({ - title: sendNotification ? "Preview ran and sent a test notification" : "Preview updated", - type: "success", - }) - } else { - toastManager.add({ title: getExitErrorMessage(result, "Failed to preview rule"), type: "error" }) } } const [previewRule, previewingRule] = useAsyncAction(() => runTest(false)) diff --git a/apps/web/src/components/alerts/alert-stat-card.tsx b/apps/web/src/components/alerts/alert-firing-hero.tsx similarity index 58% rename from apps/web/src/components/alerts/alert-stat-card.tsx rename to apps/web/src/components/alerts/alert-firing-hero.tsx index 52ade545a7..82f312270e 100644 --- a/apps/web/src/components/alerts/alert-stat-card.tsx +++ b/apps/web/src/components/alerts/alert-firing-hero.tsx @@ -1,98 +1,9 @@ -import type { ReactNode } from "react" import { Card, CardContent } from "@maple/ui/components/ui/card" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { TONE_FILL, TONE_TEXT, type Tone } from "@maple/ui/lib/tone" +import { pluralize } from "@maple/ui/lib/format" +import { TONE_FILL, type Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" -export type AlertStatItem = { - label: string - value: ReactNode - hint?: ReactNode - tone?: Tone -} - -/** - * Flat, divider-separated summary row. Replaces the old "one card per single - * value" grid (the hero-metric / identical-card-grid pattern the design system - * rejects). Hairlines come from a `bg-border` backplate showing through a 1px - * gap; cells stack on mobile and sit in a row from `sm` up. - */ -export function AlertStatShell({ children, className }: { children: ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -/** One strip cell. Given `onSelect` it renders as a toggle button. */ -export function AlertStatCell({ - label, - value, - hint, - valueClassName, - onSelect, - selected, - selectedClassName, -}: { - label: string - value: ReactNode - hint?: ReactNode - valueClassName?: string - onSelect?: () => void - selected?: boolean - selectedClassName?: string -}) { - const body = ( - <> - {label} -
- - {value} - - {hint && {hint}} -
- - ) - if (!onSelect) { - return
{body}
- } - return ( - - ) -} - -export function AlertStatStrip({ items, className }: { items: AlertStatItem[]; className?: string }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} - /* -------------------------------------------------------------------------- */ /* Status bar — flat one-row treatment that leads the Monitor / dashboard */ /* -------------------------------------------------------------------------- */ @@ -160,15 +71,15 @@ export function AlertFiringHero({
- + Firing now - + {openCount} - {openCount === 1 ? "incident" : "incidents"} + {pluralize(openCount, "incident")} · diff --git a/apps/web/src/components/alerts/alert-rule-chart.browser.test.tsx b/apps/web/src/components/alerts/alert-rule-chart.browser.test.tsx index 344059201b..258df8a319 100644 --- a/apps/web/src/components/alerts/alert-rule-chart.browser.test.tsx +++ b/apps/web/src/components/alerts/alert-rule-chart.browser.test.tsx @@ -190,8 +190,8 @@ describe("alert rule chart rail", () => { render() expect(screen.getByText("Eval")).toBeTruthy() expect(screen.getByText("Incident")).toBeTruthy() - expect(screen.getByText("Healthy 2")).toBeTruthy() - expect(screen.getByText("Breached 1")).toBeTruthy() + expect(screen.getByText("Healthy").parentElement?.textContent).toContain("2") + expect(screen.getByText("Breached").parentElement?.textContent).toContain("1") }) it("reports the clicked bucket with its own time bounds", () => { diff --git a/apps/web/src/components/alerts/alert-rule-chart.tsx b/apps/web/src/components/alerts/alert-rule-chart.tsx index 9d9460f9a3..a30b613e59 100644 --- a/apps/web/src/components/alerts/alert-rule-chart.tsx +++ b/apps/web/src/components/alerts/alert-rule-chart.tsx @@ -29,33 +29,40 @@ import { } from "@/lib/alerts/chart-series" import { normalizeTimestampInput } from "@/lib/timezone-format" import { - PlotFrame, - PlotTooltipBody, + CursorPlot, bucketTimeScale, - createTooltipFocusStore, - cursorTooltip, DASHED_Y_GRID, focusCrosshair, - resolvePlotColor, roundCapDasharray, useChartId, - usePlotChromeColors, - useResolvedSeriesColors, - type PlotTooltipSeries, + useCursorPlot, + type CursorPlotSeries, } from "@maple/ui/components/plot" -import { useTheme } from "@maple/ui/hooks/use-theme" +import { ChartEmpty, ChartError, ChartLoading } from "@maple/ui/components/charts" /** A `ChartConfig` in all but name — kept local now that the Recharts kit is gone. */ type ChartConfig = Record import { formatBucketLabel } from "@maple/ui/lib/format" import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" -import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { TONE_FILL } from "@maple/ui/lib/tone" +import { TONE_COLOR, TONE_FILL } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" +import { SeriesLegend } from "@/components/common/series-legend" + /** The single-series signal line and its area fill — one fixed accent, never hashed. */ const SIGNAL_TOKEN = "--chart-1" -const SIGNAL_FALLBACK = "#6366f1" + +/** Colours the chart paints that are not a tooltip row: the bands, rules and fills. */ +const PAINT_KEYS = { + signal: "__signal", + destructive: "__destructive", + muted: "__muted", +} as const +const PAINT_COLORS = [ + { key: PAINT_KEYS.signal, color: SIGNAL_TOKEN }, + { key: PAINT_KEYS.destructive, color: "--destructive" }, + { key: PAINT_KEYS.muted, color: "--muted-foreground" }, +] /** * THE alert rule chart — shared by the create form's live hero and the rule @@ -374,21 +381,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ })) }, [incidentBands, domain]) - const chromeColors = usePlotChromeColors() - const focusStore = React.useMemo(() => createTooltipFocusStore(), []) const signalGradientId = useChartId("alert-signal") - const { theme } = useTheme() - - const palette = React.useMemo( - () => ({ - signal: resolvePlotColor(SIGNAL_TOKEN, SIGNAL_FALLBACK), - destructive: resolvePlotColor("--destructive", "#ef4444"), - muted: resolvePlotColor("--muted-foreground", "#71717a"), - }), - [theme], - ) - - const resolvedSeriesColors = useResolvedSeriesColors(seriesColors, palette.signal) /** * One plotted bucket: the row's series values, plus its instant as a `Date`. @@ -403,11 +396,12 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ [chartData], ) - const tooltipSeries = React.useMemo[]>(() => { + const series = React.useMemo[]>(() => { const keys = isMultiSeries ? seriesKeys : [SINGLE_KEY] - const rows: PlotTooltipSeries[] = keys.map((key) => ({ + const rows: CursorPlotSeries[] = keys.map((key) => ({ + key, label: chartConfig[key]?.label ?? key, - color: isMultiSeries ? (resolvedSeriesColors.get(key) ?? palette.signal) : palette.signal, + color: isMultiSeries ? (seriesColors.get(key) ?? SIGNAL_TOKEN) : SIGNAL_TOKEN, value: (point: SignalPoint) => { const value = point[key] return typeof value === "number" ? value : null @@ -416,8 +410,9 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ })) if (hasGhost) { rows.push({ + key: GHOST_KEY, label: chartConfig[GHOST_KEY]?.label ?? "Other source", - color: palette.muted, + color: "--muted-foreground", dashed: true, value: (point: SignalPoint) => { const value = point[GHOST_KEY] @@ -427,7 +422,17 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ }) } return rows - }, [isMultiSeries, seriesKeys, chartConfig, resolvedSeriesColors, palette, signalType, hasGhost]) + }, [isMultiSeries, seriesKeys, chartConfig, seriesColors, signalType, hasGhost]) + const paintSeries = React.useMemo(() => [...series, ...PAINT_COLORS], [series]) + const plot = useCursorPlot(paintSeries) + const palette = React.useMemo( + () => ({ + signal: plot.color(PAINT_KEYS.signal), + destructive: plot.color(PAINT_KEYS.destructive), + muted: plot.color(PAINT_KEYS.muted), + }), + [plot], + ) const definition = React.useMemo(() => { const at = (point: SignalPoint) => point.at @@ -548,7 +553,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ id: key, x: at, y: valueOf(key), - stroke: resolvedSeriesColors.get(key) ?? palette.signal, + stroke: plot.color(key), strokeWidth: 1.5, curve, }), @@ -572,7 +577,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ curve, }), ]), - focusCrosshair(chromeColors), + focusCrosshair(plot.chrome), ], scales: { x: { @@ -609,7 +614,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ margin: { top: 8, right: PLOT_RIGHT, left: Y_AXIS_WIDTH }, focus: "group-x", focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }) }, [ points, @@ -623,17 +628,15 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ hasGhost, isMultiSeries, seriesKeys, - resolvedSeriesColors, + plot, palette, splitOffset, breachAbove, breachBelow, signalGradientId, - chromeColors, formatTime, timeZone, signalType, - focusStore, ]) const chartArea = hasSignal ? ( @@ -645,58 +648,47 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ * rather than a reserved band inside it. */} {isMultiSeries ? ( -
- {seriesKeys.map((key) => ( - - - - {chartConfig[key]?.label ?? key} - - - ))} -
- ) : null} -
- ( - { - const t = num(point.t) - const label = formatTime(t, "tooltip") - const meta = bucketMeta.get(t) - const extras = [ - meta != null ? `${meta.sampleCount} samples` : null, - meta?.status === "skipped" ? "skipped" : null, - meta?.provisional === true ? "in progress" : null, - ].filter(Boolean) - return extras.length > 0 ? `${label} · ${extras.join(" · ")}` : label - }} - /> - )} + ({ + key, + label: chartConfig[key]?.label ?? key, + color: plot.color(key), + }))} /> -
+ ) : null} + { + const t = num(point.t) + const label = formatTime(t, "tooltip") + const meta = bucketMeta.get(t) + const extras = [ + meta != null ? `${meta.sampleCount} samples` : null, + meta?.status === "skipped" ? "skipped" : null, + meta?.provisional === true ? "in progress" : null, + ].filter(Boolean) + return extras.length > 0 ? `${label} · ${extras.join(" · ")}` : label + }} + ariaLabel="Alert signal" + height={CHART_HEIGHT} + />
) : loading ? ( - + ) : error != null ? ( - -

Preview query failed

-

{error}

-
+ +
+

Preview query failed

+

{error}

+
+
) : ( - -

No data in this window. Try widening the range.

-
+ + No data in this window. + ) return ( @@ -704,7 +696,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ {chartArea} {hasSignal && ( -
+
{error != null && ( -

{error}

+

{error}

)}
)} {hasSignal && hasGhost && ( -
+
0 && ( -

+

Shaded: rule would have fired (approximate — the live scheduler evaluates every minute).

)} {hasSignal && source === "preview" && noDataBands.length > 0 && ( -

- Hatched: no data received in these windows. -

+

Hatched: no data received in these windows.

)} {preview?.truncatedToStart != null && ( -

+

{clampedToPreview ? "Axis starts at " : "Query series starts at "} {formatTime(Date.parse(preview.truncatedToStart), "tooltip")} — the selected range needs more evaluation windows than one preview replays. Widen the rule's window or shorten the @@ -808,7 +798,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ "h-full flex-1 rounded-[1px] p-0", RAIL_COLOR[cell.status], onSelectBucket != null && "cursor-pointer", - cell.opened && "ring-1 ring-inset ring-destructive", + cell.opened && "ring-1 ring-inset ring-severity-error", selected && "ring-2 ring-foreground ring-offset-0", )} /> @@ -833,7 +823,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({

@@ -845,22 +835,6 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ ) }) -function Placeholder({ children, tone }: { children: React.ReactNode; tone?: "destructive" }) { - return ( -
-
{children}
-
- ) -} - /** Fixed-width label column that keeps both lanes aligned to the plot area. */ function RailGutter({ children }: { children: React.ReactNode }) { return ( @@ -877,24 +851,31 @@ function RailLegend({ totals: { breached: number; healthy: number; skipped: number; errored: number } }) { return ( -
- Healthy {totals.healthy} - Breached {totals.breached} - {totals.errored > 0 && ( - Failed {totals.errored} - )} - {totals.skipped > 0 && ( - Skipped {totals.skipped} - )} -
- ) -} - -function LegendChip({ className, children }: { className?: string; children: React.ReactNode }) { - return ( - - - {children} - + 0 + ? [{ key: "errored", label: "Failed", value: totals.errored, color: TONE_COLOR.warn }] + : []), + ...(totals.skipped > 0 + ? [ + { + key: "skipped", + label: "Skipped", + value: totals.skipped, + color: "color-mix(in oklab, var(--color-muted-foreground) 30%, transparent)", + }, + ] + : []), + ]} + /> ) } diff --git a/apps/web/src/components/alerts/destination-card.tsx b/apps/web/src/components/alerts/destination-card.tsx index 4df4f4139e..ef76160321 100644 --- a/apps/web/src/components/alerts/destination-card.tsx +++ b/apps/web/src/components/alerts/destination-card.tsx @@ -1,17 +1,16 @@ import type { AlertDestinationDocument } from "@maple/domain/http" import { destinationProvider, ProviderLogo } from "@/components/alerts/destination-provider" -import { AlertWarningIcon, CheckIcon, DotsVerticalIcon, PencilIcon, TrashIcon } from "@/components/icons" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { useState } from "react" +import { RelativeTime } from "@/components/common/relative-time" +import { AlertWarningIcon, CheckIcon, PencilIcon, TrashIcon } from "@/components/icons" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" import { Switch } from "@maple/ui/components/ui/switch" import { cn } from "@maple/ui/lib/utils" @@ -23,7 +22,8 @@ interface DestinationCardProps { onToggle: (destination: AlertDestinationDocument) => void onTest: (destination: AlertDestinationDocument) => void onEdit: (destination: AlertDestinationDocument) => void - onDelete: (destination: AlertDestinationDocument) => void + /** Resolves to whether it was deleted; the confirm dialog stays open on failure. */ + onDelete: (destination: AlertDestinationDocument) => Promise } export function DestinationCard({ @@ -37,6 +37,7 @@ export function DestinationCard({ onDelete, }: DestinationCardProps) { const provider = destinationProvider(destination) + const [confirmDelete, setConfirmDelete] = useState(false) return ( {destination.name} - {provider.label} - +
- + {destination.summary} @@ -82,18 +84,18 @@ export function DestinationCard({ tested{" "} - {destination.lastTestedAt - ? formatRelativeTime(destination.lastTestedAt) - : "never"} + {destination.lastTestedAt ? ( + + ) : ( + "never" + )} {!destination.enabled && ( <> · - - Disabled - + Disabled )}
@@ -101,7 +103,7 @@ export function DestinationCard({ {destination.lastTestError && ( - + {destination.lastTestError} @@ -126,31 +128,33 @@ export function DestinationCard({ Send test {isAdmin && ( - - } + + onEdit(destination)}> + + Edit + + + setConfirmDelete(true)} + disabled={isDeleting} > - - - - onEdit(destination)}> - - Edit - - - onDelete(destination)} - disabled={isDeleting} - > - - Delete - - - + + Delete + + )}
+ onDelete(destination)} + /> ) } diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx index 9bb66f724e..8abc3f60b8 100644 --- a/apps/web/src/components/alerts/destination-dialog.tsx +++ b/apps/web/src/components/alerts/destination-dialog.tsx @@ -20,13 +20,7 @@ import { import { chatIntegrationId } from "@/components/integrations/integration-catalog" import { useAsyncAction } from "@/hooks/use-mutation-action" import { useChatConnectors, useChatWorkspaceConnect } from "@/components/alerts/use-chat-workspaces" -import { - ArrowRightIcon, - ArrowRotateClockwiseIcon, - CircleInfoIcon, - HazelIcon, - MagnifierIcon, -} from "@/components/icons" +import { ArrowRightIcon, CircleInfoIcon, HazelIcon, MagnifierIcon } from "@/components/icons" import { CHANNEL_RESULT_LIMIT, channelLabel, @@ -53,8 +47,9 @@ import { DialogTitle, } from "@maple/ui/components/ui/dialog" import { Input } from "@maple/ui/components/ui/input" -import { Label } from "@maple/ui/components/ui/label" -import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" +import { Panel } from "@maple/ui/components/ui/panel" +import { RefreshButton } from "@maple/ui/components/ui/refresh-button" +import { Field, FieldDescription, FieldError, FieldLabel } from "@maple/ui/components/ui/field" import { Select, SelectContent, @@ -176,7 +171,7 @@ function ProviderTile({ disabled={busy} aria-pressed={selected} className={cn( - "relative flex flex-col items-start gap-2 overflow-hidden rounded-lg border p-3 text-left transition-colors", + "relative flex flex-col items-start gap-2 overflow-hidden rounded-md border p-3 text-left transition-colors", selected ? "border-(--tile-accent) bg-muted/40" : "border-border/60 bg-card hover:border-border hover:bg-muted/40", @@ -198,7 +193,7 @@ function ProviderTile({ {label ?? provider.label} {busy ? : null}
-

+

{description ?? provider.description}

@@ -265,13 +260,13 @@ function EmailMemberPicker({ placeholder={form.memberUserIds.length === 0 ? "Select members…" : "Add member..."} value={form.memberUserIds} /> - + Alert emails go to the selected workspace members (up to {MAX_EMAIL_MEMBER_RECIPIENTS}). {form.memberUserIds.length > MAX_EMAIL_MEMBER_RECIPIENTS && ( -

+ Select at most {MAX_EMAIL_MEMBER_RECIPIENTS} members. -

+ )} ) @@ -424,7 +419,7 @@ function HazelOAuthFields({ return (
{statusFailed ? ( -

+

Couldn't check your Hazel connection status. This may be a temporary issue — try connecting again.

@@ -465,7 +460,7 @@ function HazelOAuthFields({ return (
-
+
Connected to Hazel
@@ -482,7 +477,7 @@ function HazelOAuthFields({ > Disconnect -
+ Hazel organization @@ -526,11 +521,11 @@ function HazelOAuthFields({ {organizationsFailed ? ( -

+ Couldn't load your Hazel organizations. Try reconnecting or refreshing. -

+ ) : organizations.length === 0 ? ( -

+

No organizations returned. Make sure your Hazel account is a member of at least one organization.

@@ -577,11 +572,11 @@ function HazelOAuthFields({ {orgIdForChannels.length > 0 && !channelsLoading && channelsFailed ? ( -

+ Couldn't load channels for this organization. Try reselecting the organization. -

+ ) : orgIdForChannels.length > 0 && !channelsLoading && channels.length === 0 ? ( -

+

No channels. Make sure your account is in at least one channel of this organization.

) : null} @@ -745,30 +740,26 @@ function ChatDestinationFields({ } return ( -
+
- +
{storedChannelName ? ( - + Currently{" "} #{storedChannelName} ) : null} - + className="-my-1 h-6 gap-1 px-1.5 text-2xs text-muted-foreground" + onRefresh={refreshChannels} + pending={channelsLoading} + />
#{channel.name} {channel.is_private ? ( - private + private ) : null} @@ -825,7 +816,9 @@ function ChatDestinationFields({ {needsReinstall ? (
-

{failure?.message}

+ + {failure?.message} +
) : channels.length === 0 && !channelsLoading ? ( -

+

No channels returned. Make sure the Maple bot can see at least one channel, then hit Refresh.

) : null} -

+

Private channels are listed once the Maple bot has been added to them. Use Send test after saving to check it can post.

-
+
) } @@ -880,7 +873,7 @@ function FieldHelper({ provider }: { provider: DestinationProvider }) { href={provider.docsUrl} target="_blank" rel="noreferrer" - className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" + className="text-2xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" > {provider.docsLabel ?? "Docs"} ↗
@@ -927,16 +920,16 @@ function TelegramChatPicker({ }) return ( -
+
- +
- {error !== null ?

{error}

: null} + {error !== null ? ( + + {error} + + ) : null} {chats !== null && chats.length === 0 ? ( -

+

No recent chats. Add the bot to the group or channel (or send it a message), then detect again. Telegram only keeps the last 24 hours.

@@ -966,14 +963,14 @@ function TelegramChatPicker({ className="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted" > {chat.title} - + {TELEGRAM_CHAT_TYPE_LABELS[chat.type]} ))}
) : null} -
+ ) } @@ -1017,7 +1014,7 @@ export function DestinationDialog({ Send to {providerLocked ? ( -
+ Change -
+ ) : ( )} @@ -1048,7 +1045,7 @@ export function DestinationDialog({
-
+ Name @@ -1089,13 +1086,13 @@ export function DestinationDialog({ /> {form.integrationKey.trim().length > 0 && !isValidPagerDutyKey(form.integrationKey) && ( -

+ That isn't a routing key (must be 32 characters). A ~20-character REST API token won't work — copy the Events API v2 integration key. -

+ )} - + In PagerDuty: open the service → Integrations → add or select an{" "} - + In Discord: Channel settings → Integrations → Webhooks → New Webhook, then copy the URL. @@ -1163,7 +1160,7 @@ export function DestinationDialog({ } className="font-mono text-xs" /> - + In Telegram: message @BotFather, send{" "} /newbot, then copy the token it replies with. @@ -1191,7 +1188,7 @@ export function DestinationDialog({ placeholder="-1001234567890 or @mychannel" className="font-mono text-xs" /> -

+

Add the bot to the chat, then hit Detect chats — or enter the id by hand. Maple checks the bot can reach it when you save. @@ -1251,7 +1248,7 @@ export function DestinationDialog({ (isClerkAuthEnabled ? ( ) : ( -

+

Email destinations target workspace members and require Clerk authentication, which is not enabled in this deployment.

@@ -1272,7 +1269,7 @@ export function DestinationDialog({ isEditing={isEditing} /> )} -
+
{isEditing ? ( diff --git a/apps/web/src/components/alerts/details-section.tsx b/apps/web/src/components/alerts/details-section.tsx index f0957c5aff..445c6abfbd 100644 --- a/apps/web/src/components/alerts/details-section.tsx +++ b/apps/web/src/components/alerts/details-section.tsx @@ -6,7 +6,7 @@ import { Input } from "@maple/ui/components/ui/input" import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { Textarea } from "@maple/ui/components/ui/textarea" -import { SectionHeader } from "@/components/layout/section-header" +import { SectionHeading } from "@/components/common/section-heading" import { TagInput } from "@/components/alerts/tag-input" import { PlusIcon } from "@/components/icons" import type { RuleFormState } from "@/lib/alerts/form-utils" @@ -31,7 +31,7 @@ export function DetailsSection({ form, onChange, suggestedName, tagSuggestions } return ( - +
@@ -43,7 +43,7 @@ export function DetailsSection({ form, onChange, suggestedName, tagSuggestions } variant="ghost" size="sm" onClick={() => onChange((c) => ({ ...c, name: suggestedName! }))} - className="h-6 px-1.5 text-[11px]" + className="h-6 px-1.5 text-2xs" > Suggest: {suggestedName} diff --git a/apps/web/src/components/alerts/incident-timeline-strip.tsx b/apps/web/src/components/alerts/incident-timeline-strip.tsx index 8e1cc2e91f..ad9ff49d74 100644 --- a/apps/web/src/components/alerts/incident-timeline-strip.tsx +++ b/apps/web/src/components/alerts/incident-timeline-strip.tsx @@ -4,6 +4,7 @@ import type { AlertIncidentDocument } from "@maple/domain/http" import { cn } from "@maple/ui/lib/utils" import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" /** * Fixed-bucket strip of a rule's incident history over a time range: red where @@ -67,7 +68,7 @@ export function IncidentTimelineStrip({ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length positional strip key={i} className={cn( - "flex-1 rounded-[2px]", + "flex-1 rounded-xs", compact ? "h-2" : "h-3", hit ? held @@ -82,21 +83,21 @@ export function IncidentTimelineStrip({ })}
{showAxisLabels && !compact && ( -
- {formatEdge(range.min, effectiveTimezone)} - {formatEdge(range.max, effectiveTimezone)} +
+ + {formatTimestampInTimezone(range.min, { + timeZone: effectiveTimezone, + style: "range", + })} + + + {formatTimestampInTimezone(range.max, { + timeZone: effectiveTimezone, + style: "range", + })} +
)}
) } - -function formatEdge(ms: number, timeZone: string): string { - return new Date(ms).toLocaleString(undefined, { - timeZone, - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) -} diff --git a/apps/web/src/components/alerts/notifications-section.tsx b/apps/web/src/components/alerts/notifications-section.tsx index 21d6718428..bc74d5b2cb 100644 --- a/apps/web/src/components/alerts/notifications-section.tsx +++ b/apps/web/src/components/alerts/notifications-section.tsx @@ -7,6 +7,9 @@ import { useUser } from "@clerk/clerk-react" import { ALERT_TEMPLATE_VARIABLES, type AlertDestinationDocument } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Panel } from "@maple/ui/components/ui/panel" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { DropdownMenu, DropdownMenuContent, @@ -25,7 +28,7 @@ import { ProviderLogo, } from "@/components/alerts/destination-provider" import { useChatConnectors, useChatWorkspaceConnect } from "@/components/alerts/use-chat-workspaces" -import { SectionHeader } from "@/components/layout/section-header" +import { SectionHeading } from "@/components/common/section-heading" import { chatDestinationForm, defaultDestinationForm, @@ -112,23 +115,36 @@ export function NotificationsSection({ return (
- + {selected.length > 0 && ( - + + + } + > + + Send test + + + Send a real test notification to every destination below + + )}
-
+ {selected.map((destination) => ( Destination unavailable - +
))} {selected.length === 0 && unresolved.length === 0 && ( @@ -168,7 +182,7 @@ export function NotificationsSection({ onQuickCreate={onQuickCreate} /> )} -
+ @@ -199,18 +213,16 @@ function SelectedDestination({ /> {primary} - {paused ? {paused} · : null} + {paused ? {paused} · : null} {secondary} - +
) } @@ -430,7 +442,7 @@ function MessageTemplate({ Message template {hasTemplate && !open && ( - + Customized )} @@ -469,15 +481,20 @@ function MessageTemplate({ Insert a variable:
{ALERT_TEMPLATE_VARIABLES.map((variable) => ( - + + appendToBody(`{{ ${variable.key} }}`)} + className="rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-3xs text-muted-foreground hover:border-border hover:text-foreground" + /> + } + > + {variable.key} + + {variable.description} + ))}
diff --git a/apps/web/src/components/alerts/overview/active-incidents-table.tsx b/apps/web/src/components/alerts/overview/active-incidents-table.tsx index eba971c56f..9bd7c68fc2 100644 --- a/apps/web/src/components/alerts/overview/active-incidents-table.tsx +++ b/apps/web/src/components/alerts/overview/active-incidents-table.tsx @@ -9,7 +9,7 @@ import { AlertStatusBadge } from "@/components/alerts/alert-status-badge" import { sortIncidents, TagChips, TagGroupHeaderRow } from "@/components/alerts/overview/shared" import { formatSignalValue } from "@/lib/alerts/form-utils" import { groupByTag as groupItemsByTag } from "@/lib/alerts/tag-grouping" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { EMPTY_VALUE } from "@maple/ui/lib/format" import { Badge } from "@maple/ui/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" @@ -49,7 +49,6 @@ export function ActiveIncidentsTable({ ) const renderRow = (incident: AlertIncidentDocument, key: string) => { - const duration = incident.lastTriggeredAt ? formatRelativeTime(incident.lastTriggeredAt) : "—" const tags = tagsByRuleId.get(incident.ruleId) ?? [] return ( )} - {duration} + + {incident.lastTriggeredAt ? ( + + ) : ( + EMPTY_VALUE + )} + {incident.lastNotifiedAt ? ( diff --git a/apps/web/src/components/alerts/overview/alerts-empty-state.tsx b/apps/web/src/components/alerts/overview/alerts-empty-state.tsx index c55d106602..98a8d6d426 100644 --- a/apps/web/src/components/alerts/overview/alerts-empty-state.tsx +++ b/apps/web/src/components/alerts/overview/alerts-empty-state.tsx @@ -68,7 +68,7 @@ export function AlertsEmptyState({ {isAdmin && (
{needsDestination && ( -
+

Add a destination

@@ -137,7 +137,7 @@ function TemplateTile({ template, serviceName }: { template: AlertTemplate; serv to="/alerts/create" search={{ template: template.id, serviceName }} className={cn( - "group flex flex-col gap-1.5 rounded-lg border bg-card p-3 text-left transition-colors", + "group flex flex-col gap-1.5 rounded-md border bg-card p-3 text-left transition-colors", "hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", tone.hoverBorder, )} @@ -150,9 +150,7 @@ function TemplateTile({ template, serviceName }: { template: AlertTemplate; serv {template.title}
- - {template.summary} - + {template.summary} ) } @@ -165,7 +163,7 @@ function TemplateTile({ template, serviceName }: { template: AlertTemplate; serv */ function QuietMonitor() { return ( -
+
A steady signal running below an unset alert threshold {/* Dashed amber threshold — the line a rule would place. */} diff --git a/apps/web/src/components/alerts/overview/alerts-health-summary.tsx b/apps/web/src/components/alerts/overview/alerts-health-summary.tsx index 6bbb7807be..428284fae9 100644 --- a/apps/web/src/components/alerts/overview/alerts-health-summary.tsx +++ b/apps/web/src/components/alerts/overview/alerts-health-summary.tsx @@ -1,4 +1,5 @@ -import { AlertStatCell, AlertStatShell } from "@/components/alerts/alert-stat-card" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" +import type { Tone } from "@maple/ui/lib/tone" /** Health buckets a rule can land in — mirrors the `status` search param. */ export type AlertsStatusFilter = "firing" | "attention" | "healthy" | "disabled" @@ -15,45 +16,17 @@ const cards: Array<{ label: string hint: string /** Value tone when the bucket is non-empty. */ - tone: string - activeClass: string + tone: Tone }> = [ - { - key: "firing", - label: "Firing", - hint: "open incidents", - tone: "text-severity-error", - activeClass: "ring-severity-error/50 bg-severity-error/[0.04]", - }, - { - key: "attention", - label: "Needs attention", - hint: "errors · stale · unrouted", - tone: "text-severity-warn", - activeClass: "ring-severity-warn/50 bg-severity-warn/[0.05]", - }, - { - key: "healthy", - label: "Healthy", - hint: "evaluating normally", - tone: "text-severity-info", - activeClass: "ring-severity-info/50 bg-severity-info/[0.05]", - }, - { - key: "disabled", - label: "Disabled", - hint: "not evaluating", - tone: "text-muted-foreground", - activeClass: "ring-border bg-muted/40", - }, + { key: "firing", label: "Firing", hint: "open incidents", tone: "crit" }, + { key: "attention", label: "Needs attention", hint: "errors · stale · unrouted", tone: "warn" }, + { key: "healthy", label: "Healthy", hint: "evaluating normally", tone: "info" }, + { key: "disabled", label: "Disabled", hint: "not evaluating", tone: "neutral" }, ] /** - * Row of four clickable health buckets that leads the alerts overview. Follows - * the flat divider-separated strip treatment from `alert-stat-card.tsx` (a - * `bg-border` backplate showing through 1px gaps) rather than a card grid; the - * active filter is marked with an inset ring + tint so it survives the - * `overflow-hidden` container. Clicking the active card clears the filter. + * Row of four clickable health buckets that leads the alerts overview, as a + * `StatRail` of selectable tiles. Clicking the active tile clears the filter. */ export function AlertsHealthSummary({ counts, @@ -65,23 +38,30 @@ export function AlertsHealthSummary({ onActiveChange: (status: AlertsStatusFilter | undefined) => void }) { return ( - + {cards.map((card) => { const count = counts[card.key] const isActive = active === card.key return ( - 0 ? card.tone : "text-muted-foreground/60"} + subline={card.hint} + tone={card.tone} + valueClassName={ + count === 0 + ? "text-muted-foreground/60" + : card.key === "disabled" + ? "text-muted-foreground" + : undefined + } onSelect={() => onActiveChange(isActive ? undefined : card.key)} selected={isActive} - selectedClassName={card.activeClass} /> ) })} - + ) } diff --git a/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx b/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx index da7d8e0b02..70662a9084 100644 --- a/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx +++ b/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx @@ -2,13 +2,13 @@ import { useNavigate, useSearch } from "@tanstack/react-router" import { Exit } from "effect" import * as AsyncResult from "effect/reactivity/AsyncResult" import { memo, useEffect, useMemo, useRef, useState } from "react" -import { toastManager } from "@maple/ui/components/ui/toast" +import { EMPTY_VALUE, pluralize } from "@maple/ui/lib/format" import type { AlertDestinationDocument, AlertRuleDocument } from "@maple/domain/http" import { Unitflow, View } from "@maple/unitflow/react" import { SegmentedSelect } from "@/components/common/segmented-select" -import { AlertStatStrip } from "@/components/alerts/alert-stat-card" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { AlertTagControls } from "@/components/alerts/alert-tag-controls" import { ActiveIncidentsTable } from "@/components/alerts/overview/active-incidents-table" import { AlertsEmptyState } from "@/components/alerts/overview/alerts-empty-state" @@ -19,7 +19,7 @@ import { import { RulesOverviewTable } from "@/components/alerts/overview/rules-overview-table" import { MagnifierIcon } from "@/components/icons" import { FilteredEmpty } from "@/components/common/filtered-empty" -import { getExitErrorMessage } from "@/lib/error-toast" +import { toastExit } from "@/lib/error-toast" import { needsAttention } from "@/lib/alerts/rule-status" import { filterByTags, @@ -150,10 +150,7 @@ const AlertsOverviewContent = memo(function AlertsOverviewContent({ if (toggleState === lastToggleState.current) return lastToggleState.current = toggleState if (AsyncResult.isFailure(toggleState)) { - toastManager.add({ - title: getExitErrorMessage(Exit.failCause(toggleState.cause), "Failed to update rule"), - type: "error", - }) + toastExit(Exit.failCause(toggleState.cause), { error: "Failed to update rule" }) } }, [toggleState]) const isToggling = AsyncResult.isWaiting(toggleState) @@ -247,7 +244,7 @@ const AlertsOverviewContent = memo(function AlertsOverviewContent({ const mttr = useMemo(() => { const resolved = incidents.filter((i) => i.resolvedAt && i.firstTriggeredAt) - if (resolved.length === 0) return "—" + if (resolved.length === 0) return EMPTY_VALUE const avg = resolved.reduce((sum, i) => { return sum + (new Date(i.resolvedAt!).getTime() - new Date(i.firstTriggeredAt).getTime()) @@ -376,17 +373,21 @@ const AlertsOverviewContent = memo(function AlertsOverviewContent({ aria-label="Triggered window" />
- + + + + +
) diff --git a/apps/web/src/components/alerts/overview/rules-overview-table.tsx b/apps/web/src/components/alerts/overview/rules-overview-table.tsx index 990716e503..34a7e4f984 100644 --- a/apps/web/src/components/alerts/overview/rules-overview-table.tsx +++ b/apps/web/src/components/alerts/overview/rules-overview-table.tsx @@ -13,7 +13,8 @@ import { NotifyChannels, SignalBadge, TagChips, TagGroupHeaderRow } from "@/comp import { CircleWarningIcon } from "@/components/icons" import { comparatorLabels, formatSignalValue } from "@/lib/alerts/form-utils" import { worstState } from "@/lib/alerts/rule-status" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" +import { EMPTY_VALUE } from "@maple/ui/lib/format" import { Switch } from "@maple/ui/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" @@ -118,7 +119,7 @@ export function RulesOverviewTable({ + } onClick={(e) => e.stopPropagation()} > @@ -152,11 +153,11 @@ export function RulesOverviewTable({ ) : ( - — + {EMPTY_VALUE} )} - {lastEvaluatedAt ? formatRelativeTime(lastEvaluatedAt) : "—"} + {lastEvaluatedAt ? : EMPTY_VALUE} e.stopPropagation()}> diff --git a/apps/web/src/components/alerts/overview/settings-tab.tsx b/apps/web/src/components/alerts/overview/settings-tab.tsx index c9141484be..c1df7a0c33 100644 --- a/apps/web/src/components/alerts/overview/settings-tab.tsx +++ b/apps/web/src/components/alerts/overview/settings-tab.tsx @@ -26,11 +26,11 @@ import { suggestedDestinationName, type DestinationFormState, } from "@/lib/alerts/form-utils" -import { getExitErrorMessage } from "@/lib/error-toast" +import { toastExit } from "@/lib/error-toast" import { publicError } from "@/lib/error-messages" import { useAlertDestinationsList } from "@/hooks/use-alerts-list" import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { useAsyncAction } from "@/hooks/use-mutation-action" +import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action" import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { Badge } from "@maple/ui/components/ui/badge" @@ -55,8 +55,8 @@ export interface DestinationManager { setForm: Dispatch> isEditing: boolean saving: boolean - testingId: AlertDestinationDocument["id"] | null - deletingId: AlertDestinationDocument["id"] | null + isTesting: (id: AlertDestinationId) => boolean + isDeleting: (id: AlertDestinationId) => boolean /** True when the dialog opened on a provider picked elsewhere, so the provider grid starts folded. */ providerLocked: boolean unlockProvider: () => void @@ -67,7 +67,8 @@ export interface DestinationManager { quickCreate: (form: DestinationFormState) => Promise test: (destination: AlertDestinationDocument) => Promise toggle: (destination: AlertDestinationDocument) => Promise - remove: (destination: AlertDestinationDocument) => Promise + /** Resolves to whether it was deleted, so a confirm dialog can stay open on failure. */ + remove: (destination: AlertDestinationDocument) => Promise } export function useDestinationManager(options?: { @@ -91,8 +92,6 @@ export function useDestinationManager(options?: { const [form, setForm] = useState(defaultDestinationForm()) const [editing, setEditing] = useState(null) const [providerLocked, setProviderLocked] = useState(false) - const [testingId, setTestingId] = useState(null) - const [deletingId, setDeletingId] = useState(null) function openDialog(destination?: AlertDestinationDocument, preset?: DestinationFormState) { setEditing(destination ?? null) @@ -108,13 +107,11 @@ export function useDestinationManager(options?: { payload: buildDestinationCreateParamsV2(named) as never, reactivityKeys: ["alertDestinations"], }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Destination created", type: "success" }) - options?.onCreated?.(result.value.id) - return true + if (!toastExit(result, { success: "Destination created", error: "Failed to save destination" })) { + return false } - toastManager.add({ title: getExitErrorMessage(result, "Failed to save destination"), type: "error" }) - return false + if (Exit.isSuccess(result)) options?.onCreated?.(result.value.id) + return true } const [quickCreate, quickCreating] = useAsyncAction(create) @@ -129,14 +126,8 @@ export function useDestinationManager(options?: { payload: buildDestinationUpdateParamsV2(form) as never, reactivityKeys: ["alertDestinations"], }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Destination updated", type: "success" }) + if (toastExit(result, { success: "Destination updated", error: "Failed to save destination" })) { setDialogOpen(false) - } else { - toastManager.add({ - title: getExitErrorMessage(result, "Failed to save destination"), - type: "error", - }) } } else { if (await create(form)) setDialogOpen(false) @@ -144,26 +135,19 @@ export function useDestinationManager(options?: { }) const saving = quickCreating || savingDialog - async function test(destination: AlertDestinationDocument) { - setTestingId(destination.id) + const testAction = useKeyedAsyncAction(async (id: AlertDestinationId) => { const result = await testDestination({ - params: { id: destination.id }, + params: { id }, reactivityKeys: ["alertDestinations", "alertDeliveryEvents"], }) + if (!toastExit(result, { error: "Failed to send test notification" })) return if (Exit.isSuccess(result)) { - if (result.value.success) { - toastManager.add({ title: result.value.message, type: "success" }) - } else { - toastManager.add({ title: result.value.message, type: "error" }) - } - } else { toastManager.add({ - title: getExitErrorMessage(result, "Failed to send test notification"), - type: "error", + title: result.value.message, + type: result.value.success ? "success" : "error", }) } - setTestingId(null) - } + }) async function toggle(destination: AlertDestinationDocument) { const nextForm = destinationToFormState(destination) @@ -174,38 +158,28 @@ export function useDestinationManager(options?: { payload: buildDestinationUpdateParamsV2(nextForm) as never, reactivityKeys: ["alertDestinations"], }) - if (!Exit.isSuccess(result)) { - toastManager.add({ - title: getExitErrorMessage(result, "Failed to update destination"), - type: "error", - }) - } + toastExit(result, { error: "Failed to update destination" }) } - async function remove(destination: AlertDestinationDocument) { - setDeletingId(destination.id) + const removeAction = useKeyedAsyncAction(async (id: AlertDestinationId) => { const result = await deleteDestination({ - params: { id: destination.id }, + params: { id }, reactivityKeys: ["alertDestinations", "alertRules"], }) if (Exit.isSuccess(result)) { toastManager.add({ title: "Destination deleted", type: "success" }) + return true + } + // A destination still referenced by rules deletes with a 409 + // conflict_error whose message already names the referencing rules. + const v2 = publicError(Option.getOrUndefined(Exit.findErrorOption(result))) + if (v2 !== null && v2.type === "conflict_error") { + toastManager.add({ title: v2.message, type: "error" }) } else { - // A destination still referenced by rules deletes with a 409 - // conflict_error whose message already names the referencing rules. - const failure = Option.getOrUndefined(Exit.findErrorOption(result)) - const v2 = publicError(failure) - if (v2 !== null && v2.type === "conflict_error") { - toastManager.add({ title: v2.message, type: "error" }) - } else { - toastManager.add({ - title: getExitErrorMessage(result, "Failed to delete destination"), - type: "error", - }) - } + toastExit(result, { error: "Failed to delete destination" }) } - setDeletingId(null) - } + return false + }) return { dialogOpen, @@ -217,13 +191,13 @@ export function useDestinationManager(options?: { unlockProvider: () => setProviderLocked(false), quickCreate, saving, - testingId, - deletingId, + isTesting: testAction.isPending, + isDeleting: removeAction.isPending, openDialog, save, - test, + test: (destination) => testAction.run(destination.id), toggle, - remove, + remove: (destination) => removeAction.run(destination.id), } } @@ -302,8 +276,8 @@ export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationMa key={destination.id} destination={destination} isAdmin={isAdmin} - isTesting={manager.testingId === destination.id} - isDeleting={manager.deletingId === destination.id} + isTesting={manager.isTesting(destination.id)} + isDeleting={manager.isDeleting(destination.id)} onToggle={manager.toggle} onTest={manager.test} onEdit={manager.openDialog} @@ -389,7 +363,7 @@ export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationMa {event.attemptNumber > 1 && ( ↻{event.attemptNumber} @@ -423,7 +397,7 @@ export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationMa {event.status === "failed" ? ( - + {event.errorMessage ?? "Delivery failed"} {event.responseCode != null && ( diff --git a/apps/web/src/components/alerts/overview/shared.tsx b/apps/web/src/components/alerts/overview/shared.tsx index 3ab84911f5..d3e0b18510 100644 --- a/apps/web/src/components/alerts/overview/shared.tsx +++ b/apps/web/src/components/alerts/overview/shared.tsx @@ -15,7 +15,7 @@ import { cn } from "@maple/ui/lib/utils" /* -------------------------------------------------------------------------- */ const signalBadgeClass: Record = { - error_rate: "border-destructive/30 text-destructive", + error_rate: "border-severity-error/30 text-severity-error", p95_latency: "border-primary/30 text-primary", p99_latency: "border-primary/30 text-primary", apdex: "border-severity-warn/30 text-severity-warn", diff --git a/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx b/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx index 7c0d8204c1..78444d4e91 100644 --- a/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx +++ b/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx @@ -11,6 +11,7 @@ import type { import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" import { cn } from "@maple/ui/lib/utils" +import { countLabel, pluralize } from "@maple/ui/lib/format" import { CheckIcon, ChevronDownIcon, CircleWarningIcon } from "@/components/icons" import type { AlertRuleStateRow } from "@/lib/collections/alerts" import { @@ -175,8 +176,8 @@ export function RuleDiagnosisPanel({ {showAll ? "Show only issues" : passingCount === hiddenCount - ? `${passingCount} ${passingCount === 1 ? "check" : "checks"} passing` - : `Show ${hiddenCount} more ${hiddenCount === 1 ? "step" : "steps"}`} + ? `${countLabel(passingCount, "check")} passing` + : `Show ${hiddenCount} more ${pluralize(hiddenCount, "step")}`} )}
diff --git a/apps/web/src/components/alerts/rule-live-chart-hero.tsx b/apps/web/src/components/alerts/rule-live-chart-hero.tsx index 7f18ddde65..9d4eb4ef04 100644 --- a/apps/web/src/components/alerts/rule-live-chart-hero.tsx +++ b/apps/web/src/components/alerts/rule-live-chart-hero.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" import type { AlertRulePreviewResponse } from "@maple/domain/http" @@ -115,16 +116,19 @@ export function RuleLiveChartHero({ presetValue={timeRange.presetValue} onChange={onTimeRangeChange} /> - + + + } + > + + Dry run + + + Evaluate the rule once against current data. Nothing is sent. + +
diff --git a/apps/web/src/components/alerts/rule-templates-overlay.tsx b/apps/web/src/components/alerts/rule-templates-overlay.tsx index 5befe7132b..a4340814ef 100644 --- a/apps/web/src/components/alerts/rule-templates-overlay.tsx +++ b/apps/web/src/components/alerts/rule-templates-overlay.tsx @@ -90,7 +90,7 @@ function TemplateTile({ onPick(template)}> } title={template.title} subtitle={template.description} />
- {template.summary} + {template.summary} - +
diff --git a/apps/web/src/components/alerts/signal-and-threshold-section.tsx b/apps/web/src/components/alerts/signal-and-threshold-section.tsx index 006c21da5c..80b4254ccb 100644 --- a/apps/web/src/components/alerts/signal-and-threshold-section.tsx +++ b/apps/web/src/components/alerts/signal-and-threshold-section.tsx @@ -1,4 +1,6 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { RadioGroupPrimitive, RadioPrimitive } from "@maple/ui/components/ui/radio-group" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { useDeferredValue, @@ -21,7 +23,7 @@ import { SettingRow } from "@maple/ui/components/ui/setting-row" import { cn } from "@maple/ui/lib/utils" import { SegmentedSelect } from "@/components/common/segmented-select" -import { SectionHeader } from "@/components/layout/section-header" +import { SectionHeading } from "@/components/common/section-heading" import { QueryPanel } from "@/components/dashboard-builder/config/query-panel" import { RawSqlEditorPanel } from "@/components/dashboard-builder/config/raw-sql-editor-panel" import { @@ -212,7 +214,7 @@ export function SignalAndThresholdSection({ return ( - +
{/* Tier 1: signal kind. Always visible. */} @@ -321,7 +323,9 @@ export function SignalAndThresholdSection({ className="flex w-full items-center justify-between gap-2 text-left text-xs text-muted-foreground hover:text-foreground" aria-expanded={advancedOpen} > - Evaluation timing + + Evaluation timing + {form.windowMinutes}min · {form.consecutiveBreachesRequired}× · renotify{" "} @@ -429,8 +433,9 @@ function BuiltinSignalChips({ }) { return (
-
onChange(opt.value)} > ) })} -
-

+ +

Built-in signals measure entry-point (root) spans only. A service that records failures on child spans and returns success from its entry point — cron jobs and workers typically do — stays healthy here at any threshold. Use Raw SQL for those, or rely on error notifications. @@ -481,8 +486,9 @@ function SeverityToggle({ onChange: (next: AlertSeverity) => void }) { return ( -

@@ -491,9 +497,9 @@ function SeverityToggle({ return ( onChange(opt.value)} > ) })} -
+ ) } /** One option in a chip radiogroup; the selected chip takes its option's brand colour. */ -function RadioChip({ +function RadioChip({ + value, selected, selectedClass, - onSelect, children, }: { + value: V selected: boolean selectedClass: string - onSelect: () => void children: ReactNode }) { return ( - + ) } diff --git a/apps/web/src/components/analytics/ai/ai-sections.tsx b/apps/web/src/components/analytics/ai/ai-sections.tsx index b6237fa561..36d9df3e60 100644 --- a/apps/web/src/components/analytics/ai/ai-sections.tsx +++ b/apps/web/src/components/analytics/ai/ai-sections.tsx @@ -15,7 +15,7 @@ import type { AiContentFormat, AiCrawlPurpose, AiProduct } from "@maple/domain/a import { ColumnHead, DataTable } from "@/components/common/data-table" import { shareBar } from "../../infra/primitives/share-bar" -import { BarSpark } from "@/components/common/stat-rail" +import { MiniBars } from "@maple/ui/components/ui/mini-bars" import { SPARK_COLOR } from "../../infra/severity-tokens" import { FileCodeIcon, @@ -27,6 +27,8 @@ import { } from "@/components/icons" import type { WebAnalyticsAiCrawler, WebAnalyticsAiCrawlerFormat } from "@/api/warehouse/web-analytics" import { AiProductIcon } from "./ai-product-icon" +import { StatFigure } from "@/components/common/stat-rail" +import { RelativeTime } from "@/components/common/relative-time" import { Panel, PanelHeader, PanelTitle } from "@maple/ui/components/ui/panel" import { productForCrawler, @@ -75,14 +77,12 @@ export function AiPanel({ divided={false} className="items-start pt-3 pb-2.5" action={ - aside ? ( -
{aside}
- ) : undefined + aside ?
{aside}
: undefined } >
{title} - {hint ?
{hint}
: null} + {hint ?
{hint}
: null}
{children} @@ -92,7 +92,7 @@ export function AiPanel({
) })} @@ -420,7 +421,7 @@ function PurposeBadge({ purpose }: { purpose: AiCrawlPurpose }) { {purpose} @@ -452,7 +453,7 @@ export function AiContentFormats({ formats }: { formats: ReadonlyArray {other && other.requests > 0 ? ( -
+
{plural(other.requests, "other fetch", "other fetches")} (images, scripts, feeds and other files) @@ -477,25 +478,23 @@ function FormatCard({ const pages = row?.pages ?? 0 return ( -
+
-
{label}
-
{description}
+
{label}
+
{description}
-
- - {formatNumber(requests)} - - - {requests === 1 ? "fetch" : "fetches"} - -
-
+ +
{requests === 0 ? ( "Not fetched yet" ) : pages === 0 ? ( @@ -509,7 +508,7 @@ function FormatCard({ )}
-
+
) } diff --git a/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx b/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx index 60e86f71b5..e90cc5a071 100644 --- a/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx +++ b/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx @@ -1,5 +1,6 @@ import { Result } from "@/lib/effect-atom" -import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatNumber, formatPercent } from "@maple/ui/lib/format" +import { SectionHeading } from "@/components/common/section-heading" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -164,14 +165,10 @@ export function AnalyticsAiTab({
-
-

What AI crawlers read

- {visitorFiltered ? ( - - Crawls follow the site and page filters only - - ) : null} -
+ } @@ -225,7 +222,7 @@ function AiKpis({ } subline={ @@ -238,19 +235,19 @@ function AiKpis({ /> 0 && failed / fetches > 0.5 ? "warn" : "neutral"} subline={ crawlers diff --git a/apps/web/src/components/analytics/analytics-bot-notice.tsx b/apps/web/src/components/analytics/analytics-bot-notice.tsx index f7b1a4118e..d4815d737d 100644 --- a/apps/web/src/components/analytics/analytics-bot-notice.tsx +++ b/apps/web/src/components/analytics/analytics-bot-notice.tsx @@ -1,4 +1,5 @@ import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import { Panel } from "@maple/ui/components/ui/panel" import { FaceRobotIcon } from "@/components/icons" import type { WebAnalyticsSummary } from "@/api/warehouse/web-analytics" @@ -43,7 +44,7 @@ export function AnalyticsBotNotice({ if (mix.botShare === null || mix.botShare < NOTICE_THRESHOLD) return null return ( -
+ {formatPercent(mix.botShare)} of recorded sessions are bots @@ -52,7 +53,7 @@ export function AnalyticsBotNotice({ {formatNumber(mix.botSessions)} of {formatNumber(mix.sessions)} sessions came from crawlers or automated browsers, and are {excluded(traffic)}. Change that under Traffic in the sidebar. -
+ ) } diff --git a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx index a9cbaa5aee..16da795afe 100644 --- a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx +++ b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx @@ -2,6 +2,9 @@ import { useDeferredValue, useMemo, useState, type ReactNode } from "react" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { cn } from "@maple/ui/lib/utils" +import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" +import { Panel } from "@maple/ui/components/ui/panel" import { formatNumber, formatPercent } from "@maple/ui/lib/format" import { Dialog, @@ -224,7 +227,7 @@ export function AnalyticsBreakdownPanel({ // is the only honest measure. The expand dialog portals out of this card — // `/panel` classes are card-only (`!ranked`); the dialog keeps viewport // queries, which are truthful there via its `max-w-[92vw]`. -
+
{tabs} {dimension.rows.length === 0 ? ( -
+ {dimension.emptyMessage ?? `No ${dimension.noun} data in the selected window.`} -
+ ) : ( <>
+
) } @@ -328,7 +331,7 @@ function DimensionTabs({ {dim.tab} @@ -360,7 +363,7 @@ function FilterInput({ // from searching it, and the card's own input must never steal the page. autoFocus={autoFocus} className={cn( - "h-6 rounded-sm border bg-background px-2 text-[11px] placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "h-6 rounded-sm border bg-background px-2 text-2xs placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", className, )} /> @@ -498,17 +501,14 @@ function BreakdownTable({ // it reads as a hover in both. The text colour is the one token // guaranteed to contrast with whatever the row sits on. "hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:outline-none", - // Selection is a rule down the leading edge rather than a wash: - // a wash in the primary hue is precisely what the fill already - // is, and at 47% share the two were the same picture. `shadow` - // rather than a border so nothing shifts by 3px. - isSelected && "bg-primary/10 shadow-[inset_3px_0_0_var(--primary)]", + // Selection is the shared leading-edge lane, not a primary wash (the fill already is one). + rowSelectedClass(isSelected), )} > {/* Position in the order on screen, so it stays true when a different column takes over the sort. */} {ranked ? ( - + {index + 1} ) : null} @@ -516,7 +516,7 @@ function BreakdownTable({ {icon ?? (hasIcons ? : null)} @@ -538,7 +538,7 @@ function BreakdownTable({ ) : null} diff --git a/apps/web/src/components/analytics/analytics-filter-sidebar.tsx b/apps/web/src/components/analytics/analytics-filter-sidebar.tsx index 0b6ad380ff..85ec1b7953 100644 --- a/apps/web/src/components/analytics/analytics-filter-sidebar.tsx +++ b/apps/web/src/components/analytics/analytics-filter-sidebar.tsx @@ -1,7 +1,6 @@ import { Result } from "@/lib/effect-atom" import { Separator } from "@maple/ui/components/ui/separator" -import { cn } from "@maple/ui/lib/utils" import type { WebAnalyticsBreakdowns, WebAnalyticsEvent } from "@/api/warehouse/web-analytics" import type { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" @@ -11,7 +10,7 @@ import { SingleCheckboxFilter, type FilterOption, } from "@/components/filters/filter-section" -import { FILTER_SECTION_LABEL } from "@maple/ui/components/filters/filter-styles" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { FilterSidebarBody, FilterSidebarError, @@ -140,9 +139,9 @@ function AnalyticsFilterSidebarView({ whichever of these is picked. Humans alone is checked on load — see DEFAULT_TRAFFIC. */}
-

+ {FILTER_SECTION_LABEL_TEXT.traffic} -

+ {/* Additive, not exclusive: both ticked is "all", which is what two checkboxes look like they should do. `toggleTraffic` owns the mapping, including that unticking the last one widens rather than @@ -219,9 +218,9 @@ function AnalyticsFilterSidebarView({ "zero new visitors" rather than as "not counted". Unchecking both means all visitors. */}
-

+ {FILTER_SECTION_LABEL_TEXT.visitorType} -

+ 0 const count = identified ? live.visitors : live.sessions const minutes = Math.max(1, Math.round(live.windowSeconds / 60)) - const noun = identified - ? count === 1 - ? "visitor" - : "visitors" - : count === 1 - ? "session" - : "sessions" + const noun = pluralize(count, identified ? "visitor" : "session") return ( {count > 0 ? : } diff --git a/apps/web/src/components/analytics/analytics-metric-strip.tsx b/apps/web/src/components/analytics/analytics-metric-strip.tsx index 5c772b87a5..16283f989f 100644 --- a/apps/web/src/components/analytics/analytics-metric-strip.tsx +++ b/apps/web/src/components/analytics/analytics-metric-strip.tsx @@ -1,8 +1,7 @@ -import { cn } from "@maple/ui/lib/utils" -import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Delta } from "@maple/ui/components/ui/delta" +import { EMPTY_VALUE } from "@maple/ui/lib/format" -import { StatRailItem } from "@/components/common/stat-rail" +import { StatRailItem, StatRailItemSkeleton } from "@/components/common/stat-rail" import { ANALYTICS_METRICS, isMetricAvailable, @@ -96,7 +95,7 @@ function MetricTile({ return ( point.value) : undefined} delta={delta === null ? undefined : } subline={ @@ -117,16 +116,9 @@ function MetricTile({ export function AnalyticsMetricStripLoading() { return ( -
+
{ANALYTICS_METRICS.map((metric) => ( -
- -
- - -
- -
+ ))}
) diff --git a/apps/web/src/components/analytics/analytics-traffic-chart.tsx b/apps/web/src/components/analytics/analytics-traffic-chart.tsx index aaf113d394..c1cd7bda37 100644 --- a/apps/web/src/components/analytics/analytics-traffic-chart.tsx +++ b/apps/web/src/components/analytics/analytics-traffic-chart.tsx @@ -1,32 +1,31 @@ import { useMemo } from "react" import { areaY, d3Curve, defineChart, lineY } from "@tanstack/charts" import { scaleLinear } from "@tanstack/charts-scales/linear" -import { scalePoint } from "@tanstack/charts-scales/point" import { curveMonotoneX } from "d3-shape" -import { zonedDateParts } from "@maple/query-engine/datetime" -import { toEpochMs } from "@maple/ui/lib/time-format" import { - PlotFrame, - PlotTooltipBody, - createTooltipFocusStore, - cursorTooltip, + CursorPlot, DASHED_Y_GRID, + bucketDate, focusCrosshair, focusDot, - resolvePlotColor, + makeBucketAxis, useChartId, - usePlotChromeColors, + useCursorPlot, verticalGradient, - type PlotTooltipSeries, + type CursorPlotSeries, } from "@maple/ui/components/plot" import { useMediaQuery } from "@maple/ui/hooks/use-media-query" -import { useTheme } from "@maple/ui/hooks/use-theme" import { linkedCursorChartProps } from "@/hooks/use-linked-cursor" import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { CHART_EMPTY_MESSAGE, isoToLabel, makeBucketLabeler } from "../infra/chart-utils" -import { CHART_HEIGHT, ChartCard, ChartCardMessage } from "../infra/primitives/chart-card" +import { + CHART_EMPTY_MESSAGE, + CHART_HEIGHT, + ChartCard, + ChartCardMessage, +} from "@/components/common/chart-card" +import { SeriesLegend } from "@/components/common/series-legend" import type { AnalyticsMetricDescriptor, AnalyticsMetricSource } from "./metrics" // The page's one accent, same as the KPI sparklines (`SPARK_COLOR.neutral`) and @@ -34,15 +33,14 @@ import type { AnalyticsMetricDescriptor, AnalyticsMetricSource } from "./metrics // amber only in the dark theme and a blue in the light one — the chart would // have disagreed with the tile that selected it, at half of all page loads. const PRIMARY_TOKEN = "--primary" -const PRIMARY_FALLBACK = "#6366f1" /** The designated second-series token: cool against the accent in both themes. */ const COMPANION_TOKEN = "--chart-2" -const COMPANION_FALLBACK = "#22d3ee" /** Series keys. Fixed, so the tooltip can map a row back to its descriptor. */ -const PRIMARY = "primary" -const COMPANION = "companion" +type SeriesKey = "primary" | "companion" +const PRIMARY: SeriesKey = "primary" +const COMPANION: SeriesKey = "companion" interface AnalyticsTrafficChartProps { /** The metric selected in the KPI strip. Supplies the series and its formatting. */ @@ -58,10 +56,10 @@ interface AnalyticsTrafficChartProps { /** One bucket, carrying whichever of the two series reported there. */ interface TrafficPoint { - /** The bucket's ISO timestamp — the x value, so ticks can be chosen by calendar day. */ + /** The bucket's ISO timestamp. */ bucket: string - /** The tooltip heading: time of day, dated once the window crosses 24h. */ - label: string + /** The bucket as an instant, the x value. */ + date: Date primary?: number companion?: number } @@ -88,21 +86,9 @@ interface TrafficPoint { */ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: AnalyticsTrafficChartProps) { const gradientPrefix = useChartId("traffic") - const chromeColors = usePlotChromeColors() - const focusStore = useMemo(() => createTooltipFocusStore(), []) - // Tokens resolved to literals: canvas cannot read `var()`, and `useTheme` is - // the invalidation key that repaints them when the theme flips. - const { theme } = useTheme() const { effectiveTimezone } = useTimezonePreference() - const colors = useMemo( - () => ({ - primary: resolvePlotColor(PRIMARY_TOKEN, PRIMARY_FALLBACK), - companion: resolvePlotColor(COMPANION_TOKEN, COMPANION_FALLBACK), - }), - [theme], - ) - const { data, dayTicks, totals } = useMemo(() => { + const { data, totals } = useMemo(() => { const primaryPoints = metric.series(source) const companionPoints = companion?.series(source) ?? [] @@ -140,75 +126,58 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana zeroFill("primary", metric, primaryPoints) zeroFill("companion", companion, companionPoints) - const label = makeBucketLabeler(buckets, effectiveTimezone) - // The first bucket of each calendar day in the selected zone. Over a multi-day window the - // axis ticks there and nowhere else — one dated label per day reads; a - // thinned run of "Aug 14, 9:30pm" labels does not. Within a single day the - // list has one entry and the axis keeps its time-of-day ticks instead. - const dayTicks: string[] = [] - let lastDay = "" - for (const bucket of buckets) { - const parts = zonedDateParts(toEpochMs(bucket), effectiveTimezone) - const day = `${parts.year}-${parts.month}-${parts.day}` - if (day !== lastDay) { - dayTicks.push(bucket) - lastDay = day - } - } - // A "last 7 days" window opens mid-evening, so its first day is a sliver a - // few pixels wide — its label collides with the next day's and thinning - // drops the FULL day. Below half a day of coverage the sliver goes untitled. - if (dayTicks.length > 1) { - const leadSpan = toEpochMs(dayTicks[1]!) - toEpochMs(dayTicks[0]!) - if (leadSpan < 12 * 60 * 60 * 1000) dayTicks.shift() - } return { - data: buckets.map((bucket) => ({ bucket, label: label(bucket), ...byBucket.get(bucket)! })), - dayTicks, + data: buckets.map((bucket) => ({ bucket, date: bucketDate(bucket), ...byBucket.get(bucket)! })), totals: { primary: primaryPoints.reduce((sum, point) => sum + point.value, 0), companion: companionPoints.reduce((sum, point) => sum + point.value, 0), }, } - }, [metric, companion, source, effectiveTimezone]) - - // Selected metric first — this order is the legend's, where it should lead. - const series = [ - { key: PRIMARY, descriptor: metric, color: colors.primary, total: totals.primary }, - ...(companion - ? [ - { - key: COMPANION, - descriptor: companion, - color: colors.companion, - total: totals.companion, - }, - ] - : []), - ] + }, [metric, companion, source]) - // Painting order is by magnitude, not by selection: the bigger area is laid - // down first so the smaller one sits on top of it. Both areas are filled, so - // drawing page views over visitors would bury the visitors series completely — - // and which of the pair is selected must not decide whether you can see the - // other one. - const painted = [...series].sort((a, b) => b.total - a.total) + // Identical ranges tick identically to every other bucketed chart: round + // clock boundaries, dated once the window crosses a day. + const axis = useMemo( + () => + makeBucketAxis( + data.map((point) => point.bucket), + effectiveTimezone, + ), + [data, effectiveTimezone], + ) - const tooltipSeries = useMemo[]>( + // Selected metric first, this order is the legend's, where it should lead. + const series = useMemo( + () => [ + { key: PRIMARY, descriptor: metric, color: PRIMARY_TOKEN, total: totals.primary }, + ...(companion + ? [{ key: COMPANION, descriptor: companion, color: COMPANION_TOKEN, total: totals.companion }] + : []), + ], + [metric, companion, totals], + ) + const cursorSeries = useMemo[]>( () => series.map((entry) => ({ + key: entry.key, label: entry.descriptor.label, color: entry.color, value: (point: TrafficPoint) => { - const value = point[entry.key as "primary" | "companion"] + const value = point[entry.key] return typeof value === "number" ? value : null }, format: (value: number) => entry.descriptor.format(value), })), - // `series` is rebuilt every render (it is a plain array, not memoised), so - // this depends on the inputs behind it rather than on its identity. - [metric, companion, colors, totals], + [series], ) + const plot = useCursorPlot(cursorSeries) + + // Painting order is by magnitude, not by selection: the bigger area is laid + // down first so the smaller one sits on top of it. Both areas are filled, so + // drawing page views over visitors would bury the visitors series completely, + // and which of the pair is selected must not decide whether you can see the + // other one. + const painted = useMemo(() => [...series].sort((a, b) => b.total - a.total), [series]) // On a phone the 52px axis gutter is ~15% of the plot; the compact tick labels // ("1.2k", "45s") fit in 36. Viewport rather than container is honest here — @@ -216,20 +185,19 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana const narrow = useMediaQuery("max-sm") const definition = useMemo(() => { - const at = (point: TrafficPoint) => point.bucket - const perDay = dayTicks.length > 1 + const at = (point: TrafficPoint) => point.date // A bucket one table has and the other doesn't is a gap, not a zero — // joining across it would draw a dip that never happened, which is what // `connectNulls={false}` said. - const valueOf = (key: string) => (point: TrafficPoint) => { - const value = point[key as "primary" | "companion"] + const valueOf = (key: SeriesKey) => (point: TrafficPoint) => { + const value = point[key] return typeof value === "number" ? value : null } const curve = d3Curve(curveMonotoneX) return defineChart({ gradients: painted.map((entry) => - verticalGradient(`${gradientPrefix}-${entry.key}`, entry.color, 0.35, 0.02), + verticalGradient(`${gradientPrefix}-${entry.key}`, plot.color(entry.key), 0.35, 0.02), ), marks: [ // Painting order is by magnitude — see `painted`. Each series is a @@ -249,39 +217,18 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana id: entry.key, x: at, y: valueOf(entry.key), - stroke: entry.color, + stroke: plot.color(entry.key), strokeWidth: 1.5, curve, }), ]), - ...painted.map((entry) => focusDot(data, at, valueOf(entry.key), entry.color, chromeColors)), - focusCrosshair(chromeColors), + ...painted.map((entry) => + focusDot(data, at, valueOf(entry.key), plot.color(entry.key), plot.chrome), + ), + focusCrosshair(plot.chrome), ], scales: { - x: { - scale: scalePoint, - axis: { - line: false, - ticks: perDay - ? { - size: 0, - padding: 8, - values: dayTicks, - format: (bucket: string) => - new Date(toEpochMs(bucket)).toLocaleDateString("en-US", { - timeZone: effectiveTimezone, - month: "short", - day: "numeric", - }), - } - : { - size: 0, - padding: 8, - format: (iso: string) => isoToLabel(iso, effectiveTimezone), - }, - tickLabels: { thin: { minGap: 12 } }, - }, - }, + x: axis.x, y: { grid: DASHED_Y_GRID, scale: scaleLinear, @@ -305,28 +252,21 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana margin: { left: narrow ? 36 : 52, right: 8, top: 4 }, focus: "group-x", focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }) - }, [data, dayTicks, painted, gradientPrefix, chromeColors, metric, focusStore, narrow, effectiveTimezone]) + }, [data, axis, painted, gradientPrefix, plot, metric, narrow]) // Only when there are two series to tell apart — a lone series is already // named by the card title, and a legend restating it is one accessory too many. const legend = companion ? ( - <> - {series.map((entry) => ( - - - {entry.descriptor.label} - - {entry.descriptor.format(entry.total)} - - - ))} - + ({ + key: entry.key, + label: entry.descriptor.label, + color: `var(${entry.color})`, + value: entry.descriptor.format(entry.total), + }))} + /> ) : undefined return ( @@ -345,18 +285,13 @@ export function AnalyticsTrafficChart({ metric, companion, source, syncId }: Ana // this names the chart within that group. {...linkedCursorChartProps(syncId != null ? `analytics-${metric.label}` : undefined)} > - axis.heading(point.bucket)} ariaLabel={metric.label} className="h-full w-full" - renderTooltipBody={({ points }) => ( - point.label} - /> - )} />
)} diff --git a/apps/web/src/components/analytics/metrics.ts b/apps/web/src/components/analytics/metrics.ts index ccd8907c51..faa61616ef 100644 --- a/apps/web/src/components/analytics/metrics.ts +++ b/apps/web/src/components/analytics/metrics.ts @@ -6,7 +6,7 @@ // chart renders the selected one's series — so a tile and the chart it drives // can never disagree about what they are showing. -import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatNumber, formatPercent } from "@maple/ui/lib/format" import type { WebAnalyticsPageviewsPoint, @@ -107,14 +107,14 @@ export interface AnalyticsMetricDescriptor { * a session is read in minutes and seconds. */ export function formatSessionDuration(ms: number): string { - if (ms <= 0) return "—" + if (ms <= 0) return EMPTY_VALUE const seconds = Math.round(ms / 1000) if (seconds < 60) return `${seconds}s` const minutes = Math.floor(seconds / 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` } -const formatRatio = (value: number): string => (value > 0 ? value.toFixed(1) : "—") +const formatRatio = (value: number): string => (value > 0 ? value.toFixed(1) : EMPTY_VALUE) const sum = (rows: ReadonlyArray, pick: (row: T) => number): number => rows.reduce((total, row) => total + pick(row), 0) diff --git a/apps/web/src/components/analytics/product-event-trace-samples.tsx b/apps/web/src/components/analytics/product-event-trace-samples.tsx index 235f5efafe..3f84b3e7d9 100644 --- a/apps/web/src/components/analytics/product-event-trace-samples.tsx +++ b/apps/web/src/components/analytics/product-event-trace-samples.tsx @@ -5,7 +5,9 @@ import { formatTimestampInTimezone } from "@/lib/timezone-format" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { ChartBarTrendUpIcon } from "@/components/icons" import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { ListRow } from "@maple/ui/components/ui/list-row" import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" +import { ErrorState } from "@/components/common/error-state" /** * Recent traces behind one product event. Renders nothing when empty (browser @@ -40,44 +42,56 @@ export function ProductEventTraceSamples({ one trace can fire the event from several spans. */} {response.data.map((sample, index) => (
  • - - - {sample.serviceName === "" ? null : {sample.serviceName}} - {sample.userId || sample.visitorId ? ( + + } + leading={} + title={ + + {sample.serviceName} + {sample.userId || sample.visitorId ? ( + + {sample.serviceName === "" ? "" : " "}·{" "} + {sample.userId || sample.visitorId} + + ) : null} + + } + trailing={ - · {sample.userId || sample.visitorId} + {formatTimestampInTimezone(sample.timestamp, { + timeZone: effectiveTimezone, + })} - ) : null} - - {formatTimestampInTimezone(sample.timestamp, { - timeZone: effectiveTimezone, - })} - - + } + />
  • ))} ) }) - .onError(() => ( + .onError((error) => (

    Traces behind “{eventName}”

    -

    - Could not load traces for this event. This is a query failure, not an empty result — - reload to try again. -

    +
    )) .orElse(() => null) diff --git a/apps/web/src/components/anomalies/anomaly-format.test.ts b/apps/web/src/components/anomalies/anomaly-format.test.ts index b001920e19..ff97e0042d 100644 --- a/apps/web/src/components/anomalies/anomaly-format.test.ts +++ b/apps/web/src/components/anomalies/anomaly-format.test.ts @@ -109,7 +109,7 @@ describe("deviation", () => { describe("formatSignalValue", () => { it("formats error rate as a percent", () => { expect(formatSignalValue("error_rate", 0.123)).toBe("12.3%") - expect(formatSignalValue("error_rate", 0)).toBe("0.0%") + expect(formatSignalValue("error_rate", 0)).toBe("0%") }) it("formats latency in ms below one second and switches to seconds at 1000ms", () => { diff --git a/apps/web/src/components/anomalies/anomaly-format.ts b/apps/web/src/components/anomalies/anomaly-format.ts index d78964f7b6..629a621cb3 100644 --- a/apps/web/src/components/anomalies/anomaly-format.ts +++ b/apps/web/src/components/anomalies/anomaly-format.ts @@ -4,6 +4,7 @@ import type { AnomalySignalType, AnomalyTriageStatus, } from "@maple/domain/http" +import { formatErrorRate } from "@maple/ui/lib/format" import { TONE_FILL, TONE_SOFT, TONE_TEXT, type Tone } from "@maple/ui/lib/tone" import { BoltIcon, ChartLineIcon, CircleWarningIcon, FileIcon, PulseIcon } from "@/components/icons" @@ -59,7 +60,7 @@ export const SIGNAL_ICON: Record = { export function formatSignalValue(signalType: AnomalySignalType, value: number): string { switch (signalType) { case "error_rate": - return `${(value * 100).toFixed(1)}%` + return formatErrorRate(value) case "latency_p95": return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${value.toFixed(0)}ms` case "throughput": @@ -174,4 +175,4 @@ export const TRIAGE_STATUS_CHIP: Record /** Keeps the 20px / 11px meta-pill size on anomaly rows and cards (Badge `size="sm"` shrinks on sm+). */ -export const META_CHIP_CLASS = "h-5 gap-1.5 font-normal text-[11px] sm:h-5 sm:text-[11px]" +export const META_CHIP_CLASS = "h-5 gap-1.5 font-normal text-2xs sm:h-5 sm:text-2xs" diff --git a/apps/web/src/components/anomalies/anomaly-group.tsx b/apps/web/src/components/anomalies/anomaly-group.tsx index eee68cea72..fb2fff1af1 100644 --- a/apps/web/src/components/anomalies/anomaly-group.tsx +++ b/apps/web/src/components/anomalies/anomaly-group.tsx @@ -93,7 +93,7 @@ export function AnomalyGroup({ {clusters.map((cluster) => (
    {cluster.incidents.length >= 2 ? ( -
    +
    {cluster.serviceName} diff --git a/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx b/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx index 0fa13fb632..627217ae4b 100644 --- a/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx +++ b/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx @@ -16,6 +16,7 @@ import { formatNumber } from "@maple/ui/lib/format" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { errorIssueFromV2 } from "@/lib/services/error-issues" import { ServiceDot } from "@maple/ui/components/service-dot" +import { IconButton } from "@maple/ui/components/ui/icon-button" export function AnomalyLinkedIssueCard({ incident, @@ -40,7 +41,7 @@ export function AnomalyLinkedIssueCard({ : "Escalate this anomaly by linking it to an existing error issue."}

    {incident.fingerprintHash !== null ? ( - + fingerprint {incident.fingerprintHash} ) : null} @@ -173,18 +174,12 @@ function IssueCardActions({ Open issue - - +
    ) } diff --git a/apps/web/src/components/anomalies/anomaly-live-indicator.tsx b/apps/web/src/components/anomalies/anomaly-live-indicator.tsx index 525d7dd5e1..1a6f75cba4 100644 --- a/apps/web/src/components/anomalies/anomaly-live-indicator.tsx +++ b/apps/web/src/components/anomalies/anomaly-live-indicator.tsx @@ -1,4 +1,5 @@ import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { TONE_BORDER, TONE_SOFT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" @@ -10,23 +11,31 @@ export function AnomalyLiveIndicator({ onToggle: (live: boolean) => void }) { return ( - + + onToggle(!live)} + className={cn( + "inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors", + live + ? cn(TONE_BORDER.ok, TONE_SOFT.ok) + : "border-border/70 text-muted-foreground hover:text-foreground", + )} + /> + } + > + + {live ? "Live" : "Paused"} + + + {live + ? "Auto-refreshing every 15s. Click to pause." + : "Paused. Click to resume auto-refresh."} + + ) } diff --git a/apps/web/src/components/anomalies/anomaly-row.tsx b/apps/web/src/components/anomalies/anomaly-row.tsx index cdef8ab14c..36444ed43e 100644 --- a/apps/web/src/components/anomalies/anomaly-row.tsx +++ b/apps/web/src/components/anomalies/anomaly-row.tsx @@ -19,6 +19,7 @@ import { TRIAGE_STATUS_CHIP, } from "./anomaly-format" import { ServiceDot } from "@maple/ui/components/service-dot" +import { EMPTY_VALUE } from "@maple/ui/lib/format" export interface AnomalyRowProps { incident: AnomalyIncidentDocument @@ -105,7 +106,7 @@ export function AnomalyRow({ incident, focused = false, onFocus, variant = "defa ) : null} - {incident.deploymentEnv || "—"} + {incident.deploymentEnv || EMPTY_VALUE} diff --git a/apps/web/src/components/anomalies/anomaly-sidebar.tsx b/apps/web/src/components/anomalies/anomaly-sidebar.tsx index 6bc1eee154..2f101ba79d 100644 --- a/apps/web/src/components/anomalies/anomaly-sidebar.tsx +++ b/apps/web/src/components/anomalies/anomaly-sidebar.tsx @@ -9,7 +9,6 @@ import { cn } from "@maple/ui/lib/utils" import { RelativeTime } from "@/components/common/relative-time" import { LinkIcon } from "@/components/icons" import { shortIssueId } from "@/components/errors/issue-id" -import { formatRelativeTime } from "@maple/ui/lib/time-format" import { deviation, formatSignalValue, @@ -22,6 +21,7 @@ import { } from "./anomaly-format" import { ServiceDot } from "@maple/ui/components/service-dot" import { DetailRail } from "@maple/ui/components/detail-rail" +import { EMPTY_VALUE, formatNumber } from "@maple/ui/lib/format" export function AnomalySidebar({ incident, @@ -78,11 +78,15 @@ export function AnomalySidebar({ - {isStale - ? `stale · last seen ${formatRelativeTime(incident.lastTriggeredAt)}` - : isOpen - ? "open" - : "resolved"} + {isStale ? ( + <> + stale · + + ) : isOpen ? ( + "open" + ) : ( + "resolved" + )} @@ -105,7 +109,7 @@ export function AnomalySidebar({ - {incident.deploymentEnv || "—"} + {incident.deploymentEnv || EMPTY_VALUE} @@ -159,7 +163,7 @@ export function AnomalySidebar({ - {incident.lastSampleCount.toLocaleString()} + {formatNumber(incident.lastSampleCount)} diff --git a/apps/web/src/components/anomalies/anomaly-timeseries-chart.tsx b/apps/web/src/components/anomalies/anomaly-timeseries-chart.tsx index 1a1b110752..a3fc5a16a1 100644 --- a/apps/web/src/components/anomalies/anomaly-timeseries-chart.tsx +++ b/apps/web/src/components/anomalies/anomaly-timeseries-chart.tsx @@ -3,35 +3,33 @@ import * as React from "react" import { areaY, d3Curve, defineChart, lineY, rect } from "@tanstack/charts" import { decorative } from "@tanstack/charts/mark/decorative" import { scaleLinear } from "@tanstack/charts-scales/linear" -import { scalePoint } from "@tanstack/charts-scales/point" import { curveMonotoneX } from "d3-shape" import type { AnomalyIncidentDocument, AnomalyIncidentTimeseriesResponse } from "@maple/domain/http" import { - PlotFrame, - PlotTooltipBody, - createTooltipFocusStore, - cursorTooltip, + CursorPlot, DASHED_Y_GRID, + bucketDate, focusCrosshair, focusDot, - resolvePlotColor, + makeBucketAxis, thresholdRules, useChartId, - usePlotChromeColors, + useCursorPlot, verticalGradient, - type PlotTooltipSeries, + type CursorPlotSeries, } from "@maple/ui/components/plot" -import { useTheme } from "@maple/ui/hooks/use-theme" -import { formatBucketLabel } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" +import { SeriesLegend } from "@/components/common/series-legend" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" + import { formatSignalValue } from "./anomaly-format" -/** Tokens plus the literal each falls back to — canvas cannot read `var()`. */ +/** The observed line's colour, by incident severity. */ const SEVERITY_STROKE = { - critical: ["--destructive", "#ef4444"], - warning: ["--chart-4", "#fbbf24"], -} satisfies Record<"critical" | "warning", readonly [string, string]> + critical: "--severity-error", + warning: "--severity-warn", +} satisfies Record<"critical" | "warning", string> /** One bucket of the observed signal. */ interface SignalPoint { @@ -49,17 +47,26 @@ export function AnomalyTimeseriesChart({ className?: string }) { const { signalType, baselineMedian, thresholdValue } = timeseries - const { theme } = useTheme() - // `theme` is in the deps but not in the body on purpose: `resolvePlotColor` - // reads computed style, so the colour has to be re-resolved when the theme - // flips even though nothing here references it. - const stroke = React.useMemo(() => { - const [token, fallback] = SEVERITY_STROKE[incident.severity] - return resolvePlotColor(token, fallback) - }, [incident.severity, theme]) - const chromeColors = usePlotChromeColors() const gradientId = useChartId("anomaly-observed") - const focusStore = React.useMemo(() => createTooltipFocusStore(), []) + + const valueFormatter = React.useCallback( + (value: number) => formatSignalValue(signalType, Number.isFinite(value) ? value : 0), + [signalType], + ) + const series = React.useMemo[]>( + () => [ + { + key: "observed", + label: "Observed", + color: SEVERITY_STROKE[incident.severity], + value: (point: SignalPoint) => point.value, + format: valueFormatter, + }, + ], + [incident.severity, valueFormatter], + ) + const plot = useCursorPlot(series) + const stroke = plot.color("observed") const data = React.useMemo( () => @@ -69,15 +76,18 @@ export function AnomalyTimeseriesChart({ [timeseries.buckets], ) - const axisContext = React.useMemo(() => { - if (data.length < 2) return { rangeMs: 0, bucketSeconds: timeseries.bucketSeconds } - const first = Date.parse(data[0]!.bucket) - const last = Date.parse(data[data.length - 1]!.bucket) - return { rangeMs: last - first, bucketSeconds: timeseries.bucketSeconds } - }, [data, timeseries.bucketSeconds]) + const { effectiveTimezone } = useTimezonePreference() + const axis = React.useMemo( + () => + makeBucketAxis( + data.map((point) => point.bucket), + effectiveTimezone, + ), + [data, effectiveTimezone], + ) - // Snap the incident window to actual bucket values so the category axis - // can place the shading. + // Snap the incident window to actual bucket values so the shading lines up + // with the plotted points. const window = React.useMemo(() => { if (data.length === 0) return null const startMs = Date.parse(incident.firstTriggeredAt) @@ -92,7 +102,7 @@ export function AnomalyTimeseriesChart({ // Window starts after the last bucket (fresh incident): pin to the edge. if (x1 === null) x1 = data[data.length - 1]!.bucket if (x2 === null || Date.parse(x2) < Date.parse(x1)) x2 = x1 - return { x1, x2 } + return { x1: bucketDate(x1), x2: bucketDate(x2) } }, [data, incident.firstTriggeredAt, incident.resolvedAt]) // Pad the y-domain so both reference lines stay visible. Also the band's @@ -103,25 +113,8 @@ export function AnomalyTimeseriesChart({ return [0, maxVal * 1.15] }, [data, thresholdValue, baselineMedian]) - const valueFormatter = React.useCallback( - (value: number) => formatSignalValue(signalType, Number.isFinite(value) ? value : 0), - [signalType], - ) - - const tooltipSeries = React.useMemo[]>( - () => [ - { - label: "Observed", - color: stroke, - value: (point: SignalPoint) => point.value, - format: valueFormatter, - }, - ], - [stroke, valueFormatter], - ) - const definition = React.useMemo(() => { - const at = (point: SignalPoint) => point.bucket + const at = (point: SignalPoint) => bucketDate(point.bucket) const value = (point: SignalPoint) => point.value return defineChart({ @@ -133,8 +126,8 @@ export function AnomalyTimeseriesChart({ ? [ decorative( rect([window], { - x1: (w: { x1: string; x2: string }) => w.x1, - x2: (w: { x1: string; x2: string }) => w.x2, + x1: (w: { x1: Date; x2: Date }) => w.x1, + x2: (w: { x1: Date; x2: Date }) => w.x2, y1: () => yDomain[0], y2: () => yDomain[1], fill: stroke, @@ -153,9 +146,9 @@ export function AnomalyTimeseriesChart({ color: "--muted-foreground", label: "Baseline", }, - { value: thresholdValue, color: "--destructive", label: "Threshold" }, + { value: thresholdValue, color: "--severity-error", label: "Threshold" }, ], - { labelX: data.at(-1)?.bucket }, + { labelX: axis.domainMs ? new Date(axis.domainMs[1]) : undefined }, ), areaY(data, { x: at, @@ -172,22 +165,11 @@ export function AnomalyTimeseriesChart({ strokeWidth: 2, curve: d3Curve(curveMonotoneX), }), - focusDot(data, at, value, stroke, chromeColors), - focusCrosshair(chromeColors), + focusDot(data, at, value, stroke, plot.chrome), + focusCrosshair(plot.chrome), ], scales: { - x: { - scale: scalePoint, - axis: { - line: false, - ticks: { - size: 0, - padding: 8, - format: (v: string) => formatBucketLabel(v, axisContext, "tick"), - }, - tickLabels: { thin: { minGap: 12 } }, - }, - }, + x: axis.x, y: { grid: DASHED_Y_GRID, scale: scaleLinear().domain(yDomain), @@ -201,20 +183,19 @@ export function AnomalyTimeseriesChart({ margin: { top: 8, right: 8, left: 70 }, focus: "group-x", focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }) }, [ data, window, yDomain, stroke, - chromeColors, + plot, gradientId, baselineMedian, thresholdValue, - axisContext, + axis, valueFormatter, - focusStore, ]) if (data.length === 0) { @@ -233,35 +214,33 @@ export function AnomalyTimeseriesChart({ return (
    - axis.heading(point.bucket)} ariaLabel="Observed signal" className="h-64 w-full" - renderTooltipBody={({ points }) => ( - - formatBucketLabel(point.bucket, axisContext, "tooltip") - } - /> - )} /> -
    - - - Observed - - - - Baseline median {formatSignalValue(signalType, baselineMedian)} - - - - Threshold {formatSignalValue(signalType, thresholdValue)} - -
    +
    ) } diff --git a/apps/web/src/components/anomalies/use-anomaly-mutations.ts b/apps/web/src/components/anomalies/use-anomaly-mutations.ts index bd3ce263a2..54647feafd 100644 --- a/apps/web/src/components/anomalies/use-anomaly-mutations.ts +++ b/apps/web/src/components/anomalies/use-anomaly-mutations.ts @@ -1,8 +1,6 @@ -import { Exit } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { useAtomSet } from "@/lib/effect-atom" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { showErrorToast } from "@/lib/error-toast" +import { toastExit } from "@/lib/error-toast" import type { AnomalyIncidentId, ErrorIssueId } from "@maple/domain/http" export function useAnomalyMutations() { @@ -18,11 +16,7 @@ export function useAnomalyMutations() { params: { id: incidentId }, reactivityKeys: ["anomalyIncidents", `anomalyIncident:${incidentId}`], }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Anomaly resolved", type: "success" }) - } else { - showErrorToast(result, { title: "Resolve failed" }) - } + toastExit(result, { success: "Anomaly resolved", error: "Resolve failed" }) return result } @@ -43,14 +37,10 @@ export function useAnomalyMutations() { payload: { issue_id: issueId }, reactivityKeys: ["anomalyIncidents", `anomalyIncident:${incidentId}`, ...issueKeys], }) - if (Exit.isSuccess(result)) { - toastManager.add({ - title: issueId === null ? "Issue unlinked" : "Linked to issue", - type: "success", - }) - } else { - showErrorToast(result, { title: issueId === null ? "Unlink failed" : "Link failed" }) - } + toastExit(result, { + success: issueId === null ? "Issue unlinked" : "Linked to issue", + error: issueId === null ? "Unlink failed" : "Link failed", + }) return result } diff --git a/apps/web/src/components/app-error-boundary.tsx b/apps/web/src/components/app-error-boundary.tsx index c70c3be8ce..c769949817 100644 --- a/apps/web/src/components/app-error-boundary.tsx +++ b/apps/web/src/components/app-error-boundary.tsx @@ -107,7 +107,7 @@ function CrashScreen({ error }: { error: unknown }) { {name} details -
    +					
     						{stack ?? presentation.title}
     					
    diff --git a/apps/web/src/components/billing/quota-banner.tsx b/apps/web/src/components/billing/quota-banner.tsx index 9d67f8eee9..90f843f8a0 100644 --- a/apps/web/src/components/billing/quota-banner.tsx +++ b/apps/web/src/components/billing/quota-banner.tsx @@ -4,6 +4,7 @@ import { Link } from "@tanstack/react-router" import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { CircleWarningIcon, XmarkIcon } from "@/components/icons" import { getActivePlan, getQuotaStatus, type QuotaLevel } from "@/lib/billing/plan-gating" @@ -71,14 +72,9 @@ export function QuotaBanner() { {isOver ? "Upgrade plan" : "View usage"} {!isOver && ( - + )} diff --git a/apps/web/src/components/chat/approval-card.tsx b/apps/web/src/components/chat/approval-card.tsx index bec3880a6b..573d32b8b8 100644 --- a/apps/web/src/components/chat/approval-card.tsx +++ b/apps/web/src/components/chat/approval-card.tsx @@ -49,7 +49,7 @@ export function ApprovalCard({ toolName, input, resolved, onApprove, onDeny }: A {showRaw ? ( -
    +					
     						{safeStringify(input)}
     					
    ) : null} diff --git a/apps/web/src/components/chat/approval-renderers/dashboard.tsx b/apps/web/src/components/chat/approval-renderers/dashboard.tsx index fa49e3f498..923165ef0d 100644 --- a/apps/web/src/components/chat/approval-renderers/dashboard.tsx +++ b/apps/web/src/components/chat/approval-renderers/dashboard.tsx @@ -1,4 +1,6 @@ import { Badge } from "@maple/ui/components/ui/badge" +import { Panel } from "@maple/ui/components/ui/panel" +import { EMPTY_VALUE, countLabel } from "@maple/ui/lib/format" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { @@ -56,7 +58,7 @@ function WidgetRow({ widget }: { widget: NormalizedWidget }) { {widget.title} {widget.source ? ( - {widget.source} + {widget.source} ) : null} {widget.groupBy ? ( @@ -77,7 +79,7 @@ function WidgetList({ widgets, max = 8 }: { widgets: NormalizedWidget[]; max?: n ))} {overflow > 0 ? ( -
    + {overflow} more
    +
    + {overflow} more
    ) : null}
    ) @@ -141,7 +143,7 @@ export function CreateDashboardSummary({ input }: ApprovalRendererProps) {
    {name}
    {dashboardJsonName && dashboardJsonName !== name ? ( -
    +
    Inner title: {dashboardJsonName}
    ) : null} @@ -161,7 +163,7 @@ export function CreateDashboardSummary({ input }: ApprovalRendererProps) {
    ) : !isCustom ? ( -
    +
    Template will generate widgets automatically.
    ) : null} @@ -171,7 +173,7 @@ export function CreateDashboardSummary({ input }: ApprovalRendererProps) { export function UpdateDashboardSummary({ input }: ApprovalRendererProps) { const obj = asRecord(input) ?? {} - const dashboardId = asString(obj.dashboard_id) ?? "—" + const dashboardId = asString(obj.dashboard_id) ?? EMPTY_VALUE const name = asString(obj.name) const description = asString(obj.description) const timeRange = asString(obj.time_range) @@ -216,7 +218,7 @@ export function UpdateDashboardSummary({ input }: ApprovalRendererProps) {
    0 - ? `Full replacement · ${widgets.length} widget${widgets.length === 1 ? "" : "s"}` + ? `Full replacement · ${countLabel(widgets.length, "widget")}` : "Full replacement" } > @@ -234,7 +236,7 @@ export function UpdateDashboardSummary({ input }: ApprovalRendererProps) { export function AddDashboardWidgetSummary({ input }: ApprovalRendererProps) { const obj = asRecord(input) ?? {} - const dashboardId = asString(obj.dashboard_id) ?? "—" + const dashboardId = asString(obj.dashboard_id) ?? EMPTY_VALUE const visualization = asString(obj.visualization) ?? "chart" const Icon = vizIcon(visualization) const label = vizLabel(visualization) @@ -278,17 +280,17 @@ export function AddDashboardWidgetSummary({ input }: ApprovalRendererProps) {
    -
    +
    {title}
    {endpoint ? ( -
    {endpoint}
    +
    {endpoint}
    ) : null}
    -
    +
    @@ -301,8 +303,8 @@ export function AddDashboardWidgetSummary({ input }: ApprovalRendererProps) { export function UpdateDashboardWidgetSummary({ input }: ApprovalRendererProps) { const obj = asRecord(input) ?? {} - const dashboardId = asString(obj.dashboard_id) ?? "—" - const widgetId = asString(obj.widget_id) ?? "—" + const dashboardId = asString(obj.dashboard_id) ?? EMPTY_VALUE + const widgetId = asString(obj.widget_id) ?? EMPTY_VALUE const visualization = asString(obj.visualization) const dataSourceResult = safeParseJson(obj.data_source_json) @@ -366,8 +368,8 @@ export function UpdateDashboardWidgetSummary({ input }: ApprovalRendererProps) { export function RemoveDashboardWidgetSummary({ input }: ApprovalRendererProps) { const obj = asRecord(input) ?? {} - const dashboardId = asString(obj.dashboard_id) ?? "—" - const widgetId = asString(obj.widget_id) ?? "—" + const dashboardId = asString(obj.dashboard_id) ?? EMPTY_VALUE + const widgetId = asString(obj.widget_id) ?? EMPTY_VALUE return (
    @@ -382,7 +384,7 @@ export function RemoveDashboardWidgetSummary({ input }: ApprovalRendererProps) { export function ReorderDashboardWidgetsSummary({ input }: ApprovalRendererProps) { const obj = asRecord(input) ?? {} - const dashboardId = asString(obj.dashboard_id) ?? "—" + const dashboardId = asString(obj.dashboard_id) ?? EMPTY_VALUE const layoutsRaw = safeParseJson(obj.layouts_json) const layouts = layoutsRaw.ok ? (asArray(layoutsRaw.value) ?? []) : [] @@ -397,7 +399,7 @@ export function ReorderDashboardWidgetsSummary({ input }: ApprovalRendererProps)
    - Reorder {ids.length || ""} widget{ids.length === 1 ? "" : "s"} + Reorder {ids.length > 0 ? countLabel(ids.length, "widget") : "widgets"} {dashboardId} @@ -406,12 +408,12 @@ export function ReorderDashboardWidgetsSummary({ input }: ApprovalRendererProps) {previewIds.length > 0 ? (
    {previewIds.map((id) => ( - + {id} ))} {overflow > 0 ? ( - + {overflow} more + + {overflow} more ) : null}
    ) : null} diff --git a/apps/web/src/components/chat/approval-renderers/index.tsx b/apps/web/src/components/chat/approval-renderers/index.tsx index 12e1fb7bd4..da3288726e 100644 --- a/apps/web/src/components/chat/approval-renderers/index.tsx +++ b/apps/web/src/components/chat/approval-renderers/index.tsx @@ -9,6 +9,7 @@ import { UpdateDashboardSummary, UpdateDashboardWidgetSummary, } from "./dashboard" +import { EMPTY_VALUE } from "@maple/ui/lib/format" interface SummaryProps { toolName: string @@ -31,7 +32,7 @@ export function ApprovalSummary({ toolName, input }: SummaryProps) { } function formatValue(value: unknown): { kind: "scalar"; text: string } | { kind: "blob"; chars: number } { - if (value === null || value === undefined) return { kind: "scalar", text: "—" } + if (value === null || value === undefined) return { kind: "scalar", text: EMPTY_VALUE } if (typeof value === "boolean") return { kind: "scalar", text: value ? "true" : "false" } if (typeof value === "number") return { kind: "scalar", text: String(value) } if (typeof value === "string") { @@ -51,7 +52,7 @@ function KeyValueFallback({ input }: { input: unknown }) { const obj = asRecord(input) if (!obj) { return ( -
    +			
     				{safeStringify(input)}
     			
    ) diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index 8d19122406..113b61aa1d 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { Exit } from "effect" import { useMountEffect } from "@/hooks/use-mount-effect" import { toastManager } from "@maple/ui/components/ui/toast" +import { toastExit } from "@/lib/error-toast" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { useAtomSet } from "@/lib/effect-atom" import { MapleAiAtomClient } from "@/lib/services/common/ai-atom-client" @@ -190,8 +191,7 @@ export function ChatConversation({ const exit = await decideProposal({ payload: new ChatApplyRequest({ sessionId, toolCallId, decision: "deny" }), }) - if (Exit.isSuccess(exit)) resolveApproval(toolCallId, "denied") - else toastManager.add({ title: "Couldn't deny this change", type: "error" }) + if (toastExit(exit, { error: "Couldn't deny this change" })) resolveApproval(toolCallId, "denied") }, [decideProposal, sessionId, resolveApproval], ) @@ -209,7 +209,7 @@ export function ChatConversation({ resolveApproval(toolCallId, "applied") toastManager.add({ title: "Change applied", type: "success" }) } else { - toastManager.add({ title: `Failed to apply ${tool}`, type: "error" }) + toastExit(exit, { error: `Failed to apply ${tool}` }) } }, [decideProposal, sessionId, resolveApproval], @@ -390,7 +390,6 @@ function FailedSendNotice({ failed, onRetry }: { failed: FailedSend; onRetry: (t onRetry(failed.message)}> Message not sent: {failed.error.message} - ) } diff --git a/apps/web/src/components/chat/chat-empty-state.tsx b/apps/web/src/components/chat/chat-empty-state.tsx index 8333e2d038..454d37989a 100644 --- a/apps/web/src/components/chat/chat-empty-state.tsx +++ b/apps/web/src/components/chat/chat-empty-state.tsx @@ -1,14 +1,6 @@ import type { ComponentType } from "react" -import { - ArrowTrendUpIcon, - ChartLineIcon, - CompactLinesIcon, - CornerDownLeftIcon, - PixelNodesIcon, - PixelSparkleIcon, - PixelTriangleWarningIcon, - ServerIcon, -} from "@/components/icons" +import { ArrowTrendUpIcon, CornerDownLeftIcon, PixelSparkleIcon } from "@/components/icons" +import { CONCEPT_ICON } from "@/components/icons/concept" import type { IconProps } from "@/components/icons/icon" import { DocsLink } from "@/components/common/docs-link" import { useSignalPresence } from "@/hooks/use-signal-presence" @@ -21,14 +13,14 @@ interface Tone { const TONES = { errors: { - Glyph: PixelTriangleWarningIcon, - chip: "text-destructive bg-destructive/10 border-destructive/20", + Glyph: CONCEPT_ICON.error, + chip: "text-severity-error bg-severity-error/10 border-severity-error/20", }, - traces: { Glyph: PixelNodesIcon, chip: "text-chart-2 bg-chart-2/10 border-chart-2/20" }, - logs: { Glyph: CompactLinesIcon, chip: "text-chart-5 bg-chart-5/10 border-chart-5/20" }, + traces: { Glyph: CONCEPT_ICON.trace, chip: "text-chart-2 bg-chart-2/10 border-chart-2/20" }, + logs: { Glyph: CONCEPT_ICON.log, chip: "text-chart-5 bg-chart-5/10 border-chart-5/20" }, health: { Glyph: ArrowTrendUpIcon, chip: "text-chart-3 bg-chart-3/10 border-chart-3/20" }, - services: { Glyph: ServerIcon, chip: "text-chart-4 bg-chart-4/10 border-chart-4/20" }, - metrics: { Glyph: ChartLineIcon, chip: "text-chart-1 bg-chart-1/10 border-chart-1/20" }, + services: { Glyph: CONCEPT_ICON.service, chip: "text-chart-4 bg-chart-4/10 border-chart-4/20" }, + metrics: { Glyph: CONCEPT_ICON.metric, chip: "text-chart-1 bg-chart-1/10 border-chart-1/20" }, any: { Glyph: PixelSparkleIcon, chip: "text-primary bg-primary/10 border-primary/20" }, } satisfies Record diff --git a/apps/web/src/components/chat/chat-page.tsx b/apps/web/src/components/chat/chat-page.tsx index 7808a8ba2a..8a406c3810 100644 --- a/apps/web/src/components/chat/chat-page.tsx +++ b/apps/web/src/components/chat/chat-page.tsx @@ -8,6 +8,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@maple/ui/compone import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@maple/ui/components/ui/sheet" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { useIsMobile } from "@maple/ui/hooks/use-media-query" import { ChevronDownIcon, LinkIcon, PlusIcon } from "@/components/icons" import { useAppHotkey } from "@/hooks/use-app-hotkey" @@ -216,27 +217,20 @@ function ChatPageInner({ {activeTitle} - - + {conversationArea} diff --git a/apps/web/src/components/chat/chat-sidebar.tsx b/apps/web/src/components/chat/chat-sidebar.tsx index 511acde9c6..2039da4117 100644 --- a/apps/web/src/components/chat/chat-sidebar.tsx +++ b/apps/web/src/components/chat/chat-sidebar.tsx @@ -5,6 +5,7 @@ import { cn } from "@maple/ui/lib/utils" import { Button } from "@maple/ui/components/ui/button" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { DropdownMenu, DropdownMenuContent, @@ -184,6 +185,7 @@ function ChatSidebarRow({ const Icon = tabIcon(tab) const inputRef = useRef(null) const [draft, setDraft] = useState(tab.title) + const [confirmDelete, setConfirmDelete] = useState(false) useEffect(() => { if (isRenaming) { @@ -297,7 +299,7 @@ function ChatSidebarRow({ disabled={!canDelete} variant="destructive" onClick={() => { - if (canDelete) onClose(tab.id) + if (canDelete) setConfirmDelete(true) }} > @@ -307,6 +309,18 @@ function ChatSidebarRow({ )}
    + {/* Outside the row so dialog clicks do not bubble into the row's select handler. */} + { + setConfirmDelete(false) + onClose(tab.id) + }} + /> ) } diff --git a/apps/web/src/components/chat/diagnosis-report-card.tsx b/apps/web/src/components/chat/diagnosis-report-card.tsx index 9ed7dca5b8..02de7afdde 100644 --- a/apps/web/src/components/chat/diagnosis-report-card.tsx +++ b/apps/web/src/components/chat/diagnosis-report-card.tsx @@ -43,7 +43,7 @@ export function DiagnosisReportCard({ report }: { report: AiTriageResult }) { AI Diagnosis · {report.confidence} confidence @@ -96,7 +96,7 @@ export function DiagnosisReportCard({ report }: { report: AiTriageResult }) { onClick={() => setOpenIndex(action.index)} className="-mx-2 flex w-[calc(100%+1rem)] items-start gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50" > - + {action.index + 1} = { const STATUS_TONE: Record = { Firing: TONE_TEXT.crit, Open: TONE_TEXT.crit, - Resolved: "text-severity-info", + Resolved: TONE_TEXT.info, } satisfies Record /** The last dash segment of a UUID-ish id, cut to 8 chars: the part that tells ids apart. */ @@ -49,9 +50,7 @@ export function AttachmentCard({ }) { return (
    -
    +
    @@ -70,7 +69,7 @@ export function AttachmentCard({
    {title}
    {children}
    -
    +
    ) } @@ -117,9 +116,7 @@ export function InvestigationAttachmentCard({ {ctx.facts.map((fact) => (
  • {fact.label} - - {fact.value} - + {fact.value}
  • ))} diff --git a/apps/web/src/components/chat/message-actions.tsx b/apps/web/src/components/chat/message-actions.tsx index 0936452cd4..94593d5a15 100644 --- a/apps/web/src/components/chat/message-actions.tsx +++ b/apps/web/src/components/chat/message-actions.tsx @@ -1,6 +1,8 @@ import { CopyButton } from "@maple/ui/components/ui/copy-button" import type { UIMessage } from "@/components/ai-elements/types" import { LinkIcon } from "@/components/icons" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" /** The visible text of a message, with tool calls and markers left out. */ export function messageText(message: UIMessage): string { @@ -17,22 +19,6 @@ interface MessageActionsProps { permalink?: string } -/** - * Wall-clock label for a message, e.g. "Sep 11, 2:03 PM". The year is only spelled - * out on threads from a previous one, where it is the part that disambiguates. - */ -function timeLabel(createdAt: number): string { - const date = new Date(createdAt) - const options: Intl.DateTimeFormatOptions = { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - } - if (date.getFullYear() !== new Date().getFullYear()) options.year = "numeric" - return date.toLocaleString(undefined, options) -} - /** * Assistant-message actions, revealed on hover of the enclosing `Message` row, * with the turn's timestamp beside them. Deliberately limited to what @@ -42,6 +28,7 @@ function timeLabel(createdAt: number): string { export function MessageActions({ message, permalink }: MessageActionsProps) { const text = messageText(message) const createdAt = message.createdAt + const { effectiveTimezone } = useTimezonePreference() if (!text && !permalink && createdAt === undefined) return null return ( @@ -74,10 +61,13 @@ export function MessageActions({ message, permalink }: MessageActionsProps) { {createdAt === undefined ? null : ( )}
    diff --git a/apps/web/src/components/chat/page-context-chips.tsx b/apps/web/src/components/chat/page-context-chips.tsx index 53d7af97bc..d5d290ddd6 100644 --- a/apps/web/src/components/chat/page-context-chips.tsx +++ b/apps/web/src/components/chat/page-context-chips.tsx @@ -4,7 +4,6 @@ import { BellIcon, ChartLineIcon, CircleWarningIcon, - CubeIcon, DockerIcon, FileIcon, GridSquareCirclePlusIcon, @@ -12,7 +11,9 @@ import { PulseIcon, ServerIcon, XmarkIcon, + type IconComponent, } from "@/components/icons" +import { CONCEPT_ICON } from "@/components/icons/concept" import { autoContextDisplay, autoContextLabel, type AutoContext } from "./auto-contexts" /** @@ -28,11 +29,11 @@ const CONTEXT_ICONS = { host: ServerIcon, logs_explorer: FileIcon, metrics_explorer: ChartLineIcon, - service: CubeIcon, + service: CONCEPT_ICON.service, service_map: NetworkNodesIcon, trace: PulseIcon, traces_explorer: PulseIcon, -} satisfies Record +} satisfies Record interface PageContextChipsProps { contexts: AutoContext[] diff --git a/apps/web/src/components/chat/task-card.tsx b/apps/web/src/components/chat/task-card.tsx index c3c8630c28..1f64fec275 100644 --- a/apps/web/src/components/chat/task-card.tsx +++ b/apps/web/src/components/chat/task-card.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { cn } from "@maple/ui/lib/utils" import { TONE_TEXT } from "@maple/ui/lib/tone" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { SectionHeading } from "@/components/common/section-heading" import { Sheet, SheetContent, SheetPanel } from "@maple/ui/components/ui/sheet" import { SheetDetailHeader } from "@/components/common/sheet-detail-header" import { ChevronRightIcon, CircleCheckIcon, CircleWarningIcon, CircleXmarkIcon } from "@/components/icons" @@ -93,14 +93,14 @@ export function TaskCard({ > {running ? `${activity}…` : label}
    - + · {prompt} {status === "error" ? ( - failed + failed ) : budgetExhausted === true ? ( - partial + partial ) : null} {running ? : null} @@ -133,7 +133,7 @@ export function TaskCard({ ) : null}
    - Answer + {running ? ( // A div, not a p: the loader renders a dot grid, which cannot live inside one.
    @@ -164,7 +164,12 @@ export function TaskCard({ */} {messages.length > 0 ? (
    - Its own steps + {messages.map((message) => (
    {message.parts.map((part, index) => diff --git a/apps/web/src/components/chat/turn-failure-notice.tsx b/apps/web/src/components/chat/turn-failure-notice.tsx index 11c07d26e0..eaf031586a 100644 --- a/apps/web/src/components/chat/turn-failure-notice.tsx +++ b/apps/web/src/components/chat/turn-failure-notice.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react" import { cn } from "@maple/ui/lib/utils" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { Alert, AlertAction, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" @@ -18,7 +19,7 @@ export function ChatFailureNotice({ }) { return ( - + {children} diff --git a/apps/web/src/components/chat/turn-minimap.tsx b/apps/web/src/components/chat/turn-minimap.tsx index b1b6fa83b2..3182bc52e9 100644 --- a/apps/web/src/components/chat/turn-minimap.tsx +++ b/apps/web/src/components/chat/turn-minimap.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react" import { cn } from "@maple/ui/lib/utils" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { useMessageScroller, useMessageScrollerVisibility } from "@maple/ui/components/ui/message-scroller" import { ChevronDownIcon, ChevronUpIcon } from "@/components/icons" import type { TranscriptRow } from "./transcript-rows" @@ -255,16 +255,9 @@ function TurnMinimapStep({ previous ? "bottom-[calc(100%+4px)]" : "top-[calc(100%+4px)]", )} > - + ) } diff --git a/apps/web/src/components/chat/widget-fix-attachment-card.tsx b/apps/web/src/components/chat/widget-fix-attachment-card.tsx index e6a0bdecf2..c17861751f 100644 --- a/apps/web/src/components/chat/widget-fix-attachment-card.tsx +++ b/apps/web/src/components/chat/widget-fix-attachment-card.tsx @@ -25,10 +25,10 @@ export function WidgetFixAttachmentCard({ ctx, className }: WidgetFixAttachmentC {(ctx.errorTitle || ctx.errorMessage) && (
    {ctx.errorTitle && ( -
    {ctx.errorTitle}
    +
    {ctx.errorTitle}
    )} {ctx.errorMessage && ( -
    +
    {ctx.errorMessage}
    )} diff --git a/apps/web/src/components/code-review/author-avatar.tsx b/apps/web/src/components/code-review/author-avatar.tsx index 35b3265c84..13c9bf2dc0 100644 --- a/apps/web/src/components/code-review/author-avatar.tsx +++ b/apps/web/src/components/code-review/author-avatar.tsx @@ -4,7 +4,7 @@ import { cn } from "@maple/ui/lib/utils" /** A GitHub login's avatar, from GitHub's public `.png` redirect; the initial while it loads. */ export function AuthorAvatar({ login, className }: { login: string; className?: string }) { return ( - + {login.slice(0, 1).toUpperCase()} diff --git a/apps/web/src/components/code-review/code-review-analytics.tsx b/apps/web/src/components/code-review/code-review-analytics.tsx index 1b4ca97a13..1490ee5de4 100644 --- a/apps/web/src/components/code-review/code-review-analytics.tsx +++ b/apps/web/src/components/code-review/code-review-analytics.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type ReactNode } from "react" import { TONE_FILL } from "@maple/ui/lib/tone" +import { EMPTY_VALUE, countLabel, formatPercent } from "@maple/ui/lib/format" import { Link } from "@tanstack/react-router" import type { CodeReviewAnalytics, CodeReviewTotals, PrReviewSeverity } from "@maple/domain/http" import { QueryBuilderBarChart } from "@maple/ui/components/charts" @@ -130,7 +131,7 @@ function HeadlineStrip({ value={formatCount(current.findings)} subline={ current.repositoriesWithFindings > 0 - ? `in ${current.repositoriesWithFindings} ${current.repositoriesWithFindings === 1 ? "repo" : "repos"}` + ? `in ${countLabel(current.repositoriesWithFindings, "repo")}` : undefined } /> @@ -452,12 +453,12 @@ function SecondaryStats({ current }: { current: CodeReviewTotals }) { const stats = [ { label: "Avg confidence", - value: current.avgConfidence === null ? "–" : `${current.avgConfidence.toFixed(1)}/10`, + value: current.avgConfidence === null ? EMPTY_VALUE : `${current.avgConfidence.toFixed(1)}/10`, hint: "How safe the reviewer judged each change to merge", }, { label: "Avg quality", - value: current.avgScore === null ? "–" : `${Math.round(current.avgScore)}/100`, + value: current.avgScore === null ? EMPTY_VALUE : `${Math.round(current.avgScore)}/100`, hint: "100 minus a fixed penalty per open finding", }, { @@ -469,13 +470,13 @@ function SecondaryStats({ current }: { current: CodeReviewTotals }) { label: "Fixed rate", value: current.findings === 0 - ? "–" - : `${Math.round((current.resolvedFindings / current.findings) * 100)}%`, + ? EMPTY_VALUE + : formatPercent(current.resolvedFindings / current.findings), hint: "Issues a later push resolved", }, { label: "Dismissed", - value: settled === 0 ? "–" : formatCount(current.dismissedFindings), + value: settled === 0 ? EMPTY_VALUE : formatCount(current.dismissedFindings), hint: "Issues a person dismissed as not worth fixing", }, { diff --git a/apps/web/src/components/code-review/code-review-format.ts b/apps/web/src/components/code-review/code-review-format.ts index 30e30173ee..796aafed55 100644 --- a/apps/web/src/components/code-review/code-review-format.ts +++ b/apps/web/src/components/code-review/code-review-format.ts @@ -5,6 +5,7 @@ import type { PrReviewSeverity, PrReviewSkipReason, } from "@maple/domain/http" +import { EMPTY_VALUE } from "@maple/ui/lib/format" import { TONE_TEXT, type Tone } from "@maple/ui/lib/tone" export const CATEGORY_LABELS = { @@ -53,11 +54,12 @@ export const SKIP_LABELS = { const COUNT = new Intl.NumberFormat("en-US") const COMPACT = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }) +// Not `formatNumber`: that compacts from 1K, while review counts stay exact below 10K. export const formatCount = (value: number) => (value >= 10_000 ? COMPACT.format(value) : COUNT.format(value)) /** A span in the largest unit that keeps it readable: `42s`, `14m`, `5.2h`, `7.0d`. */ export function formatSpan(seconds: number | null): string { - if (seconds === null || !Number.isFinite(seconds)) return "–" + if (seconds === null || !Number.isFinite(seconds)) return EMPTY_VALUE if (seconds < 60) return `${Math.round(seconds)}s` if (seconds < 3_600) return `${Math.round(seconds / 60)}m` if (seconds < 86_400) return `${(seconds / 3_600).toFixed(1)}h` diff --git a/apps/web/src/components/code-review/code-review-settings.tsx b/apps/web/src/components/code-review/code-review-settings.tsx index 93abd107ca..195d396635 100644 --- a/apps/web/src/components/code-review/code-review-settings.tsx +++ b/apps/web/src/components/code-review/code-review-settings.tsx @@ -11,6 +11,7 @@ import { type GithubRepoSummary, PrReviewRepositoryConfig, } from "@maple/domain/http" +import { pluralize } from "@maple/ui/lib/format" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { @@ -33,7 +34,7 @@ import { ExternalLinkIcon, GearIcon, GithubIcon } from "@/components/icons" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useAsyncAction } from "@/hooks/use-mutation-action" import { useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { errorMessage } from "@/lib/error-toast" +import { errorMessage, toastExit } from "@/lib/error-toast" import { currentRegion } from "@/lib/region" import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" @@ -87,9 +88,9 @@ export function CodeReviewSettingsView() { title="Default review rules" description="Every repository starts from these. A repository can override any of them." > -
    + -
    +
    repo.prReviewEnabled).length return ( - + {enabled} of {repositories.length}{" "} - {repositories.length === 1 ? "repository" : "repositories"} reviewed + {pluralize(repositories.length, "repository", "repositories")} reviewed
      @@ -325,20 +325,13 @@ function RepositoryRow({ payload: new GithubSetPrReviewRequest({ enabled: next }), reactivityKeys: [STATUS_KEY], }) - if (Exit.isSuccess(result)) { - toastManager.add({ - title: next - ? `Maple will review pull requests on ${repo.fullName}` - : `Reviews off for ${repo.fullName}`, - type: "success", - }) - return - } - setEnabled(!next) - toastManager.add({ - title: errorMessage(result, "Failed to change pull request reviews."), - type: "error", + const ok = toastExit(result, { + success: next + ? `Maple will review pull requests on ${repo.fullName}` + : `Reviews off for ${repo.fullName}`, + error: "Failed to change pull request reviews.", }) + if (!ok) setEnabled(!next) }) // Ignored while a change is in flight, rather than disabling the switch, which would drop // keyboard focus mid-toggle. diff --git a/apps/web/src/components/code-review/review-detail-sheet.tsx b/apps/web/src/components/code-review/review-detail-sheet.tsx index 9a6e999df4..a07f050ead 100644 --- a/apps/web/src/components/code-review/review-detail-sheet.tsx +++ b/apps/web/src/components/code-review/review-detail-sheet.tsx @@ -11,22 +11,19 @@ import { DateTime } from "effect" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetPanel, - SheetTitle, -} from "@maple/ui/components/ui/sheet" +import { Sheet, SheetContent, SheetHeader, SheetPanel, SheetTitle } from "@maple/ui/components/ui/sheet" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { EMPTY_VALUE, countLabel } from "@maple/ui/lib/format" import { MessageResponse } from "@/components/ai-elements/message-response" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" +import { SectionHeading } from "@/components/common/section-heading" +import { SheetDetailHeader } from "@/components/common/sheet-detail-header" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { ExternalLinkIcon } from "@/components/icons" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" @@ -116,38 +113,49 @@ function ReviewDetailContent({ return ( <> - - - #{review.number} - {review.title ?? "Untitled pull request"} - - - {review.repositoryFullName} - {review.authorLogin ? ( - <> - {" · opened by "} - - - ) : null}{" "} - · head - -
      - Pull request - {review.commentUrl ? ( - Summary comment - ) : null} - {detail.reviewUrl ? Inline review : null} - {detail.checkRunUrl ? Check run : null} -
      -
      + +

      + {review.repositoryFullName} + {review.authorLogin ? ( + <> + {" · opened by "} + + + ) : null}{" "} + · head +

      +
      + Pull request + {review.commentUrl ? ( + Summary comment + ) : null} + {detail.reviewUrl ? ( + Inline review + ) : null} + {detail.checkRunUrl ? ( + Check run + ) : null} +
      + + } + /> + - @@ -248,11 +256,13 @@ function ReviewDetailContent({ {entryOutcome.label} - {entry.findings} {entry.findings === 1 ? "issue" : "issues"} - - - {formatRelativeFrom(entry.createdAt)} + {countLabel(entry.findings, "issue")} + ) @@ -301,7 +311,7 @@ function FindingCard({ } const modelLabel = (model: string | null) => - model === null ? "–" : (PR_REVIEW_MODELS.find((entry) => entry.id === model)?.label ?? model) + model === null ? EMPTY_VALUE : (PR_REVIEW_MODELS.find((entry) => entry.id === model)?.label ?? model) /** A dense StatRail tile: sheet-sized value that truncates instead of overflowing. */ function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { @@ -396,7 +406,7 @@ function PostMergeSection({ postMerge }: { postMerge: PrReviewPostMerge }) { function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
      -

      {title}

      + {children}
      ) diff --git a/apps/web/src/components/common/chart-card.tsx b/apps/web/src/components/common/chart-card.tsx new file mode 100644 index 0000000000..a1f79e5778 --- /dev/null +++ b/apps/web/src/components/common/chart-card.tsx @@ -0,0 +1,84 @@ +import type { ComponentProps, ReactNode } from "react" +import { Panel, PanelHeader, PanelTitle } from "@maple/ui/components/ui/panel" +import { ChartEmpty } from "@maple/ui/components/charts" +import { cn } from "@maple/ui/lib/utils" + +/** Every infra detail chart plots at this height, so a grid of them never staggers. */ +export const CHART_HEIGHT = 200 + +/** Shown when a series query returns no points for the selected window. */ +export const CHART_EMPTY_MESSAGE = "No data for this metric in the selected window." + +/** + * Card frame shared by every app chart: title on the left, legend on the right, + * and an optional scope marker next to the title saying what the panel is + * actually filtered to (see `ScopeChip` / Cloudflare's `PanelScope`). + */ +export function ChartCard({ + title, + description, + legend, + scope, + bare = false, + children, + className, + ...rest +}: Omit, "title"> & { + title: ReactNode + /** A muted line under the title, for a chart whose title alone does not say what it plots. */ + description?: ReactNode + /** + * Optional: a single-series chart whose series is named by the title has + * nothing to disambiguate. Multi-series charts should still pass one. + */ + legend?: ReactNode + /** Scope marker: what this panel is actually filtered to. */ + scope?: ReactNode + /** Drops the card border, for a chart that is a section of a page already divided by hairlines. */ + bare?: boolean + children: ReactNode + className?: string +}) { + const header = ( + + {description === undefined ? undefined : ( +
      +
      + {title} + {scope} +
      +

      {description}

      +
      + )} +
      + ) + if (bare) { + return ( +
      + {header} + {children} +
      + ) + } + return ( + + {header} + {children} + + ) +} + +/** + * Centered message filling an infra card's plot area: "no data", "not + * collected", and friends. A thin alias over {@link ChartEmpty} that supplies + * the card height; the look lives in the shared primitive. + */ +export function ChartCardMessage({ children }: { children: ReactNode }) { + return {children} +} diff --git a/apps/web/src/components/common/data-table.tsx b/apps/web/src/components/common/data-table.tsx index d92fe55d37..d631f922b0 100644 --- a/apps/web/src/components/common/data-table.tsx +++ b/apps/web/src/components/common/data-table.tsx @@ -5,6 +5,7 @@ import { EmptyMessage } from "@maple/ui/components/ui/empty" import * as React from "react" import { cn } from "@maple/ui/lib/utils" +import { refreshingClass } from "@maple/ui/lib/refreshing" import { ArrowUpDownIcon } from "@/components/icons" @@ -55,7 +56,7 @@ export function ColumnHead({ return (
      ({ } export function MetaChip({ children }: { children: React.ReactNode }) { - return {children} + return {children} } /* ------------------------------------------------------------------------------------------------- @@ -154,8 +155,9 @@ function DataTableRoot({ return (
      + +function isKindTrail(kind: DetailKind): kind is ReadonlyArray { + return Array.isArray(kind) +} + +/** The eyebrow of a detail header, segments joined by a quiet dot. Shared by page and sheet headers. */ +export function DetailEyebrow({ kind, className }: { kind: DetailKind; className?: string }) { + const segments = (isKindTrail(kind) ? kind : [kind]).filter( + (segment) => segment !== null && segment !== undefined && segment !== false && segment !== "", + ) + if (segments.length === 0) return null + return ( + + {segments.map((segment, index) => ( + + {index > 0 ? ( + + · + + ) : null} + {segment} + + ))} + + ) +} + +/** + * A detail page's header: kind eyebrow, the one `

      `, a status/meta row, and the + * actions (one labelled primary plus an overflow menu, by convention). The page + * counterpart of `SheetDetailHeader`, with the same slot names. + */ +export function DetailHeader({ + kind, + title, + titleText, + adornment, + meta, + actions, +}: { + /** "Investigation" or ["Investigation", "Error spike"]. */ + kind?: DetailKind + title: React.ReactNode + /** Tooltip for the truncating title; defaults to `title` when it is a string. */ + titleText?: string + /** Inline beside the title (a service dot, a small badge). */ + adornment?: React.ReactNode + /** The chip row under the title: status, severity, scope. */ + meta?: React.ReactNode + actions?: React.ReactNode +}) { + const tooltip = titleText ?? (typeof title === "string" ? title : undefined) + return ( + + {kind !== undefined ? : null} + + {adornment ? {title} : title} + {adornment} + + {meta ?
      {meta}
      : null} +

      + } + > + {actions ?
      {actions}
      : null} + + ) +} + +/** `DetailHeader`'s loading frame, sized to the real one so the body doesn't jump. */ +export function DetailHeaderSkeleton({ + meta = true, + actions, +}: { + meta?: boolean + actions?: React.ReactNode +}) { + return ( + + + + {meta ? : null} +
      + } + > + {actions} + + ) +} diff --git a/apps/web/src/components/common/facet-select.tsx b/apps/web/src/components/common/facet-select.tsx new file mode 100644 index 0000000000..13a3295885 --- /dev/null +++ b/apps/web/src/components/common/facet-select.tsx @@ -0,0 +1,64 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { cn } from "@maple/ui/lib/utils" + +/** Base UI selects need a real value for "no filter"; this is it, and it never reaches the URL. */ +const ALL = "__all__" + +export interface FacetSelectOption { + readonly name: string +} + +interface FacetSelectProps { + /** The dimension, drawn small beside the value: `service All`. */ + label: string + value: string | undefined + options: ReadonlyArray + onChange: (value: string | undefined) => void + className?: string +} + +/** + * A label-prefixed single-value filter select with an "All" option. A set value is drawn in + * the primary tint so a toolbar reads as "what is narrowing this page" at a glance. + */ +export function FacetSelect({ label, value, options, onChange, className }: FacetSelectProps) { + const set = value !== undefined + return ( + + ) +} diff --git a/apps/web/src/components/common/image-dropzone.tsx b/apps/web/src/components/common/image-dropzone.tsx index 656e6da948..d04052ecee 100644 --- a/apps/web/src/components/common/image-dropzone.tsx +++ b/apps/web/src/components/common/image-dropzone.tsx @@ -104,7 +104,7 @@ export function ImageDropzone({ > {preview} {isDragging && ( -
      +
      Drop image
      )} diff --git a/apps/web/src/components/common/resource-not-found.tsx b/apps/web/src/components/common/resource-not-found.tsx index c5f6cde0be..ae80babda0 100644 --- a/apps/web/src/components/common/resource-not-found.tsx +++ b/apps/web/src/components/common/resource-not-found.tsx @@ -1,6 +1,7 @@ import type React from "react" import { Button } from "@maple/ui/components/ui/button" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" +import { cn } from "@maple/ui/lib/utils" import { CircleWarningIcon } from "@/components/icons" interface ResourceNotFoundProps { @@ -22,10 +23,11 @@ export function ResourceNotFound({ backLink, backLabel, icon, - className = "py-12", + className, }: ResourceNotFoundProps): React.ReactElement { return ( - + // Inside a page shell the body already centres it; `Empty`'s tall `md:py-20` only pushes it down. + {icon ?? } {title} diff --git a/apps/web/src/components/common/search-toolbar.tsx b/apps/web/src/components/common/search-toolbar.tsx index 3c0c6467a9..47d55b77ff 100644 --- a/apps/web/src/components/common/search-toolbar.tsx +++ b/apps/web/src/components/common/search-toolbar.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react" import { SearchInput } from "@maple/ui/components/ui/search-input" import { cn } from "@maple/ui/lib/utils" +import { countLabel as nounCount } from "@maple/ui/lib/format" /** * The row between a fleet band and its table: a search box, any pivot the @@ -48,8 +49,6 @@ export function SearchToolbar({ * whole list. The count is the truth, not the page size. */ export function countLabel(shown: number, total: number, noun: string): string { - const word = total === 1 ? noun : `${noun}s` - return total > shown - ? `Top ${shown} of ${total.toLocaleString()} ${word}` - : `${total.toLocaleString()} ${word}` + const counted = nounCount(total, noun) + return total > shown ? `Top ${shown} of ${counted}` : counted } diff --git a/apps/web/src/components/common/section-heading.tsx b/apps/web/src/components/common/section-heading.tsx index cfabb6de82..6b4e087acc 100644 --- a/apps/web/src/components/common/section-heading.tsx +++ b/apps/web/src/components/common/section-heading.tsx @@ -1,9 +1,17 @@ import type { ReactNode } from "react" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { cn } from "@maple/ui/lib/utils" interface SectionHeadingProps { title: string + /** + * `title` is the text-sm row above a page section; `eyebrow` is the uppercase overline + * (form sections, detail rails), spaced `mb-3` below. Pass `className="mb-0"` when inline. + */ + variant?: "title" | "eyebrow" + /** Reference it from the group's `aria-labelledby`. */ + id?: string /** Muted note beside the title ("worst first"). */ hint?: string /** A mono count rendered inside the heading ("Zones 12"). */ @@ -14,26 +22,34 @@ interface SectionHeadingProps { className?: string } -/** The title row above a page section. */ +/** The title row above a page section, or (variant="eyebrow") the overline above a form group. */ export function SectionHeading({ title, + variant = "title", + id, hint, count, actions, as: Heading = "h2", className, }: SectionHeadingProps) { - const heading = ( -
      - + const heading = + variant === "eyebrow" ? ( + {title} - {count != null ? ( - {count} - ) : null} - - {hint ? {hint} : null} -
      - ) + {count != null ? {count} : null} + + ) : ( +
      + + {title} + {count != null ? ( + {count} + ) : null} + + {hint ? {hint} : null} +
      + ) if (!actions) return heading return (
      diff --git a/apps/web/src/components/common/series-legend.tsx b/apps/web/src/components/common/series-legend.tsx index e207e7ed7c..caace8b263 100644 --- a/apps/web/src/components/common/series-legend.tsx +++ b/apps/web/src/components/common/series-legend.tsx @@ -6,49 +6,103 @@ export interface SeriesLegendItem { key: string /** Omit (with `color`) for a single-series chart where the value alone reads. */ label?: ReactNode + /** The label's hover title, for a label truncated to fit. */ + title?: string color?: string value?: ReactNode + /** Overrides the legend's `swatch` for this item (a dashed comparison line among solid ones). */ + swatch?: SeriesLegendSwatch } +/** How a series is keyed: a dot or square for fills and bars, a stroke for lines. */ +export type SeriesLegendSwatch = "dot" | "square" | "line" | "dashed" + const VARIANTS = { inline: { root: "flex flex-wrap items-center justify-end gap-x-3 gap-y-1", - item: "inline-flex items-baseline gap-1.5", - dot: "size-1.5 translate-y-[-1px] rounded-full", - label: "text-[11px] text-muted-foreground", - value: "font-mono text-[11px] text-foreground/85 tabular-nums", + item: "inline-flex min-w-0 items-baseline gap-1.5", + dot: "size-1.5 translate-y-[-1px]", + label: "max-w-[24ch] truncate text-2xs text-muted-foreground", + value: "font-mono text-2xs text-foreground/85 tabular-nums", }, chip: { root: "flex flex-wrap items-center gap-2", - item: "inline-flex items-center gap-1.5 rounded-full border bg-background px-2 py-0.5 text-[11px]", - dot: "size-2 rounded-full", + item: "inline-flex items-center gap-1.5 rounded-full border bg-background px-2 py-0.5 text-2xs", + dot: "size-2", label: "font-medium text-foreground/80", value: "font-mono text-muted-foreground tabular-nums", }, } as const -/** Per-series colour dot, label and last value beside a chart. Hook-free: charts call it mid-render. */ +function Swatch({ kind, color, size }: { kind: SeriesLegendSwatch; color: string; size: string }) { + switch (kind) { + case "line": + return ( + + ) + case "dashed": + return ( + + ) + case "square": + return ( + + ) + case "dot": + return ( + + ) + } +} + +/** + * Per-series swatch, label and last value beside a chart. Hook-free: charts call + * it mid-render. Past `maxItems` the rest collapse into a "+N" count. + */ export function SeriesLegend({ items, variant = "inline", + swatch = "dot", + maxItems, className, }: { items: ReadonlyArray variant?: keyof typeof VARIANTS + swatch?: SeriesLegendSwatch + maxItems?: number className?: string }) { const styles = VARIANTS[variant] + const shown = maxItems === undefined ? items : items.slice(0, maxItems) + const overflow = items.length - shown.length return (
      - {items.map((item) => ( + {shown.map((item) => ( {item.color !== undefined && ( - + + )} + {item.label !== undefined && ( + + {item.label} + )} - {item.label !== undefined && {item.label}} {item.value !== undefined && {item.value}} ))} + {overflow > 0 ? +{overflow} : null}
      ) } diff --git a/apps/web/src/components/common/service-pills.tsx b/apps/web/src/components/common/service-pills.tsx index 91a8200c44..300013355d 100644 --- a/apps/web/src/components/common/service-pills.tsx +++ b/apps/web/src/components/common/service-pills.tsx @@ -17,13 +17,13 @@ export function ServicePills({ services }: { services: ReadonlyArray }) } className="flex min-w-0 flex-wrap gap-1"> {services.slice(0, VISIBLE_SERVICES).map((service) => ( - + {service} ))} {services.length > VISIBLE_SERVICES && ( - + +{services.length - VISIBLE_SERVICES} )} diff --git a/apps/web/src/components/common/sheet-detail-header.tsx b/apps/web/src/components/common/sheet-detail-header.tsx index 5b5e134aaa..2269ea783a 100644 --- a/apps/web/src/components/common/sheet-detail-header.tsx +++ b/apps/web/src/components/common/sheet-detail-header.tsx @@ -1,12 +1,14 @@ import type * as React from "react" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { SheetDescription, SheetHeader, SheetTitle } from "@maple/ui/components/ui/sheet" import { cn } from "@maple/ui/lib/utils" +import { DetailEyebrow, type DetailKind } from "./detail-header" + /** * Header of a peek/detail sheet: kind eyebrow, mono title, optional meta row. - * `pr-14` leaves room for the sheet's own close button. + * `pr-14` leaves room for the sheet's own close button. The sheet counterpart of + * `DetailHeader`, sharing its slot names and eyebrow. */ export function SheetDetailHeader({ kind, @@ -17,8 +19,8 @@ export function SheetDetailHeader({ mono = true, className, }: { - /** "Trace", "Pod", "Action". */ - kind?: React.ReactNode + /** "Trace", "Pod", or ["Action", "Run"]. */ + kind?: DetailKind title: React.ReactNode /** Status badge beside the title. */ adornment?: React.ReactNode @@ -31,7 +33,7 @@ export function SheetDetailHeader({ }) { return ( - {kind ? {kind} : null} + {kind !== undefined ? : null} = { +type StatRailColumns = 2 | 3 | 4 | 5 + +const COLUMNS_CLASS: Record = { + 2: "md:grid-cols-2", 3: "md:grid-cols-3", 4: "md:grid-cols-4", -} satisfies Record<3 | 4, string> + 5: "md:grid-cols-5", +} satisfies Record -export function StatRail({ children, columns = 4, className }: StatRailProps) { +export function StatRail({ children, columns = 4, className, "aria-busy": ariaBusy }: StatRailProps) { return (
      = { + sm: "text-xl", + md: "text-2xl tracking-tight", + lg: "text-[26px] tracking-[-0.01em]", +} satisfies Record + +const FIGURE_UNIT: Record = { + sm: "text-2xs", + md: "text-xs", + lg: "text-2xs", +} satisfies Record + +/** A headline number with a muted unit or caption riding its baseline; never wraps. */ +export function StatFigure({ + value, + unit, + size = "md", + mono = true, + className, + valueClassName, +}: { + value: React.ReactNode + unit?: React.ReactNode + size?: StatFigureSize + /** Mono figures for readouts; `false` sets the value in the display face. */ + mono?: boolean + className?: string + valueClassName?: string +}) { + return ( + + + {value} + + {unit ? {unit} : null} + + ) +} + interface StatRailItemProps { eyebrow: string /** Usually a formatted string; a node for a value with a unit or inline link. */ @@ -60,20 +116,12 @@ interface StatRailItemProps { valueClassName?: string /** Accessible name for the selectable tile when the eyebrow alone is ambiguous. */ ariaLabel?: string + /** `sm` is the dense tile: tighter padding, a smaller value and no reserved spark slot. */ + size?: "md" | "sm" + /** Muted text set inline after the value, e.g. a unit or "of 12 total". */ + hint?: React.ReactNode } -/** - * The active-tile marker, built the same way the sidebar builds its own: a 2px - * lane reserved on *every* tile, painted only on the selected one, with the - * corner squared so it reads as a rule against the tile edge rather than a - * rounded sliver floating inside it. Reserving the lane on all of them is what - * keeps the contents from shifting sideways as the selection moves. - * - * See `ACTIVE_RAIL` in `components/dashboard/app-sidebar.tsx` — same idiom, one - * level out. - */ -const RAIL_LANE = "before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:transition-colors" - export function StatRailItem({ eyebrow, value, @@ -91,13 +139,16 @@ export function StatRailItem({ className, valueClassName, ariaLabel, + size = "md", + hint, }: StatRailItemProps) { + const small = size === "sm" const body = ( <>
      @@ -105,41 +156,42 @@ export function StatRailItem({ {action ?? (delta ? ( - + {delta} ) : null)}
      -
      +
      {/* The value never wraps and never yields width; the sparkline gives way instead. A two-line "12m 45s" pushes its whole row taller than the tiles beside it, and the number is the thing the tile is for. */} -
      - {value} -
      + {spark && spark.length > 1 ? ( - - ) : compact ? null : ( + ) : compact || small ? null : (
      )}
      - {subline ? ( -
      {subline}
      - ) : null} + {subline ?
      {subline}
      : null} ) - const shell = cn("relative px-5 py-4 animate-in fade-in slide-in-from-bottom-1 duration-500", className) + const shell = cn( + "relative animate-in fade-in slide-in-from-bottom-1 duration-500", + small ? "px-4 py-3" : "px-5 py-4", + className, + ) const style = delay ? { animationDelay: `${delay}ms`, animationFillMode: "backwards" } : undefined if (!onSelect) { @@ -160,8 +212,7 @@ export function StatRailItem({ className={cn( shell, "w-full text-left", - RAIL_LANE, - selected ? "bg-muted/40 before:bg-primary" : "before:bg-transparent", + rowSelectedClass(selected), disabled ? "cursor-default" : "cursor-pointer hover:bg-muted/25", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", )} @@ -172,62 +223,25 @@ export function StatRailItem({ ) } -/** - * The ranked bar sparkline used inside a stat tile. Exported because the web - * analytics KPI strip draws the same bars full-bleed along the bottom of its own - * (selectable) tiles — same mark, different slot. - */ -export function BarSpark({ - values, - color, - className, -}: { - values: ReadonlyArray - color: string - className?: string -}) { - if (!values.length) return
      - const max = Math.max(...values, 0.0001) - const count = values.length - const gap = 2 - const barWidth = Math.max((100 - gap * (count - 1)) / count, 0.5) +/** One tile's placeholder, in the same shell as `StatRailItem`. */ +export function StatRailItemSkeleton({ className }: { className?: string }) { return ( - - {values.map((v, i) => { - const safe = Number.isFinite(v) && v >= 0 ? v : 0 - const ratio = max > 0 ? safe / max : 0 - const h = Math.max(ratio * 100, safe > 0 ? 5 : 0) - const x = i * (barWidth + gap) - const y = 100 - h - return ( - - ) - })} - +
      + +
      + + +
      + +
      ) } -export function StatRailLoading() { +export function StatRailLoading({ count = 4, columns }: { count?: number; columns?: StatRailColumns }) { return ( - - {Array.from({ length: 4 }).map((_, i) => ( -
      - -
      - - -
      - -
      + + {Array.from({ length: count }, (_, i) => ( + ))} ) diff --git a/apps/web/src/components/common/virtual-table-body.tsx b/apps/web/src/components/common/virtual-table-body.tsx new file mode 100644 index 0000000000..ebf6211618 --- /dev/null +++ b/apps/web/src/components/common/virtual-table-body.tsx @@ -0,0 +1,59 @@ +import type * as React from "react" +import type { Virtualizer } from "@tanstack/react-virtual" +import { LoadingMoreRow } from "@maple/ui/components/ui/list-footer" +import { TableBody } from "@maple/ui/components/ui/table" + +/** + * A virtualized `
    `: spacer rows above and below the rendered window keep + * the table its full height, and a loading row trails it while the next page + * loads. `renderRow` puts `measureRef` and `data-index={index}` on its row. + */ +export function VirtualTableBody({ + virtualizer, + rows, + renderRow, + colSpan, + loadingMore = false, + loadingLabel, + ref, +}: { + virtualizer: Virtualizer + rows: ReadonlyArray + renderRow: (row: R, index: number, measureRef: (node: Element | null) => void) => React.ReactNode + /** Column count, for the loading row. */ + colSpan: number + loadingMore?: boolean + loadingLabel?: string + /** For `usePageScrollMargin`, when the rows ride the page's scroller. */ + ref?: React.Ref +}) { + const items = virtualizer.getVirtualItems() + const first = items[0] + const last = items[items.length - 1] + const margin = virtualizer.options.scrollMargin + return ( + + {first ? ( + + + ) : null} + {items.map((item) => { + const row = rows[item.index] + return row === undefined ? null : renderRow(row, item.index, virtualizer.measureElement) + })} + {last ? ( + + + ) : null} + {loadingMore ? ( + + + + ) : null} + + ) +} diff --git a/apps/web/src/components/dashboard-builder/blank-dashboard-empty.tsx b/apps/web/src/components/dashboard-builder/blank-dashboard-empty.tsx index 900915808e..685ca5b24f 100644 --- a/apps/web/src/components/dashboard-builder/blank-dashboard-empty.tsx +++ b/apps/web/src/components/dashboard-builder/blank-dashboard-empty.tsx @@ -83,7 +83,7 @@ export function BlankDashboardEmpty({ {/* Templates instantiate into a *new* board, so say so here rather than letting the button read as "fill this one in for me". */} -

    +

    {readOnly ? "Read-only. You can’t add widgets to this dashboard." : "Templates create a new dashboard from this org’s data."} diff --git a/apps/web/src/components/dashboard-builder/config/chart-picker.tsx b/apps/web/src/components/dashboard-builder/config/chart-picker.tsx index a0a3326315..71200da271 100644 --- a/apps/web/src/components/dashboard-builder/config/chart-picker.tsx +++ b/apps/web/src/components/dashboard-builder/config/chart-picker.tsx @@ -95,7 +95,7 @@ function PickerCard({ {children}

    {title}
    - {description &&
    {description}
    } + {description &&
    {description}
    }
    ) diff --git a/apps/web/src/components/dashboard-builder/config/funnel-query-panel.tsx b/apps/web/src/components/dashboard-builder/config/funnel-query-panel.tsx index 9b42de5ce5..8edb465d27 100644 --- a/apps/web/src/components/dashboard-builder/config/funnel-query-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/funnel-query-panel.tsx @@ -1,3 +1,4 @@ +import { TONE_TEXT } from "@maple/ui/lib/tone" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Button } from "@maple/ui/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" @@ -105,7 +106,7 @@ export function FunnelQueryPanelView({
    Steps - + in order · session step only first · up to 10
    @@ -123,7 +124,7 @@ export function FunnelQueryPanelView({ {/* Population filter */}
    - Where + Where {/* The vocabulary is the `product_events` scope's; the data source is the editor's required prop and is not consulted under that scope, and the facet values come in explicitly. */} @@ -140,9 +141,9 @@ export function FunnelQueryPanelView({ ariaLabel="Funnel population filter" />
    -

    +

    {filterError ? ( - {filterError} + {filterError} ) : ( "Only persons with a session matching these dimensions take part." )} @@ -156,7 +157,7 @@ export function FunnelQueryPanelView({

    {funnel.addOns.keyBy && (
    - Count by + Count by [ @@ -227,7 +228,7 @@ export function FunnelQueryPanelView({ ) : null} - + the whole chain must complete within this long of step 1
    @@ -235,7 +236,7 @@ export function FunnelQueryPanelView({ {funnel.addOns.breakdown && (
    - Breakdown + Breakdown @@ -245,9 +246,7 @@ export function FunnelQueryPanelView({ }) } /> - - one bar per group, top 6 - + one bar per group, top 6
    )}
    diff --git a/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx b/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx index 3756f8044a..3f595df46e 100644 --- a/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react" import { Reorder, useDragControls } from "motion/react" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Input } from "@maple/ui/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { WIDGET_UNITS } from "@maple/domain/http" @@ -13,7 +14,7 @@ import { Switch } from "@maple/ui/components/ui/switch" import { SettingRow } from "@maple/ui/components/ui/setting-row" import { SegmentedSelect } from "@/components/common/segmented-select" import { getListPerformanceHints } from "@/lib/query-builder/performance-hints" -import { GripDotsIcon } from "@/components/icons" +import { GripDotsIcon, XmarkIcon } from "@/components/icons" import { listWhereClauseWarnings } from "@/lib/query-builder/widget-builder-shared" import { @@ -194,7 +195,7 @@ function DraggableColumnRow({ }) } > - + @@ -212,7 +213,7 @@ function DraggableColumnRow({ updateColumn(index, { align: value as "left" | "center" | "right" }) } > - + @@ -221,14 +222,14 @@ function DraggableColumnRow({ Right - + + ) } @@ -415,7 +416,7 @@ export function ListConfigPanel() {
    {slow.length > 0 && ( - + {slow.map((h) => (

    {h.reason}

    ))} @@ -424,7 +425,7 @@ export function ListConfigPanel() { )} {fast.length > 0 && ( - + {fast.map((h) => (

    {h.reason}

    ))} @@ -450,7 +451,7 @@ export function ListConfigPanel() { max={200} className="w-32" /> -

    +

    Max 200. Recommended: 25-50 for dashboard widgets.

    diff --git a/apps/web/src/components/dashboard-builder/config/markdown-editor-panel.tsx b/apps/web/src/components/dashboard-builder/config/markdown-editor-panel.tsx index 3f1d0d536f..209850a40e 100644 --- a/apps/web/src/components/dashboard-builder/config/markdown-editor-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/markdown-editor-panel.tsx @@ -18,7 +18,7 @@ export function MarkdownEditorPanel({ content, onChange }: MarkdownEditorPanelPr
    Content -

    +

    Markdown: # heading, **bold**, - list,{" "} [link](url)

    diff --git a/apps/web/src/components/dashboard-builder/config/paths-query-panel.tsx b/apps/web/src/components/dashboard-builder/config/paths-query-panel.tsx index d01e5b2230..89ce428593 100644 --- a/apps/web/src/components/dashboard-builder/config/paths-query-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/paths-query-panel.tsx @@ -1,3 +1,4 @@ +import { TONE_TEXT } from "@maple/ui/lib/tone" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Button } from "@maple/ui/components/ui/button" import { Input } from "@maple/ui/components/ui/input" @@ -98,7 +99,7 @@ export function PathsQueryPanelView({ paths, onUpdate, suggestions }: PathsQuery ]} onChange={(direction) => onUpdate((current) => ({ ...current, direction }))} /> - + {paths.direction === "after" ? "what people did next, from the first time they hit the anchor" : "how people got there, back from the last time they hit the anchor"} @@ -122,7 +123,7 @@ export function PathsQueryPanelView({ paths, onUpdate, suggestions }: PathsQuery {/* Depth + branches */}
    - Steps + Steps
    - Branches + Branches ({ ...current, branches: Number(branches) })) } /> - + named per step, the rest fold into Other
    @@ -153,7 +154,7 @@ export function PathsQueryPanelView({ paths, onUpdate, suggestions }: PathsQuery {/* Population filter */}
    - Where + Where
    -

    +

    {filterError ? ( - {filterError} + {filterError} ) : ( "Only persons with a session matching these dimensions take part." )} @@ -182,7 +183,7 @@ export function PathsQueryPanelView({ paths, onUpdate, suggestions }: PathsQuery

    {paths.addOns.keyBy && (
    - Count by + Count by [ @@ -231,7 +232,7 @@ export function PathsQueryPanelView({ paths, onUpdate, suggestions }: PathsQuery onUpdate((current) => ({ ...current, windowSeconds: seconds })) }} > - + @@ -249,13 +250,13 @@ export function PathsQueryPanelView({ paths, onUpdate, suggestions }: PathsQuery ) : null} - of the anchor + of the anchor
    )} {paths.addOns.include && (
    - Include + Include - Exclude + Exclude - + event names or page paths, comma-separated, dropped before sequencing
    diff --git a/apps/web/src/components/dashboard-builder/config/query-panel-shell.tsx b/apps/web/src/components/dashboard-builder/config/query-panel-shell.tsx index 4eaee3f1c9..41def1f2f1 100644 --- a/apps/web/src/components/dashboard-builder/config/query-panel-shell.tsx +++ b/apps/web/src/components/dashboard-builder/config/query-panel-shell.tsx @@ -1,12 +1,14 @@ import { useState, type ReactNode } from "react" import { Badge } from "@maple/ui/components/ui/badge" -import { Button } from "@maple/ui/components/ui/button" import { Checkbox } from "@maple/ui/components/ui/checkbox" +import { FilterChip } from "@maple/ui/components/ui/filter-chip" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { cn } from "@maple/ui/lib/utils" import { queryBadgeColor, type QueryBuilderDataSource } from "@maple/query-engine/query-builder" import { QUERY_BUILDER_DATA_SOURCES } from "@maple/query-model" +import { ChevronDownIcon, ChevronRightIcon } from "@/components/icons" // The chrome every query panel shares — the collapsible header with the // query's badge, its source select and its actions — and the add-on toggle @@ -25,15 +27,15 @@ export function CollapseToggle({ noun: string }) { return ( - + {collapsed ? : } + ) } @@ -97,7 +99,7 @@ export function QueryPanelShell({ {name} @@ -111,7 +113,8 @@ export function QueryPanelShell({ }} > @@ -149,20 +152,9 @@ export function AddOnToggleBar({ return (
    {items.map(({ key, label }) => ( - + ))}
    ) diff --git a/apps/web/src/components/dashboard-builder/config/query-panel.tsx b/apps/web/src/components/dashboard-builder/config/query-panel.tsx index d24eeadf02..de681b6d18 100644 --- a/apps/web/src/components/dashboard-builder/config/query-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/query-panel.tsx @@ -307,7 +307,7 @@ function TracesLogsBody({ }) } > - + @@ -325,7 +325,7 @@ function TracesLogsBody({ value={query.aggregation} onValueChange={(value) => onAggregationChange(value ?? query.aggregation)} > - + @@ -485,7 +485,7 @@ function MetricsBody({ value={query.aggregation} onValueChange={(value) => onAggregationChange(value ?? query.aggregation)} > - + @@ -524,7 +524,7 @@ function MetricsBody({ value={query.aggregation} onValueChange={(value) => onAggregationChange(value ?? query.aggregation)} > - + @@ -591,7 +591,7 @@ function AddOnSections({
    {query.addOns.groupBy && query.dataSource !== "metrics" && (
    - Group By + Group By onUpdate((current) => ({ ...current, groupBy: value }))} @@ -603,7 +603,7 @@ function AddOnSections({ {query.addOns.having && (
    - Having + Having @@ -620,7 +620,7 @@ function AddOnSections({ {query.addOns.orderBy && (
    - Order By + Order By @@ -642,7 +642,7 @@ function AddOnSections({ })) } > - + @@ -655,7 +655,7 @@ function AddOnSections({ {query.addOns.limit && (
    - Limit + Limit @@ -674,7 +674,7 @@ function AddOnSections({ {query.addOns.legend && (
    - Legend + Legend diff --git a/apps/web/src/components/dashboard-builder/config/raw-sql-editor-panel.tsx b/apps/web/src/components/dashboard-builder/config/raw-sql-editor-panel.tsx index 0f767f212f..902c9cea49 100644 --- a/apps/web/src/components/dashboard-builder/config/raw-sql-editor-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/raw-sql-editor-panel.tsx @@ -1,3 +1,5 @@ +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { rawSqlIssue } from "@maple/domain/raw-sql" import { useId, useRef, useState } from "react" @@ -5,6 +7,7 @@ import { useId, useRef, useState } from "react" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Input } from "@maple/ui/components/ui/input" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" import { useDashboardVariablesOptional } from "@/components/dashboard-builder/dashboard-variables-context" import type { DashboardWidget } from "@/components/dashboard-builder/types" @@ -85,17 +88,17 @@ export function RawSqlEditorPanel({ sql - ClickHouse + ClickHouse
    {sqlIssue !== null && !collapsed && ( - + {sqlIssue.code === "MissingOrgFilter" ? "Missing $__orgFilter" : sqlIssue.message} )} @@ -137,37 +140,52 @@ export function RawSqlEditorPanel({
    {MACRO_HINTS.map((hint) => ( - + + insertToken(hint.token)} + className="px-2 py-0.5 text-2xs rounded-sm bg-muted/40 text-muted-foreground font-mono transition-colors hover:bg-muted hover:text-foreground" + /> + } + > + {hint.token} + + + {hint.description} + + ))} {variableNames.map((name) => ( - + + insertToken(`$${name}`)} + className="px-2 py-0.5 text-2xs rounded-sm bg-primary/10 text-primary font-mono transition-colors hover:bg-primary/20" + /> + } + > + ${name} + + + Dashboard variable: expands to the selected value, e.g. + ServiceName IN (${name}). "All" expands to every value. + + ))}
    {showBucketControl && ( -
    - + - s -
    + s + )}
    @@ -200,7 +218,7 @@ export function RawSqlEditorPanel({ Run Preview )} - + Targets {targetLabel ?? widget?.visualization}
    diff --git a/apps/web/src/components/dashboard-builder/config/settings-fields.tsx b/apps/web/src/components/dashboard-builder/config/settings-fields.tsx index ef0a39aa80..0555a5e7b0 100644 --- a/apps/web/src/components/dashboard-builder/config/settings-fields.tsx +++ b/apps/web/src/components/dashboard-builder/config/settings-fields.tsx @@ -1,7 +1,9 @@ import { createContext, use, type ReactNode } from "react" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { Button } from "@maple/ui/components/ui/button" +import { eyebrowVariants } from "@maple/ui/components/ui/eyebrow" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { XmarkIcon } from "@/components/icons" import { Checkbox } from "@maple/ui/components/ui/checkbox" import { Input } from "@maple/ui/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" @@ -65,15 +67,16 @@ function useSettings() { } } -/** Dense rail setting: an eyebrow caption over a segmented control or select, not a form field. */ +// The eyebrow caption, restated at `sm:` because FieldLabel sets its own size there. +const RAIL_LABEL_CLASS = cn(eyebrowVariants({ variant: "label" }), "sm:text-2xs sm:leading-normal") + +/** Dense rail setting: an eyebrow caption labelling a segmented control, select or input. */ function RailSetting({ label, children }: { label: string; children: ReactNode }) { return ( -
    - - {label} - + + {label} {children} -
    + ) } @@ -114,17 +117,17 @@ function CheckboxRow({ onChange: (checked: boolean) => void }) { return ( -
    + onChange(next === true)} /> - -
    + + ) } @@ -241,7 +244,7 @@ function RampSwatch({ scale }: { scale: HeatmapColorScale }) { {[0, 1, 2, 3, 4].map((stop) => ( ))} @@ -524,15 +527,15 @@ function Thresholds() { }} className="h-8" /> - + +
    ))} - - - - - - {editingIndex === index && ( -
    - updateDraft(index, next)} - /> -
    - )} -
    - ))} - - - - - - - + + } + submitLabel="Save variables" + submitDisabled={hasErrors} + onSubmit={handleSave} + > + {startedEmpty && ( +
    +

    + A variable adds a selector to the toolbar; widgets that reference it re-query when the + selection changes. +

    +
      +
    • + Query — values from your telemetry + (services, environments, attribute values). +
    • +
    • + Custom — a fixed list you define. +
    • +
    • + Textbox — free text, e.g. a search + needle. +
    • +
    +
    + )} + {drafts.map((draft, index) => ( +
    + + + + ${draft.name || "…"} + + {TYPE_LABELS[draft.type]} + + + + {errors[index] !== null && editingIndex !== index ? ( + {errors[index]} + ) : ( + sourceSummary(draft) + )} + + + + move(index, -1)} + > + + + move(index, 1)} + > + + + setEditingIndex(editingIndex === index ? null : index)} + > + + + remove(index)}> + + + + + {editingIndex === index && ( +
    + updateDraft(index, next)} + /> +
    + )} +
    + ))} + + ) } @@ -322,12 +311,12 @@ function VariableForm({ onChange={(event) => onChange({ ...variable, name: event.target.value })} /> {error !== null ? ( - + {error} ) : ( variable.name !== "" && ( - + Reference it as ${variable.name} in widget filters and SQL. @@ -362,7 +351,7 @@ function VariableForm({ } }} > - + @@ -478,7 +467,7 @@ function QuerySourceFields({ } }} > - + diff --git a/apps/web/src/components/dashboard-builder/config/widget-query-builder-page.tsx b/apps/web/src/components/dashboard-builder/config/widget-query-builder-page.tsx index ce1aadaeff..fedf267c21 100644 --- a/apps/web/src/components/dashboard-builder/config/widget-query-builder-page.tsx +++ b/apps/web/src/components/dashboard-builder/config/widget-query-builder-page.tsx @@ -3,6 +3,7 @@ import { widgetTypeByVisualization } from "@maple/domain/http" import { dataSourceRawSql, dataSourceTransform, makeRawSqlDataSource } from "@maple/widgets/dashboard" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { FieldError } from "@maple/ui/components/ui/field" import { Button } from "@maple/ui/components/ui/button" import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" import { visualizationFor } from "@/components/dashboard-builder/widgets/types" @@ -474,7 +475,9 @@ export function WidgetQueryBuilderPage({ {mode === "rawSql" && showSourceToggle ? ( <> {rawSqlError && ( -

    {rawSqlError}

    + + {rawSqlError} + )} {validationError && ( -

    {validationError}

    + + {validationError} + )} {isMarkdown ? ( @@ -516,7 +521,7 @@ export function WidgetQueryBuilderPage({ - A + A
    ) : showFunnelPanel ? ( @@ -531,7 +536,7 @@ export function WidgetQueryBuilderPage({ - A + A
    ) : ( @@ -589,7 +594,7 @@ export function WidgetQueryBuilderPage({ - + {state.queries.map((q) => q.name).join(", ")} {state.formulas.length > 0 && `, ${state.formulas.map((f) => f.name).join(", ")}`} diff --git a/apps/web/src/components/dashboard-builder/history/dashboard-history-panel.tsx b/apps/web/src/components/dashboard-builder/history/dashboard-history-panel.tsx index cbd92c4554..a22f3a96bc 100644 --- a/apps/web/src/components/dashboard-builder/history/dashboard-history-panel.tsx +++ b/apps/web/src/components/dashboard-builder/history/dashboard-history-panel.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react" import type { DashboardId, DashboardVersionId } from "@maple/domain/http" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Result } from "@/lib/effect-atom" import { ErrorState } from "@/components/common/error-state" @@ -35,16 +35,15 @@ export function DashboardHistoryPanel({

    History

    - {versions.length} - +
    @@ -74,7 +73,7 @@ export function DashboardHistoryPanel({

    No history yet

    -

    +

    Each save is captured here so you can revisit or restore.

    diff --git a/apps/web/src/components/dashboard-builder/history/preview-banner.tsx b/apps/web/src/components/dashboard-builder/history/preview-banner.tsx index caadd9aded..cd7ad3a085 100644 --- a/apps/web/src/components/dashboard-builder/history/preview-banner.tsx +++ b/apps/web/src/components/dashboard-builder/history/preview-banner.tsx @@ -1,11 +1,11 @@ import { useState } from "react" import { Exit } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import type { DashboardId } from "@maple/domain/http" import { ArrowPathIcon, HistoryIcon } from "@/components/icons" import { RelativeTime } from "@/components/common/relative-time" +import { toastExit } from "@/lib/error-toast" import { buildRestorePayload, useRestoreDashboardVersion } from "./use-dashboard-history" import type { PreviewedVersion } from "@/atoms/dashboard-history-atoms" import { useDashboardMutationSync } from "@/hooks/use-dashboard-store" @@ -26,21 +26,22 @@ export function PreviewBanner({ dashboardId, preview, onCancel, onRestored }: Pr const performRestore = async () => { prepareForMutation() const result = await restore(buildRestorePayload(dashboardId, preview.versionId) as never) + const restored = toastExit(result, { + success: `Restored from v${preview.versionNumber}`, + error: "Restore failed", + }) if (Exit.isSuccess(result)) { void reconcileTxid(result.value.txid) - toastManager.add({ title: `Restored from v${preview.versionNumber}`, type: "success" }) onRestored() - return true } - toastManager.add({ title: "Restore failed", type: "error" }) - return false + return restored } return ( <>
    -
    +
    PREVIEW · diff --git a/apps/web/src/components/dashboard-builder/history/version-list-item.tsx b/apps/web/src/components/dashboard-builder/history/version-list-item.tsx index 0745d19e7a..8369f842eb 100644 --- a/apps/web/src/components/dashboard-builder/history/version-list-item.tsx +++ b/apps/web/src/components/dashboard-builder/history/version-list-item.tsx @@ -1,4 +1,7 @@ import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" import { RelativeTime } from "@/components/common/relative-time" import type { DashboardVersionChangeKind } from "@maple/domain/http" import type { V2DashboardVersion } from "@maple/domain/http/v2" @@ -76,14 +79,9 @@ export function VersionListItem({ version, isPreviewing, isCurrent, onPreview }: className={cn( "group relative w-full pl-7 pr-3 py-2.5 text-left transition-colors", "hover:bg-muted/40", - isPreviewing && "bg-primary/5", + rowSelectedClass(isPreviewing), )} > - {/* Vertical rail accent for the active row */} - {isPreviewing && ( - - )} - {/* Marker dot anchored to the rail */}
    - {KIND_LABEL[version.changeKind]} - + {isCurrent && ( - + Current - + )} {version.sourceVersionId && ( )}
    -
    - {summary} -
    -
    +
    {summary}
    +
    {initials.slice(0, 1)} diff --git a/apps/web/src/components/dashboard-builder/list-section-header.tsx b/apps/web/src/components/dashboard-builder/list-section-header.tsx index 1f10ee98dd..3ef755e3da 100644 --- a/apps/web/src/components/dashboard-builder/list-section-header.tsx +++ b/apps/web/src/components/dashboard-builder/list-section-header.tsx @@ -21,11 +21,11 @@ export function ListSectionHeader({ {title} - + {count} - {note && {note}} + {note && {note}}
    ) } diff --git a/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx b/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx index 2c5e629e16..21f04dcf68 100644 --- a/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx +++ b/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx @@ -1,13 +1,15 @@ import { useMemo, useState } from "react" import { Link } from "@tanstack/react-router" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" -import { ToolbarSearch } from "@maple/ui/components/toolbar" +import { Toolbar, ToolbarSearch } from "@maple/ui/components/toolbar" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" import { MultiSelectCombobox } from "@maple/ui/components/multi-select-combobox" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { RelativeTime } from "@/components/common/relative-time" import { DropdownMenu, @@ -28,7 +30,6 @@ import { ChartBarIcon, CircleWarningIcon, CodeIcon, - DotsIcon, FileIcon, GridIcon, MagnifierIcon, @@ -142,13 +143,13 @@ function DashboardRow({ {dashboard.name} {/* Below sm the scope lane collapses onto this line, so the row keeps its information when the column can't fit. */} - + {[empty ? "no widgets" : widgetCountLabel(dashboard), reads.short] .filter(Boolean) .join(" · ")} {dashboard.description && ( - + {dashboard.description} )} @@ -159,36 +160,36 @@ function DashboardRow({ made the row crowded without changing any decision. */} {empty ? "no widgets" : widgetCountLabel(dashboard)} - + {empty ? "" : reads.short} - - {formatRelativeTimeOrDate(dashboard.updatedAt)} - + {/* Always rendered, low-contrast when off: a hover-only star hides the primary organising affordance from touch and keyboard users. */}
    - + void }) { return ( - - - } - > - - - - - {/* Export reads the payload the list already holds, so it survives a - persistence error. Duplicate and delete write, so they don't. */} - onExport(dashboard)}>Export JSON - onDuplicate(dashboard)}> - Duplicate - - {/* Editing, not display: the row deliberately carries no tag chips - (see the row comment above), so this is the only way in from here. */} - onEditTags(dashboard)}> - Edit tags… - - - onDelete(dashboard)} - > - Delete… - - {/* States the reason in the menu itself. A Tooltip here would need a - wrapper element, which breaks the menu's roving-index nav. */} - {readOnly && ( - <> - - - Dashboard store unreachable — writes are disabled - - - )} - - - + + + {/* Export reads the payload the list already holds, so it survives a + persistence error. Duplicate and delete write, so they don't. */} + onExport(dashboard)}>Export JSON + onDuplicate(dashboard)}> + Duplicate + + {/* Editing, not display: the row deliberately carries no tag chips + (see the row comment above), so this is the only way in from here. */} + onEditTags(dashboard)}> + Edit tags… + + + onDelete(dashboard)} + > + Delete… + + {/* States the reason in the menu itself. A Tooltip here would need a + wrapper element, which breaks the menu's roving-index nav. */} + {readOnly && ( + <> + + + Dashboard store unreachable — writes are disabled + + + )} + + ) } @@ -365,7 +352,7 @@ export function DashboardList({ // otherwise compress below its content, spilling the rows past its own // box so the bottom padding lands behind them instead of after them.
    -
    + - + {filtering || scope === "favorites" ? `${scoped} of ${dashboards.length}` : `${dashboards.length} of ${dashboards.length}`} -
    + {filtering && !nothingAtAll && ( -
    +
    {matched} of {dashboards.length} match name, description or tag @@ -602,9 +589,7 @@ function TagFilterMenu({ <> Tags {selected.length > 0 && ( - - {selected.length} - + {selected.length} )} } @@ -626,7 +611,7 @@ function CreateRow({ readOnly, onCreate }: { readOnly: boolean; onCreate: () => New dashboard - + Start blank, or pick a template already wired to metrics you send. diff --git a/apps/web/src/components/dashboard-builder/sections/dashboard-section.tsx b/apps/web/src/components/dashboard-builder/sections/dashboard-section.tsx index 57a6ee047b..e916820137 100644 --- a/apps/web/src/components/dashboard-builder/sections/dashboard-section.tsx +++ b/apps/web/src/components/dashboard-builder/sections/dashboard-section.tsx @@ -1,15 +1,10 @@ import { useState } from "react" -import { ChevronDownIcon } from "@maple/ui/components/icons" import { Button } from "@maple/ui/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" import { cn } from "@maple/ui/lib/utils" -import { ArrowUpIcon, DotsVerticalIcon, PlusIcon, TrashIcon } from "@/components/icons" +import { ArrowUpIcon, ChevronDownIcon, PlusIcon, TrashIcon } from "@/components/icons" import { DashboardGrid, @@ -94,18 +89,17 @@ export function DashboardSectionView({ a control that can't do anything reads as broken. The spacer keeps its title aligned with every collapsible sibling's. */} {collapsible ? ( - + ) : (
    )} @@ -134,79 +128,65 @@ export function DashboardSectionView({
    {editable && !collapsed && ( - + )} {editable && ( - - - - - } - /> - - actions?.addTab(section.id)}> - - Add tab - - actions?.reorderSections(index, index - 1)} - > - - Move up - - actions?.reorderSections(index, index + 1)} - > - - Move down - - - {/* The stored default, distinct from this viewer's own - collapse — which lives in their URL and changes nothing - for anyone else. Meaningless on a pinned-open group, so - it is hidden rather than left there doing nothing. */} - {collapsible && ( - - actions?.setSectionCollapsedDefault( - section.id, - !(section.collapsed ?? false), - ) - } - > - {section.collapsed ? "Expanded by default" : "Collapsed by default"} - - )} - actions?.setSectionCollapsible(section.id, !collapsible)} - > - {collapsible ? "Always expanded" : "Allow collapsing"} - - + + actions?.addTab(section.id)}> + + Add tab + + actions?.reorderSections(index, index - 1)} + > + + Move up + + actions?.reorderSections(index, index + 1)} + > + + Move down + + + {/* The stored default, distinct from this viewer's own + collapse — which lives in their URL and changes nothing + for anyone else. Meaningless on a pinned-open group, so + it is hidden rather than left there doing nothing. */} + {collapsible && ( setDeleteSectionOpen(true)} + onClick={() => + actions?.setSectionCollapsedDefault( + section.id, + !(section.collapsed ?? false), + ) + } > - - Delete group… + {section.collapsed ? "Expanded by default" : "Collapsed by default"} - - + )} + actions?.setSectionCollapsible(section.id, !collapsible)} + > + {collapsible ? "Always expanded" : "Allow collapsing"} + + + setDeleteSectionOpen(true)} + > + + Delete group… + + )}
    diff --git a/apps/web/src/components/dashboard-builder/sections/section-tab-bar.tsx b/apps/web/src/components/dashboard-builder/sections/section-tab-bar.tsx index 854ce7aedc..11bc577b45 100644 --- a/apps/web/src/components/dashboard-builder/sections/section-tab-bar.tsx +++ b/apps/web/src/components/dashboard-builder/sections/section-tab-bar.tsx @@ -1,5 +1,5 @@ import { cn } from "@maple/ui/lib/utils" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { PlusIcon, TrashIcon } from "@/components/icons" import { InlineEditableText } from "@/components/dashboard-builder/sections/inline-editable-text" @@ -66,29 +66,27 @@ export function SectionTabBar({ just switching tabs, never sees a destructive control. The last tab can't be deleted — that's deleting the group. */} {editable && tabs.length > 1 && ( - + )}
    ) })} {editable && ( - + )}
    ) diff --git a/apps/web/src/components/dashboard-builder/tag-editor.tsx b/apps/web/src/components/dashboard-builder/tag-editor.tsx index dd0a883dd7..b93be2cc07 100644 --- a/apps/web/src/components/dashboard-builder/tag-editor.tsx +++ b/apps/web/src/components/dashboard-builder/tag-editor.tsx @@ -2,18 +2,10 @@ import { useState } from "react" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogTitle, -} from "@maple/ui/components/ui/dialog" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Input } from "@maple/ui/components/ui/input" -import { Label } from "@maple/ui/components/ui/label" -import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { PlusIcon, XmarkIcon } from "@/components/icons" @@ -51,47 +43,21 @@ export function TagEditorDialog({ onOpenChange: (open: boolean) => void dashboardName: string tags: ReadonlyArray - /** Tags already in use across the org — offered as one-click adds. */ + /** Tags already in use across the org, offered as one-click adds. */ suggestions?: ReadonlyArray onSave: (tags: string[]) => void }) { - return ( - - - {/* Mounted only while open so each session starts from the saved tags, - matching `VariablesManagerDialog`. */} - {open && ( - onOpenChange(false)} - onSave={(next) => { - onSave(next) - onOpenChange(false) - }} - /> - )} - - - ) -} - -function TagEditor({ - dashboardName, - initial, - suggestions, - onCancel, - onSave, -}: { - dashboardName: string - initial: ReadonlyArray - suggestions: ReadonlyArray - onCancel: () => void - onSave: (tags: string[]) => void -}) { - const [drafts, setDrafts] = useState(() => normalizeTags(initial)) + const [drafts, setDrafts] = useState(() => normalizeTags(tags)) const [pending, setPending] = useState("") + // Each opening starts from the saved tags, matching `VariablesManagerDialog`. + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) { + setDrafts(normalizeTags(tags)) + setPending("") + } + } const commit = (value: string) => { const added = normalizeTags(splitInput(value)) @@ -106,118 +72,113 @@ function TagEditor({ const unused = normalizeTags(suggestions).filter((tag) => !drafts.includes(tag)) return ( - <> - - Tags - + Group {dashboardName} with related dashboards. Tags drive the Tags filter on the dashboards list. - - - - - - Add a tag - -
    - setPending(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === ",") { - // Enter must not reach the dialog's default submit — the - // pending tag isn't saved yet. - event.preventDefault() - commit(pending) - return - } - // Backspace on an empty field pops the last chip, the - // convention every tag input shares. - if (event.key === "Backspace" && pending === "" && drafts.length > 0) { - event.preventDefault() - remove(drafts[drafts.length - 1]!) - } - }} - // Committing on blur means a typed tag is never silently lost by - // clicking Save directly. - onBlur={() => commit(pending)} - /> - -
    -
    - -
    - - {drafts.length === 0 ? ( -

    - No tags yet — this dashboard won't appear under any Tags filter. -

    - ) : ( -
    - {drafts.map((tag) => ( - - {tag} - - - ))} -
    - )} + + } + submitLabel="Save" + // Commits `pending` first: onBlur fires before click on most browsers + // but not all, and losing a just-typed tag on Save is unforgiving. + onSubmit={() => { + onSave(normalizeTags([...drafts, ...splitInput(pending)])) + onOpenChange(false) + }} + > + + + Add a tag + +
    + setPending(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === ",") { + // Enter must not reach the dialog's default submit — the + // pending tag isn't saved yet. + event.preventDefault() + commit(pending) + return + } + // Backspace on an empty field pops the last chip, the + // convention every tag input shares. + if (event.key === "Backspace" && pending === "" && drafts.length > 0) { + event.preventDefault() + remove(drafts[drafts.length - 1]!) + } + }} + // Committing on blur means a typed tag is never silently lost by + // clicking Save directly. + onBlur={() => commit(pending)} + /> + commit(pending)} + > + +
    +
    - {unused.length > 0 && ( -
    - -
    - {unused.map((tag) => ( - - ))} -
    + + + + ))}
    )} - - - - {/* Commits `pending` first: onBlur fires before click on most browsers - but not all, and losing a just-typed tag on Save is unforgiving. */} - - - + + + {unused.length > 0 && ( + + Used elsewhere +
    + {unused.map((tag) => ( + + ))} +
    +
    + )} + ) } diff --git a/apps/web/src/components/dashboard-builder/templates/template-detail-panel.tsx b/apps/web/src/components/dashboard-builder/templates/template-detail-panel.tsx index 4f8f7b9c6f..aad854a639 100644 --- a/apps/web/src/components/dashboard-builder/templates/template-detail-panel.tsx +++ b/apps/web/src/components/dashboard-builder/templates/template-detail-panel.tsx @@ -4,7 +4,7 @@ import type { V2DashboardTemplate } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Input } from "@maple/ui/components/ui/input" -import { Label } from "@maple/ui/components/ui/label" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { cn } from "@maple/ui/lib/utils" import { ArrowRightIcon, CheckIcon, ExternalLinkIcon } from "@/components/icons" import { templateIcon } from "./template-icons" @@ -69,13 +69,13 @@ function RequirementBlock({ : `no matching metrics in ${READINESS_WINDOW_LABEL}`} {requirement.kind === "metrics" && readiness !== undefined && ( - + {readiness.metricCount} {readiness.metricCount === 1 ? "metric" : "metrics"} {age !== null && ` · last seen ${age}`} )}
    -

    +

    {ready ? "Collected by" : "Emitted by"} {requirement.collector}. {!ready && requirement.hint ? ` ${requirement.hint}` : ""}

    @@ -128,7 +128,7 @@ export function TemplateDetailPanel({ template, readiness, creating, onCreate }:

    {template.name}

    -
    +
    {CATEGORY_LABELS[template.category] ?? template.category} / @@ -145,9 +145,7 @@ export function TemplateDetailPanel({ template, readiness, creating, onCreate }:
    -

    - That's the dashboard you'd get right now. -

    +

    That's the dashboard you'd get right now.

    @@ -157,12 +155,12 @@ export function TemplateDetailPanel({ template, readiness, creating, onCreate }: Parameters
    {template.parameters.map((parameter) => ( -
    +
    - - + + {parameter.required ? "required" : "optional"}
    @@ -181,11 +179,11 @@ export function TemplateDetailPanel({ template, readiness, creating, onCreate }: }} /> {parameter.description && ( -

    + {parameter.description} -

    + )} -
    + ))}
    @@ -233,7 +231,7 @@ export function TemplateDetailPanel({ template, readiness, creating, onCreate }: )} -

    +

    {missingRequired.length > 0 ? `Fill in ${missingRequired.join(" and ")} first.` : ready diff --git a/apps/web/src/components/dashboard-builder/templates/template-list.tsx b/apps/web/src/components/dashboard-builder/templates/template-list.tsx index 1df42db1d1..e940e59b78 100644 --- a/apps/web/src/components/dashboard-builder/templates/template-list.tsx +++ b/apps/web/src/components/dashboard-builder/templates/template-list.tsx @@ -1,10 +1,12 @@ import { useMemo } from "react" import type { V2DashboardTemplate } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" -import { ToolbarSearch } from "@maple/ui/components/toolbar" +import { Toolbar, ToolbarSearch } from "@maple/ui/components/toolbar" import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { cn } from "@maple/ui/lib/utils" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { ArrowRightIcon, CircleCheckIcon, @@ -67,15 +69,12 @@ function TemplateRow({ onClick={onSelect} aria-current={selected ? "true" : undefined} className={cn( - "flex w-full items-center text-left transition-colors", - selected ? "bg-muted" : "hover:bg-muted/50", + "flex min-h-9 w-full items-center text-left transition-colors", + rowSelectedClass(selected), + !selected && "hover:bg-muted/50", )} > - - + {template.name} - + {widgetCountLabel(template)} - - {status} - + {/* The lane is fixed so statuses form a column; long metric + prefixes truncate and the tooltip recovers the full one. */} + ) @@ -162,7 +160,7 @@ export function TemplateList({ return (

    -
    + -
    + {searching && !loading && ( -
    +
    {matched} of {total} match name, description or tag
    @@ -276,7 +274,7 @@ export function TemplateList({ /> {gatedByCategory.map(({ category, templates: group }) => (
    -
    +
    {CATEGORY_LABELS[category] ?? category}
    {group.map((template) => ( @@ -306,7 +304,7 @@ export function TemplateList({ Blank dashboard - start from nothing + start from nothing diff --git a/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx b/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx index 06c15b767f..0473a1c045 100644 --- a/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx +++ b/apps/web/src/components/dashboard-builder/toolbar/dashboard-toolbar.tsx @@ -4,7 +4,6 @@ import { PencilIcon, CheckIcon, GridIcon, - DotsVerticalIcon, DownloadIcon, HistoryIcon, BracketsCurlyIcon, @@ -14,13 +13,8 @@ import { } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@maple/ui/components/ui/dropdown-menu" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu" import { TimeRangePicker } from "@/components/time-range-picker/time-range-picker" import { PageRefreshControls } from "@/components/time-range-picker/refresh-controls" import { VariableSelects } from "@/components/dashboard-builder/toolbar/variable-selects" @@ -135,76 +129,66 @@ export function DashboardToolbar({ )} {isEdit ? "Done" : "Edit"} - - } - > - - - - {isEdit && ( - - - Auto Layout - - )} - {isEdit && ( - addSection()} - disabled={readOnly} - className="whitespace-nowrap" - > - - Add group - - )} - {isEdit && ( - setVariablesDialogOpen(true)} - disabled={readOnly} - className="whitespace-nowrap" - > - - Variables - - )} - {isEdit && ( - setTagsDialogOpen(true)} - disabled={readOnly} - className="whitespace-nowrap" - > - - Tags - - )} - {onOpenHistory && ( - - - Version history - - )} - {(isEdit || onOpenHistory) && } + + {isEdit && ( setShareDialogOpen(true)} + onClick={autoLayoutWidgets} + disabled={readOnly} className="whitespace-nowrap" > - - Share… + + Auto Layout + )} + {isEdit && ( downloadPortableDashboard(dashboard)} + onClick={() => addSection()} + disabled={readOnly} className="whitespace-nowrap" > - - Export as JSON + + Add group - - + )} + {isEdit && ( + setVariablesDialogOpen(true)} + disabled={readOnly} + className="whitespace-nowrap" + > + + Variables + + )} + {isEdit && ( + setTagsDialogOpen(true)} + disabled={readOnly} + className="whitespace-nowrap" + > + + Tags + + )} + {onOpenHistory && ( + + + Version history + + )} + {(isEdit || onOpenHistory) && } + setShareDialogOpen(true)} className="whitespace-nowrap"> + + Share… + + downloadPortableDashboard(dashboard)} + className="whitespace-nowrap" + > + + Export as JSON + +
    )} {(listError ?? error) ? ( -

    {listError ?? error}

    +

    {listError ?? error}

    ) : null} @@ -246,7 +249,7 @@ function NotPublicNotice({ onMakePublic: () => void }) { return ( -
    +
    @@ -272,7 +275,7 @@ function NotPublicNotice({ Make dashboard public
    -
    +
    ) } @@ -365,7 +368,7 @@ function EmbedUrlOptions() {
    {/* Three fixed columns on every row — icon, parameter, details — so names, descriptions and examples line up down the list whatever their length. */} -
      +
        {options.map(({ key, icon: Icon, param, description, example, unavailable }) => (
      • - - {param} - + {param} {description} - + {example}
      • diff --git a/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx b/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx index d64ac56368..ee4a5d72ce 100644 --- a/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx +++ b/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx @@ -11,22 +11,19 @@ * org member's shape stream — so there is no txid to await and `awaitTxId` * would hang forever. */ -import { useMemo, useRef, useState, type ReactNode } from "react" +import { useMemo, useState, type ReactNode } from "react" import { Exit } from "effect" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { ArrowRotateClockwiseIcon, - CheckIcon, CircleWarningIcon, - CopyIcon, GlobeIcon, - LinkIcon, LockIcon, ShieldIcon, } from "@/components/icons" -import { Alert } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" -import { useCopy } from "@maple/ui/hooks/use-copy" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { CopyableField } from "@maple/ui/components/ui/copyable-field" import { Dialog, DialogClose, @@ -39,6 +36,7 @@ import { } from "@maple/ui/components/ui/dialog" import { RadioGroup, RadioGroupItem } from "@maple/ui/components/ui/radio-group" import { cn } from "@maple/ui/lib/utils" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { displayError } from "@/lib/error-messages" import { useAsyncAction } from "@/hooks/use-mutation-action" @@ -169,7 +167,7 @@ export function ShareDashboardDialog({ if (value === "off") void stopSharing() else void share(value as ShareMode) }} - className="gap-0 divide-y divide-border overflow-hidden rounded-lg border" + className="gap-0 divide-y divide-border overflow-hidden rounded-md border" > @@ -271,9 +269,8 @@ function NoticeRow({ tone = "muted", children }: { tone?: "muted" | "error"; chi } /** - * Replacing kills the current link for good — every copy of it already handed - * out, every embed on someone else's page. So it asks first, inline: a second - * modal over the dialog would be heavier than the question. + * Replacing kills the current link for good: every copy of it already handed + * out, every embed on someone else's page. So it asks first. */ export function ShareLinkRow({ url, @@ -286,81 +283,27 @@ export function ShareLinkRow({ replaceWarning: string }) { const [confirmingReplace, setConfirmingReplace] = useState(false) - const [copyBlocked, setCopyBlocked] = useState(false) - const field = useRef(null) - - // When even the fallback write fails, select the field so ⌘C still works. - const { copy, copied } = useCopy({ - label: "Share link", - toast: false, - onCopy: () => setCopyBlocked(false), - onError: () => { - setCopyBlocked(true) - field.current?.select() - }, - }) return ( -
        -
        -
        - - {/* A real input rather than a span: the link stays selectable, which is - what makes the clipboard fallback above worth anything. */} - event.currentTarget.select()} - className="min-w-0 flex-1 truncate bg-transparent outline-none" - /> -
        - - +
        +
        +
        - {confirmingReplace ? ( - -

        - Replace this link?{" "} - {replaceWarning} This can't be undone. -

        -
        - - -
        -
        - ) : null} - {copyBlocked ? ( -

        - Your browser blocked the clipboard. The link is selected — copy it with ⌘C. -

        - ) : null} - - {copied ? "Share link copied to clipboard" : ""} - + + { + setConfirmingReplace(false) + onReplace() + }} + />
        ) } diff --git a/apps/web/src/components/dashboard-builder/toolbar/variable-selects.tsx b/apps/web/src/components/dashboard-builder/toolbar/variable-selects.tsx index 0d0d8d64ba..29885d8bff 100644 --- a/apps/web/src/components/dashboard-builder/toolbar/variable-selects.tsx +++ b/apps/web/src/components/dashboard-builder/toolbar/variable-selects.tsx @@ -139,7 +139,7 @@ function VariableControl({ item === ALL_VALUE ? ( All - + no filter diff --git a/apps/web/src/components/dashboard-builder/widgets/chart-preview.tsx b/apps/web/src/components/dashboard-builder/widgets/chart-preview.tsx index 970afcf7f7..3acb1037cf 100644 --- a/apps/web/src/components/dashboard-builder/widgets/chart-preview.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/chart-preview.tsx @@ -1,6 +1,6 @@ import { Suspense, type ComponentType } from "react" import type { PlotProps } from "@maple/ui/components/charts/_shared/chart-types" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ChartSkeleton } from "@maple/ui/components/charts/_shared/chart-skeleton" interface ChartPreviewProps { /** @@ -24,7 +24,7 @@ interface ChartPreviewProps { export function ChartPreview({ component: Component, data }: ChartPreviewProps) { return (
        - }> + }>
        diff --git a/apps/web/src/components/dashboard-builder/widgets/stat-widget.tsx b/apps/web/src/components/dashboard-builder/widgets/stat-widget.tsx index fb53988d4e..bd82d55e3c 100644 --- a/apps/web/src/components/dashboard-builder/widgets/stat-widget.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/stat-widget.tsx @@ -1,7 +1,7 @@ import { createContext, memo, type ReactNode, use, useMemo } from "react" import { ChartSkeleton } from "@maple/ui/components/charts/_shared/chart-skeleton" import { StatSparkline } from "@maple/ui/components/charts/sparkline/stat-sparkline" -import { formatValueByUnit } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatValueByUnit } from "@maple/ui/lib/format" import { WidgetFrame } from "@/components/dashboard-builder/widgets/widget-shell" import { useWidgetDataSource, type WidgetDataSourceLike } from "@/hooks/use-widget-data" import type { WidgetDataState, WidgetDisplayConfig, WidgetMode } from "@/components/dashboard-builder/types" @@ -13,8 +13,8 @@ interface StatWidgetProps { } export function formatValue(value: unknown, unit?: string, prefix?: string, suffix?: string): string { - if (value === null || value === undefined) return "-" - if (typeof value === "object") return "—" + if (value === null || value === undefined) return EMPTY_VALUE + if (typeof value === "object") return EMPTY_VALUE const num = typeof value === "number" ? value : Number(value) if (Number.isNaN(num)) return String(value) diff --git a/apps/web/src/components/dashboard-builder/widgets/table-widget.test.ts b/apps/web/src/components/dashboard-builder/widgets/table-widget.test.ts index 33b8dfd17c..3d9cba1c6c 100644 --- a/apps/web/src/components/dashboard-builder/widgets/table-widget.test.ts +++ b/apps/web/src/components/dashboard-builder/widgets/table-widget.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { formatValueByUnit } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatValueByUnit } from "@maple/ui/lib/format" import { buildRowKeys, formatCellValue } from "./table-widget" @@ -14,7 +14,7 @@ describe("formatCellValue", () => { it("leaves non-numeric and unitless values untouched", () => { expect(formatCellValue("checkout", "percent")).toBe("checkout") - expect(formatCellValue(null, "percent")).toBe("-") + expect(formatCellValue(null, "percent")).toBe(EMPTY_VALUE) expect(formatCellValue("raw", undefined)).toBe("raw") }) diff --git a/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx b/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx index 93d05a64fa..5b7b2eb030 100644 --- a/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx @@ -6,7 +6,7 @@ import { WidgetEmptyState, WidgetFrame } from "@/components/dashboard-builder/wi import type { WidgetDataState, WidgetDisplayConfig, WidgetMode } from "@/components/dashboard-builder/types" import { SortableHeader } from "@/components/common/sortable-header" import { useTableSort } from "@/hooks/use-table-sort" -import { formatValueByUnit } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatValueByUnit } from "@maple/ui/lib/format" interface TableWidgetProps { dataState: WidgetDataState @@ -28,7 +28,7 @@ interface TableWidgetProps { * counts, where abbreviating `1234` to "1.2K" destroys the value being read. */ export function formatCellValue(value: unknown, unit?: string): string { - if (value == null) return "-" + if (value == null) return EMPTY_VALUE const num = Number(value) if (Number.isNaN(num)) return String(value) if (!unit) return String(value) @@ -165,9 +165,7 @@ export const TableWidget = memo(function TableWidget({ mode={mode} contentClassName="flex-1 min-h-0 overflow-auto p-0" footer={truncated ? `Top ${rowLimit} rows` : undefined} - loadingSkeleton={ - - } + loadingSkeleton={} > {rows.length === 0 ? ( diff --git a/apps/web/src/components/dashboard-builder/widgets/types/markdown.tsx b/apps/web/src/components/dashboard-builder/widgets/types/markdown.tsx index 6c5513078e..43b9b69414 100644 --- a/apps/web/src/components/dashboard-builder/widgets/types/markdown.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/types/markdown.tsx @@ -24,7 +24,7 @@ function MarkdownPresetPreview({ preset }: { preset: WidgetPresetDefinition }) { // eslint-disable-next-line react/no-array-index-key -- fixed sample content
        {line.replace(/^#+\s*/, "").replace(/[*`]/g, "")}
        diff --git a/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx b/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx index 58cb3d1225..39483b62b5 100644 --- a/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx @@ -18,7 +18,7 @@ export function PreviewFrame({ }) { return (
        -
        {title}
        +
        {title}
        {children}
        ) @@ -58,7 +58,7 @@ export function rowsPresetPreview(sampleRows: Record ( {column.header} @@ -73,7 +73,7 @@ export function rowsPresetPreview(sampleRows: Record ( {formatCellValue(row[column.field], column.unit)} diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx index f361a8ba07..ac79cec992 100644 --- a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type ReactNode } from "react" import { cn } from "@maple/ui/lib/utils" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { PlotLegendSlotContext, type PlotLegendItem } from "@maple/ui/components/plot" import { GripDotsIcon, @@ -162,7 +163,7 @@ export function WidgetShell({ )} {timeRangeLabel && ( @@ -185,7 +186,7 @@ export function WidgetShell({ {visible.map((item) => ( {/* A dashed outline, not a filled square, when the series is painted as a dashed stroke — an errors overlay drawn dashed @@ -193,7 +194,7 @@ export function WidgetShell({ the chart does not. Mirrors `FixedMetricLegend`. */} 0 && ( i.label).join(", ")} > +{overflow} @@ -242,7 +243,7 @@ export function WidgetShell({ + } @@ -316,7 +317,7 @@ export function WidgetShell({ {children} {footer != null && ( -
        {footer}
        +
        {footer}
        )} ) @@ -346,7 +347,7 @@ export function WidgetEmptyState() { return ( No data in the selected time range - + Widen the time range or check the query's filters. @@ -392,7 +393,7 @@ export function WidgetFrame({ (dataState.kind === "range" ? "Range too wide" : "Not configured")}
        {dataState.message && ( - + {dataState.message} )} @@ -401,7 +402,7 @@ export function WidgetFrame({ variant="outline" size="xs" onClick={narrowRange} - className="mt-1 h-6 gap-1 text-[10px]" + className="mt-1 h-6 gap-1 text-3xs" > {actions?.narrowRangeLabel ?? "Narrow range"} @@ -412,7 +413,7 @@ export function WidgetFrame({ variant="outline" size="xs" onClick={configure} - className="mt-1 h-6 gap-1 text-[10px]" + className="mt-1 h-6 gap-1 text-3xs" > Edit @@ -421,11 +422,16 @@ export function WidgetFrame({
        ) : (
        - + {dataState.title ?? "Unable to load"} {dataState.message && ( - + {dataState.message} )} @@ -434,7 +440,7 @@ export function WidgetFrame({ variant="outline" size="xs" onClick={fix} - className="mt-1 h-6 gap-1 text-[10px]" + className="mt-1 h-6 gap-1 text-3xs" > Fix with AI diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index f3021e68e3..80e601a901 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -59,6 +59,7 @@ import { useSidebar, } from "@maple/ui/components/ui/sidebar" import { Badge } from "@maple/ui/components/ui/badge" +import { ROW_LANE } from "@maple/ui/components/ui/list-row" import { Kbd } from "@maple/ui/components/ui/kbd" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { clearSelfHostedSessionToken } from "@/lib/services/common/self-hosted-auth" @@ -75,8 +76,7 @@ import { useInfraSurfaces } from "@/hooks/use-infra-surfaces" * tokens.css and consumed by nothing before this; it is the one thing that * distinguishes "selected" from "hovered", which otherwise share a fill. */ -const RAIL_LANE = "relative before:absolute before:inset-y-0 before:left-0 before:w-0.5" -const ACTIVE_RAIL = `${RAIL_LANE} data-[active=true]:rounded-l-none data-[active=true]:before:bg-sidebar-primary data-[active=true]:text-sidebar-primary` +const ACTIVE_RAIL = `${ROW_LANE} data-[active=true]:rounded-l-none data-[active=true]:before:bg-sidebar-primary data-[active=true]:text-sidebar-primary` /** Group labels sit below the items they name, not level with them. */ const GROUP_LABEL = "h-6 text-muted-foreground" @@ -363,7 +363,7 @@ function NavRow({ return ( } tooltip={item.title} @@ -391,7 +391,7 @@ function NavRow({ {item.badge ? ( - + {item.badge} @@ -486,7 +486,7 @@ function PinnedGroup({ currentPath }: { currentPath: string }) { {visible.length === 0 ? ( -

        +

        Pin a dashboard to keep it here.

        ) : ( diff --git a/apps/web/src/components/dashboard/create-organization-dialog.tsx b/apps/web/src/components/dashboard/create-organization-dialog.tsx index 43a6d04d8a..36961c8875 100644 --- a/apps/web/src/components/dashboard/create-organization-dialog.tsx +++ b/apps/web/src/components/dashboard/create-organization-dialog.tsx @@ -3,7 +3,7 @@ import { useOrganizationList } from "@clerk/clerk-react" import { Cause, Exit, Option } from "effect" import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Input } from "@maple/ui/components/ui/input" -import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" +import { Field, FieldDescription, FieldError, FieldLabel } from "@maple/ui/components/ui/field" import { RadioGroup, RadioGroupItem } from "@maple/ui/components/ui/radio-group" import { CreateOrganizationRequest } from "@maple/domain/http" @@ -97,7 +97,7 @@ export function CreateOrganizationDialog({ setRegion(parseMapleRegion(value))} - className="gap-0 divide-y divide-border overflow-hidden rounded-lg border" + className="gap-0 divide-y divide-border overflow-hidden rounded-md border" > {MapleRegionSchema.literals.map((option) => (
        @@ -388,60 +389,66 @@ function ServiceHealthRow({ return (
      • - - -
        - - - {service.serviceName} - - - {service.environment} - - {primaryCause && ( - - {DirectionIcon && } - {primaryCause.label} - - )} -
        -
        - - - -
        - + } + leading={} + title={ + + + + {service.serviceName} + + + {service.environment} + + {primaryCause && ( + + {DirectionIcon && } + {primaryCause.label} + + )} + + } + trailing={ +
        + + + +
        + } + />
      • ) } diff --git a/apps/web/src/components/dashboard/setup-checklist.tsx b/apps/web/src/components/dashboard/setup-checklist.tsx index 1393f3d654..66591c522d 100644 --- a/apps/web/src/components/dashboard/setup-checklist.tsx +++ b/apps/web/src/components/dashboard/setup-checklist.tsx @@ -4,6 +4,8 @@ import { useNavigate } from "@tanstack/react-router" import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" import { Card, CardContent } from "@maple/ui/components/ui/card" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { ListRow } from "@maple/ui/components/ui/list-row" import { ChevronDownIcon, ChevronUpIcon, @@ -42,46 +44,39 @@ function SetupChecklistCard() { return (
        - + className="flex-1 px-5 py-4 hover:bg-muted/30" + leading={ + + + + } + title="Connect your app to see real data" + meta={getOnboardingSetupHint(qualifyAnswers.intents)} + />
        - - +
        diff --git a/apps/web/src/components/dashboard/user-avatar.tsx b/apps/web/src/components/dashboard/user-avatar.tsx index 05f1da32e1..33e101548d 100644 --- a/apps/web/src/components/dashboard/user-avatar.tsx +++ b/apps/web/src/components/dashboard/user-avatar.tsx @@ -13,7 +13,7 @@ export function UserAvatar({ name: string className?: string }) { - const base = className ?? "size-6 rounded-md text-[10px]" + const base = className ?? "size-6 rounded-md text-3xs" return imageUrl ? ( {name} ) : ( diff --git a/apps/web/src/components/errors/actor-chip.tsx b/apps/web/src/components/errors/actor-chip.tsx index 7a262df9be..35a4c2764b 100644 --- a/apps/web/src/components/errors/actor-chip.tsx +++ b/apps/web/src/components/errors/actor-chip.tsx @@ -3,6 +3,8 @@ import { internalAgentLabel } from "@maple/domain/system-agents" import { MapleMark } from "@maple/ui/components/icons/maple-mark" import { Tooltip, TooltipPopup, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { gradientFor } from "@maple/ui/lib/replay" +import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { AGENT_ACCENT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { FaceRobotIcon } from "@/components/icons" @@ -85,15 +87,15 @@ function initialsFrom(name: string): string { } const SIZE_CLASS = { - sm: "size-5 text-[9px]", - md: "size-7 text-[11px]", + sm: "size-5 text-4xs", + md: "size-7 text-2xs", } as const /** * Round avatar for an actor. People get their Clerk photo, or a per-user * gradient with initials — the same identity treatment the Sessions list uses, * so the same person looks the same across the product. Agents render in the - * violet that already means "not a human" everywhere in this timeline: Maple's + * agent accent that already means "not a human" everywhere in this timeline: Maple's * own subsystems carry the Maple mark, third-party agents the robot. */ export function ActorAvatar({ @@ -127,13 +129,7 @@ export function IdentityAvatar({ if (identity.kind === "agent") { return ( - + {identity.internal ? ( // The mark's trunk runs to the viewBox edge, so nudge it up a // touch to sit optically centred in the circle. @@ -179,12 +175,12 @@ export function ActorChip({ }) { const identity = useActorIdentity(actor) if (!identity) { - return – + return {EMPTY_VALUE} } const chipClass = cn( "inline-flex max-w-full items-center gap-1.5 align-middle text-xs", - identity.kind === "agent" ? "text-violet-600 dark:text-violet-300" : "text-muted-foreground", + identity.kind === "agent" ? AGENT_ACCENT.text : "text-muted-foreground", className, ) const body = ( diff --git a/apps/web/src/components/errors/alert-source-card.tsx b/apps/web/src/components/errors/alert-source-card.tsx index 37d463b3f6..cddb745dad 100644 --- a/apps/web/src/components/errors/alert-source-card.tsx +++ b/apps/web/src/components/errors/alert-source-card.tsx @@ -1,5 +1,6 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueDocument } from "@maple/domain/http" +import { Panel } from "@maple/ui/components/ui/panel" /** * Source panel for alert-backed issues: links back to the alert rule that @@ -13,7 +14,7 @@ export function AlertSourceCard({ issue }: { issue: ErrorIssueDocument }) { const groupKey = typeof sourceRef?.groupKey === "string" ? sourceRef.groupKey : null return ( -
        +

        Alert source

        @@ -31,6 +32,6 @@ export function AlertSourceCard({ issue }: { issue: ErrorIssueDocument }) { View alert rule → ) : null} -
        + ) } diff --git a/apps/web/src/components/errors/attach-pull-request-dialog.tsx b/apps/web/src/components/errors/attach-pull-request-dialog.tsx index 46211492da..c30cca8be3 100644 --- a/apps/web/src/components/errors/attach-pull-request-dialog.tsx +++ b/apps/web/src/components/errors/attach-pull-request-dialog.tsx @@ -5,7 +5,6 @@ import { Link } from "@tanstack/react-router" import { parsePullRequestUrl, VCS_PULL_REQUESTS_DEFAULT_LIMIT } from "@maple/domain/http" import type { PullRequestSummary } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" -import { Button } from "@maple/ui/components/ui/button" import { Combobox, ComboboxContent, @@ -13,21 +12,15 @@ import { ComboboxItem, ComboboxList, } from "@maple/ui/components/ui/combobox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogTitle, -} from "@maple/ui/components/ui/dialog" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Input } from "@maple/ui/components/ui/input" -import { Label } from "@maple/ui/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { TONE_SOFT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" +import { IN_FLIGHT_SOFT } from "./workflow-badge" + import { DocsLink } from "@/components/common/docs-link" import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" @@ -45,8 +38,9 @@ import { retainedQuery } from "@/lib/services/common/atom-client" */ export const PULL_REQUEST_STATE_TONE = { - open: TONE_SOFT.ok, - merged: "bg-purple-500/10 text-purple-600 dark:text-purple-400", + // Open is work under way; merged is done. + open: IN_FLIGHT_SOFT, + merged: TONE_SOFT.ok, closed: TONE_SOFT.neutral, } satisfies Record @@ -153,152 +147,132 @@ export function AttachPullRequestDialog({ } return ( - (next ? onOpenChange(true) : close())}> - - - Attach a pull request - - Once it merges, Maple checks whether this error actually stopped before closing the - issue. - - + (next ? onOpenChange(true) : close())} + title="Attach a pull request" + description="Once it merges, Maple checks whether this error actually stopped before closing the issue." + onSubmit={() => { + if (typedUrl !== null) void attach(typedUrl) + }} + submitLabel="Attach" + pending={busy} + submitDisabled={typedUrl === null} + panelClassName="space-y-3" + > + {!connected ? ( +
        + setFilter(event.target.value)} + /> +

        + + Connect GitHub + {" "} + to pick from your repositories instead of pasting a link. +

        + GitHub integration docs +
        + ) : ( + <> + {repositories.length > 1 ? ( + + Repository + + + ) : null} - - {!connected ? ( -
        - + Pull request + { + if (typeof value === "string") void attach(value) + }} + > + setFilter(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter" && typedUrl !== null) void attach(typedUrl) + if (event.key === "Enter" && typedUrl !== null) { + event.preventDefault() + void attach(typedUrl) + } }} /> -

        - - Connect GitHub - {" "} - to pick from your repositories instead of pasting a link. -

        - GitHub integration docs -
        - ) : ( - <> - {repositories.length > 1 ? ( -
        - - -
        - ) : null} - -
        - - { - if (typeof value === "string") void attach(value) - }} - > - setFilter(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && typedUrl !== null) { - event.preventDefault() - void attach(typedUrl) - } - }} - /> - - {loadingPullRequests ? ( - - Loading pull requests… - - ) : visible.length === 0 ? ( - - {typedUrl !== null - ? "Press Enter to attach the pull request you typed." - : pullRequests.length === 0 - ? "No pull requests in this repository yet." - : "Nothing matches that search."} - - ) : ( - - {visible.map((pr) => ( - -
        -
        - - #{pr.number} {pr.title} - - - {pr.isDraft && pr.state === "open" - ? "draft" - : pr.state} - -
        - - {pr.headRef} - {pr.authorLogin ? ` · ${pr.authorLogin}` : ""} - -
        -
        - ))} -
        - )} -
        -
        -
        - - )} -
        - - - - - -
        -
        + + {loadingPullRequests ? ( + Loading pull requests… + ) : visible.length === 0 ? ( + + {typedUrl !== null + ? "Press Enter to attach the pull request you typed." + : pullRequests.length === 0 + ? "No pull requests in this repository yet." + : "Nothing matches that search."} + + ) : ( + + {visible.map((pr) => ( + +
        +
        + + #{pr.number} {pr.title} + + + {pr.isDraft && pr.state === "open" + ? "draft" + : pr.state} + +
        + + {pr.headRef} + {pr.authorLogin ? ` · ${pr.authorLogin}` : ""} + +
        +
        + ))} +
        + )} +
        + + + + )} + ) } diff --git a/apps/web/src/components/errors/error-signal-row.tsx b/apps/web/src/components/errors/error-signal-row.tsx index 2cd97b84e5..a498a9b7de 100644 --- a/apps/web/src/components/errors/error-signal-row.tsx +++ b/apps/web/src/components/errors/error-signal-row.tsx @@ -5,7 +5,8 @@ import type { ErrorIssueId } from "@maple/domain/http" import { ServiceDot } from "@maple/ui/components/service-dot" import { Checkbox } from "@maple/ui/components/ui/checkbox" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { countLabel, formatNumber } from "@maple/ui/lib/format" +import { countLabel, EMPTY_VALUE, formatNumber } from "@maple/ui/lib/format" +import { formatRelativeShort } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import { formatTimestampInTimezone, normalizeTimestampInput } from "@/lib/timezone-format" @@ -28,11 +29,7 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000 function formatLastSeen(iso: string, timeZone: string): string { const d = new Date(normalizeTimestampInput(iso)) if (Number.isNaN(d.getTime())) return iso - const diffMs = Date.now() - d.getTime() - if (diffMs < 60_000) return "now" - if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m` - if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h` - if (diffMs < WEEK_MS) return `${Math.floor(diffMs / 86_400_000)}d` + if (Date.now() - d.getTime() < WEEK_MS) return formatRelativeShort(d) const sameYear = zonedDateParts(d.getTime(), timeZone).year === zonedDateParts(Date.now(), timeZone).year return d.toLocaleDateString(undefined, { timeZone, @@ -80,7 +77,7 @@ function SignalActivity({ ) : null} {commentCount > 0 ? ( @@ -90,7 +87,7 @@ function SignalActivity({ {prCount > 0 ? ( 0 ? "text-foreground/70" : "text-muted-foreground", )} title={ @@ -167,7 +164,7 @@ export function ErrorSignalHeader({
        @@ -410,7 +407,7 @@ export function ErrorSignalRow({ } > {signal.windowCount === null ? ( - — + {EMPTY_VALUE} ) : ( - {stats.map((stat) => ( - - {stat.value} - {stat.label} - - ))} + + {stats.map((stat) => ( + + {stat.value} + + ))} + in the last 24 hours
        ) diff --git a/apps/web/src/components/errors/issue-detail-skeleton.tsx b/apps/web/src/components/errors/issue-detail-skeleton.tsx index e20e7fd91b..08e05ec60e 100644 --- a/apps/web/src/components/errors/issue-detail-skeleton.tsx +++ b/apps/web/src/components/errors/issue-detail-skeleton.tsx @@ -3,6 +3,7 @@ import { IssueField } from "./issue-field" import type { ErrorIssueId } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" import { DetailRail } from "@maple/ui/components/detail-rail" +import { Panel } from "@maple/ui/components/ui/panel" import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { PulseIcon } from "@/components/icons" @@ -129,7 +130,7 @@ function OverviewSkeleton({ windowLabel }: { windowLabel: string }) { {/* `IssueOccurrencePanel`. The heading is the real one; the plot is the chart's own `h-44`, so nothing reflows when the bars arrive. */} -
        +

        Occurrences @@ -137,7 +138,7 @@ function OverviewSkeleton({ windowLabel }: { windowLabel: string }) {

        -
        +
        ) } diff --git a/apps/web/src/components/errors/issue-header.tsx b/apps/web/src/components/errors/issue-header.tsx index 9ce94b870c..3be37f33ec 100644 --- a/apps/web/src/components/errors/issue-header.tsx +++ b/apps/web/src/components/errors/issue-header.tsx @@ -1,4 +1,3 @@ -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Link } from "@tanstack/react-router" import type { ErrorIssueDocument, ErrorIssueId } from "@maple/domain/http" @@ -14,9 +13,10 @@ import { useCopy } from "@maple/ui/hooks/use-copy" import { CopyIcon, DotsVerticalIcon, LinkIcon, PulseIcon } from "@/components/icons" import { OpenAnomalyBadge } from "@/components/anomalies/related-anomalies-section" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DetailHeader } from "@/components/common/detail-header" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import type { TimeRange } from "@/components/time-range-picker/types" +import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { liveInvestigationSummary, resolveSignalState } from "@/lib/models/error-signal" import { agentPromptFromIssue } from "./agent-debug-prompt" @@ -41,7 +41,7 @@ export interface IssueHeaderProps { issue: ErrorIssueDocument issueId: ErrorIssueId investigation: V2Investigation | null - search: { startTime?: string; endTime?: string; timePreset?: string } + search: TimeRangeSearch onTimeChange: (range: TimeRange) => void onStartInvestigation: () => void startingInvestigation: boolean @@ -63,56 +63,40 @@ export function IssueHeader({ const state = resolveSignalState(issue, liveInvestigationSummary(investigation)) return ( - - - {issue.serviceName || "Unknown service"} - {/* Absorbs `IssueKindBadge`. A plain error is the default and says - nothing here; an alert- or integration-backed issue is worth - knowing before you read the title. */} - {issue.kind === "error" ? null : ( - <> - - · - - {issue.kind} - - )} - - {headline} -
        - - - -
        -
    + + + + + } - > -
    - - {investigation ? ( - - ) : ( - - )} - -
    - + actions={ + <> + + {investigation ? ( + + ) : ( + + )} + + + } + /> ) } diff --git a/apps/web/src/components/errors/issue-incidents-table.tsx b/apps/web/src/components/errors/issue-incidents-table.tsx index 57cf8eba55..55ed442bc4 100644 --- a/apps/web/src/components/errors/issue-incidents-table.tsx +++ b/apps/web/src/components/errors/issue-incidents-table.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "@maple/ui/components/ui/t import { cn } from "@maple/ui/lib/utils" import { DocsLink } from "@/components/common/docs-link" import { RelativeTime } from "@/components/common/relative-time" +import { formatNumber } from "@maple/ui/lib/format" interface IssueIncidentsTableProps { incidents: ReadonlyArray @@ -96,7 +97,7 @@ export function IssueIncidentsTable({ incidents }: IssueIncidentsTableProps) { - {incident.occurrenceCount.toLocaleString()} + {formatNumber(incident.occurrenceCount)} ) diff --git a/apps/web/src/components/errors/issue-occurrence-chart.tsx b/apps/web/src/components/errors/issue-occurrence-chart.tsx index c48f47bc0c..f6e8b49ad8 100644 --- a/apps/web/src/components/errors/issue-occurrence-chart.tsx +++ b/apps/web/src/components/errors/issue-occurrence-chart.tsx @@ -2,25 +2,25 @@ import { EmptyMessage } from "@maple/ui/components/ui/empty" import { barY, defineChart } from "@tanstack/charts" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { scaleLinear } from "@tanstack/charts-scales/linear" -import { scalePoint } from "@tanstack/charts-scales/point" import * as React from "react" import type { IssueSeverity } from "@maple/domain/http" import { - PlotFrame, - PlotTooltipBody, - createTooltipFocusStore, - cursorTooltip, + CursorPlot, DASHED_Y_GRID, + bucketDate, integerTickValues, linearYDomain, + makeBucketAxis, minBarLength, niceLinearDomain, - usePlotColors, - type PlotTooltipSeries, + useCursorPlot, + type CursorPlotSeries, } from "@maple/ui/components/plot" import { cn } from "@maple/ui/lib/utils" -import { formatBucketLabel, formatNumber } from "@maple/ui/lib/format" +import { formatNumber } from "@maple/ui/lib/format" + +import { SEVERITY_TOKEN } from "./severity-badge" // A type alias, not an interface: only an alias gets TypeScript's implicit index // signature, which is what lets a row be read by key name — `linearYDomain` takes @@ -53,20 +53,27 @@ interface IssueOccurrenceChartProps { * sparkline read against numbers printed beside it; at panel size a chart with no * scale is decoration. */ +// The badge's severity tokens, so a high-severity issue's bars match its badge. const SEVERITY_COLOR_TOKENS = { - critical: { bar: ["--destructive", "#ef4444"] }, - high: { bar: ["--color-orange-500", "#f97316"] }, - medium: { bar: ["--color-amber-500", "#f59e0b"] }, - low: { bar: ["--color-sky-500", "#0ea5e9"] }, - unset: { bar: ["--primary", "#6366f1"] }, -} as const satisfies Record + ...SEVERITY_TOKEN, + unset: "--primary", +} satisfies Record export function IssueOccurrenceChart({ data, severity = null, className }: IssueOccurrenceChartProps) { - // Module-scope maps, indexed — `usePlotColors` memoises on the object's - // identity and reads computed style, so a literal built from the prop would - // re-read it every frame. - const colors = usePlotColors(SEVERITY_COLOR_TOKENS[severity ?? "unset"]) - const focusStore = React.useMemo(() => createTooltipFocusStore(), []) + const color = SEVERITY_COLOR_TOKENS[severity ?? "unset"] + const series = React.useMemo[]>( + () => [ + { + key: "count", + label: "Occurrences", + color, + value: (point: TimeseriesPoint) => point.count, + format: (value: number) => value.toLocaleString(), + }, + ], + [color], + ) + const plot = useCursorPlot(series) const sorted = React.useMemo( () => @@ -78,29 +85,13 @@ export function IssueOccurrenceChart({ data, severity = null, className }: Issue ) const { effectiveTimezone: timeZone } = useTimezonePreference() - const axisContext = React.useMemo(() => { - if (sorted.length < 2) return { rangeMs: 0, bucketSeconds: undefined, timeZone } - const firstMs = Date.parse(sorted[0]!.bucket) - const secondMs = Date.parse(sorted[1]!.bucket) - const lastMs = Date.parse(sorted[sorted.length - 1]!.bucket) - const diffMs = secondMs - firstMs - return { - rangeMs: lastMs - firstMs, - bucketSeconds: diffMs > 0 ? diffMs / 1000 : undefined, - timeZone, - } - }, [sorted, timeZone]) - - const tooltipSeries = React.useMemo[]>( - () => [ - { - label: "Occurrences", - color: colors.bar, - value: (point: TimeseriesPoint) => point.count, - format: (value: number) => value.toLocaleString(), - }, - ], - [colors.bar], + const axis = React.useMemo( + () => + makeBucketAxis( + sorted.map((point) => point.bucket), + timeZone, + ), + [sorted, timeZone], ) /** @@ -124,25 +115,14 @@ export function IssueOccurrenceChart({ data, severity = null, className }: Issue defineChart({ marks: [ barY(sorted, { - x: (point: TimeseriesPoint) => point.bucket, + x: (point: TimeseriesPoint) => bucketDate(point.bucket), y: (point: TimeseriesPoint) => liftCount(point.count), - fill: colors.bar, + fill: plot.color("count"), radius: 2, }), ], scales: { - x: { - scale: scalePoint, - axis: { - line: false, - ticks: { - size: 0, - padding: 4, - format: (value: string) => formatBucketLabel(value, axisContext, "tick"), - }, - tickLabels: { thin: { minGap: 12 } }, - }, - }, + x: axis.xBand, y: { grid: DASHED_Y_GRID, scale: scaleLinear().domain(yDomain), @@ -164,9 +144,9 @@ export function IssueOccurrenceChart({ data, severity = null, className }: Issue margin: { top: 8, right: 4, bottom: 22, left: 44 }, focus: "group-x", focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }), - [sorted, colors.bar, axisContext, focusStore, yDomain, liftCount], + [sorted, plot, axis, yDomain, liftCount], ) if (sorted.length === 0) { @@ -184,20 +164,13 @@ export function IssueOccurrenceChart({ data, severity = null, className }: Issue } return ( - axis.heading(point.bucket)} ariaLabel="Occurrences over time" className={cn("h-44 w-full", className)} - renderTooltipBody={({ points }) => ( - - formatBucketLabel(point.bucket, axisContext, "tooltip") - } - /> - )} /> ) } diff --git a/apps/web/src/components/errors/issue-occurrence-panel.tsx b/apps/web/src/components/errors/issue-occurrence-panel.tsx index ceaea86b84..fd6aa0bafc 100644 --- a/apps/web/src/components/errors/issue-occurrence-panel.tsx +++ b/apps/web/src/components/errors/issue-occurrence-panel.tsx @@ -4,6 +4,7 @@ import { useMemo } from "react" import type { IssueSeverity } from "@maple/domain/http" import { formatNumber } from "@maple/ui/lib/format" +import { ChartCard } from "@/components/common/chart-card" import { densifySpark, surgeRatio } from "@/lib/models/error-signal" import { IssueOccurrenceChart } from "./issue-occurrence-chart" @@ -45,27 +46,21 @@ export function IssueOccurrencePanel({ }, [data, window]) return ( -
    + peak {formatNumber(peak)} per{" "} + {formatBucket(window.bucketMs)} + + } + legend={surging ? Surging : null} + className="shrink-0" > -
    -
    -

    - Occurrences -

    - - peak {formatNumber(peak)} per{" "} - {formatBucket(window.bucketMs)} - -
    - {surging ? Surging : null} +
    +
    - -
    + ) } diff --git a/apps/web/src/components/errors/issue-occurrences-table.tsx b/apps/web/src/components/errors/issue-occurrences-table.tsx index 08131ee24c..1b79125d62 100644 --- a/apps/web/src/components/errors/issue-occurrences-table.tsx +++ b/apps/web/src/components/errors/issue-occurrences-table.tsx @@ -56,7 +56,7 @@ export function IssueOccurrencesTable({ traces }: IssueOccurrencesTableProps) { search={{ t: trace.timestamp }} className={cn( "inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-2 py-0.5", - "font-mono text-[11px] text-muted-foreground tabular-nums", + "font-mono text-2xs text-muted-foreground tabular-nums", "transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary", )} > diff --git a/apps/web/src/components/errors/issue-pickers.tsx b/apps/web/src/components/errors/issue-pickers.tsx index 02cb514635..708752540c 100644 --- a/apps/web/src/components/errors/issue-pickers.tsx +++ b/apps/web/src/components/errors/issue-pickers.tsx @@ -141,7 +141,7 @@ export function StatePicker({ title={`Status: ${WORKFLOW_LABEL[current]} · click to change`} className={cn( TRIGGER, - "-ml-1.5 inline-flex h-6 max-w-full items-center gap-1.5 px-1.5 text-[11px] whitespace-nowrap text-muted-foreground hover:text-foreground data-popup-open:text-foreground", + "-ml-1.5 inline-flex h-6 max-w-full items-center gap-1.5 px-1.5 text-2xs whitespace-nowrap text-muted-foreground hover:text-foreground data-popup-open:text-foreground", className, )} > diff --git a/apps/web/src/components/errors/issue-pull-requests-panel.tsx b/apps/web/src/components/errors/issue-pull-requests-panel.tsx index a0dbf52d8a..865ebfcd83 100644 --- a/apps/web/src/components/errors/issue-pull-requests-panel.tsx +++ b/apps/web/src/components/errors/issue-pull-requests-panel.tsx @@ -8,6 +8,7 @@ import { cn } from "@maple/ui/lib/utils" import { DocsLink } from "@/components/common/docs-link" import { GithubIcon, PlusIcon, TrashIcon } from "@/components/icons" import { AttachPullRequestDialog, PULL_REQUEST_STATE_TONE } from "./attach-pull-request-dialog" +import { IconButton } from "@maple/ui/components/ui/icon-button" /** * Pull requests attached to this issue. @@ -113,16 +114,15 @@ export function IssuePullRequestsPanel({ ) : null} - + ) })} diff --git a/apps/web/src/components/errors/issue-sidebar.tsx b/apps/web/src/components/errors/issue-sidebar.tsx index 753fca287e..5e23f3f9a3 100644 --- a/apps/web/src/components/errors/issue-sidebar.tsx +++ b/apps/web/src/components/errors/issue-sidebar.tsx @@ -20,6 +20,8 @@ import { SeveritySelect } from "./severity-select" import { StateSelect } from "./state-select" import { ServiceDot } from "@maple/ui/components/service-dot" import { DetailRail } from "@maple/ui/components/detail-rail" +import { formatNumber } from "@maple/ui/lib/format" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" type Busy = | "state" @@ -223,13 +225,13 @@ function EnvironmentValue({ environments }: { environments: ReadonlyArray (
  • {env.name} - - {env.count.toLocaleString()} + + {formatNumber(env.count)}
  • ))} {hidden > 0 ? ( -
  • +{hidden} more
  • +
  • +{hidden} more
  • ) : null} ) @@ -243,18 +245,24 @@ function EnvironmentValue({ environments }: { environments: ReadonlyArray void copy(id)} - title="Copy issue ID" - className="group/copy -mr-1.5 inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 font-mono text-xs tabular-nums text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - > - {shortIssueId(id)} - - + + void copy(id)} + className="group/copy -mr-1.5 inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 font-mono text-xs tabular-nums text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + } + > + {shortIssueId(id)} + + + Copy issue ID + ) } diff --git a/apps/web/src/components/errors/issue-source.tsx b/apps/web/src/components/errors/issue-source.tsx index d14cad3dca..6b4cb688ad 100644 --- a/apps/web/src/components/errors/issue-source.tsx +++ b/apps/web/src/components/errors/issue-source.tsx @@ -16,7 +16,8 @@ import { KIND_LABEL } from "@/lib/errors/error-filter-chips" export const SOURCES: ReadonlyArray = ["error", "alert", "integration"] export const SOURCE_COLOR = { - error: "var(--color-rose-500)", + // The chart error-series token: the same red errors carry on every chart. + error: "var(--chart-error)", alert: "var(--color-violet-500)", integration: "var(--color-teal-500)", } satisfies Record diff --git a/apps/web/src/components/errors/issue-timeline.tsx b/apps/web/src/components/errors/issue-timeline.tsx index b4dbf8996b..939f8c3338 100644 --- a/apps/web/src/components/errors/issue-timeline.tsx +++ b/apps/web/src/components/errors/issue-timeline.tsx @@ -7,13 +7,14 @@ import type { import { countLabel, formatRatePerHour } from "@maple/ui/lib/format" import type { ReactNode } from "react" import { Badge } from "@maple/ui/components/ui/badge" -import { TONE_FILL } from "@maple/ui/lib/tone" +import { AGENT_ACCENT, TONE_FILL } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { RelativeTime } from "@/components/common/relative-time" import { MessageResponse } from "@/components/ai-elements/message-response" import { useActorDirectory } from "@/hooks/use-actor-directory" import { IdentityAvatar, resolveActorIdentity, type ActorIdentity } from "./actor-chip" +import { IN_FLIGHT_FILL } from "./workflow-badge" const EVENT_LABEL: Record = { created: "Created", @@ -40,27 +41,27 @@ const EVENT_LABEL: Record = { const DOT_CLASS: Record = { created: "bg-primary", - state_change: "bg-blue-500", + state_change: IN_FLIGHT_FILL, assignment: "bg-muted-foreground", - claim: "bg-violet-500", - release: "bg-violet-500/60", + claim: AGENT_ACCENT.fill, + release: "bg-chart-ai-agent/60", lease_expired: TONE_FILL.warn, comment: "bg-muted-foreground", - agent_note: "bg-violet-500", + agent_note: AGENT_ACCENT.fill, fix_proposed: TONE_FILL.ok, regression: TONE_FILL.crit, snooze: "bg-muted-foreground/70", unsnooze: "bg-muted-foreground/70", - ai_triage: "bg-violet-500", + ai_triage: AGENT_ACCENT.fill, anomaly_linked: TONE_FILL.warn, - severity_change: "bg-orange-500", + severity_change: TONE_FILL.warn, pr_linked: "bg-muted-foreground", pr_unlinked: "bg-muted-foreground/60", - pr_merged: "bg-purple-500", - // Teal matches the `verifying` workflow badge, so the timeline row and the + pr_merged: TONE_FILL.ok, + // Matches the `verifying` workflow badge, so the timeline row and the // state chip read as the same thing happening. - verification_started: "bg-teal-500", - verification_verdict: "bg-teal-500", + verification_started: IN_FLIGHT_FILL, + verification_verdict: IN_FLIGHT_FILL, } satisfies Record /** @@ -297,7 +298,7 @@ export function IssueTimeline({
    Escalation - + {escalation.severity} · {escalation.status}
    @@ -335,7 +336,7 @@ export function IssueTimeline({
    {EVENT_LABEL[event.type]} {event.fromState && event.toState ? ( - + {event.fromState} → {event.toState} ) : null} @@ -367,8 +368,8 @@ function AuthorLabel({ return ( @@ -380,7 +381,7 @@ function AuthorLabel({ /** * A written message: avatar in the rail, author and time in the header, body in - * a bubble. Agent messages are tinted violet so a room half-full of automation + * a bubble. Agent messages carry the agent accent so a room half-full of automation * still reads at a glance as "these two paragraphs weren't written by a person". */ function MessageRow({ @@ -428,7 +429,7 @@ function MessageRow({ pill size="xs" mono - className="bg-violet-500/10 font-normal text-violet-600 dark:text-violet-300" + className={cn("font-normal", AGENT_ACCENT.soft)} title={identity?.detail ?? undefined} > {identity?.detail ?? "agent"} @@ -440,9 +441,7 @@ function MessageRow({
    {body} @@ -478,7 +477,7 @@ const MESSAGE_ITEM = `${ITEM} first:pt-0` // `whitespace-nowrap`: `formatRelativeTime` can return "just now", and a gutter // that wraps it onto two lines pushes the dot out of line with its own row. const STAMP = - "py-3 text-right text-[11px] leading-5 whitespace-nowrap tabular-nums text-muted-foreground transition-opacity" + "py-3 text-right text-2xs leading-5 whitespace-nowrap tabular-nums text-muted-foreground transition-opacity" /** The connector column: a hairline the full row height, marker centred on it. */ function Rail({ children }: { children: ReactNode }) { diff --git a/apps/web/src/components/errors/issue-verification-card.tsx b/apps/web/src/components/errors/issue-verification-card.tsx index b30ec0302a..924e482a79 100644 --- a/apps/web/src/components/errors/issue-verification-card.tsx +++ b/apps/web/src/components/errors/issue-verification-card.tsx @@ -2,11 +2,14 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueVerificationDocument, WorkflowState } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" +import { Panel } from "@maple/ui/components/ui/panel" import { formatRatePerHour } from "@maple/ui/lib/format" import { formatRelativeShort } from "@maple/ui/lib/time-format" import { TONE_SOFT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" +import { IN_FLIGHT_SOFT } from "./workflow-badge" + import { MagnifierCheckIcon } from "@/components/icons" /** @@ -20,8 +23,8 @@ import { MagnifierCheckIcon } from "@/components/icons" */ const STATUS_TONE: Record = { - waiting: "bg-teal-500/10 text-teal-600 dark:text-teal-400", - running: "bg-teal-500/10 text-teal-600 dark:text-teal-400", + waiting: IN_FLIGHT_SOFT, + running: IN_FLIGHT_SOFT, verified: TONE_SOFT.ok, not_fixed: TONE_SOFT.crit, inconclusive: TONE_SOFT.warn, @@ -87,7 +90,7 @@ export function IssueVerificationCard({ verification.status === "inconclusive" return ( -
    +
    @@ -137,6 +140,6 @@ export function IssueVerificationCard({ First check was inconclusive; watching for longer.

    ) : null} -
    + ) } diff --git a/apps/web/src/components/errors/issues-bulk-bar.tsx b/apps/web/src/components/errors/issues-bulk-bar.tsx index 49f4310f7f..22d5373cc9 100644 --- a/apps/web/src/components/errors/issues-bulk-bar.tsx +++ b/apps/web/src/components/errors/issues-bulk-bar.tsx @@ -16,6 +16,7 @@ import { WORKFLOW_LABEL } from "@/components/icons/workflow-ring" import { XmarkIcon } from "@/components/icons" import { SEVERITY_LABEL, SEVERITY_ORDER, SeverityDot } from "./severity-badge" import type { IssueMutations } from "./use-issue-mutations" +import { IconButton } from "@maple/ui/components/ui/icon-button" /** * A selected row carries its workflow state, not just its id: the "Move to" @@ -112,9 +113,9 @@ export function IssuesBulkBar({ )) )} - +
    ) } diff --git a/apps/web/src/components/errors/lease-hud.tsx b/apps/web/src/components/errors/lease-hud.tsx index 00acd9e956..eac9d2e240 100644 --- a/apps/web/src/components/errors/lease-hud.tsx +++ b/apps/web/src/components/errors/lease-hud.tsx @@ -1,4 +1,5 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Panel } from "@maple/ui/components/ui/panel" import { StatusDot } from "@maple/ui/components/ui/status-dot" import * as React from "react" import type { ActorDocument } from "@maple/domain/http" @@ -42,12 +43,16 @@ export function LeaseHud({ leaseExpiresAt, claimedAt, leaseHolder, className }: const dashOffset = RING_CIRCUMFERENCE * (1 - progress) - const ringColor = expired ? "var(--muted-foreground)" : danger ? "var(--destructive)" : "var(--primary)" + const ringColor = expired + ? "var(--muted-foreground)" + : danger + ? "var(--severity-error)" + : "var(--primary)" return ( -
    -
    + ) } diff --git a/apps/web/src/components/errors/linked-investigation-panel.tsx b/apps/web/src/components/errors/linked-investigation-panel.tsx index efe8363e17..acd2787ed9 100644 --- a/apps/web/src/components/errors/linked-investigation-panel.tsx +++ b/apps/web/src/components/errors/linked-investigation-panel.tsx @@ -4,6 +4,7 @@ import type { IssueEscalationAttemptDocument } from "@maple/domain/http" import type { V2Investigation } from "@maple/domain/http/v2" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { Panel } from "@maple/ui/components/ui/panel" import { PulseIcon } from "@/components/icons" @@ -28,7 +29,7 @@ export function LinkedInvestigationPanel({ }) { if (!investigation) { return ( -
    +

    No linked investigation

    @@ -39,12 +40,12 @@ export function LinkedInvestigationPanel({ Start investigation -

    +
    ) } return ( -
    +

    Linked investigation

    @@ -86,6 +87,6 @@ export function LinkedInvestigationPanel({ > Open investigation -
    + ) } diff --git a/apps/web/src/components/errors/quick-pick-menu.tsx b/apps/web/src/components/errors/quick-pick-menu.tsx index 3921ccd90a..e652d9e858 100644 --- a/apps/web/src/components/errors/quick-pick-menu.tsx +++ b/apps/web/src/components/errors/quick-pick-menu.tsx @@ -165,7 +165,7 @@ export function QuickPickMenu({ onKeyDown={onInputKeyDown} className="h-9 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" /> - {hotkey ? {hotkey} : null} + {hotkey ? {hotkey} : null}
    No match @@ -186,7 +186,7 @@ export function QuickPickMenu({ ) : null} {item.shortcut ? ( - + {item.shortcut} ) : null} diff --git a/apps/web/src/components/errors/severity-badge.tsx b/apps/web/src/components/errors/severity-badge.tsx index bcd07e3fb0..1776667328 100644 --- a/apps/web/src/components/errors/severity-badge.tsx +++ b/apps/web/src/components/errors/severity-badge.tsx @@ -24,6 +24,14 @@ export const SEVERITY_FILL: Record = { low: "bg-severity-debug", } satisfies Record +/** Design-token name for the same four levels, for canvas charts that resolve it to a literal. */ +export const SEVERITY_TOKEN: Record = { + critical: "--severity-fatal", + high: "--severity-error", + medium: "--severity-warn", + low: "--severity-debug", +} satisfies Record + /** `text-*` counterpart of {@link SEVERITY_FILL}, for `currentColor` SVG marks. */ export const SEVERITY_TEXT: Record = { critical: "text-severity-fatal", diff --git a/apps/web/src/components/errors/signal-spark.tsx b/apps/web/src/components/errors/signal-spark.tsx index 8a0397a701..2670caabe0 100644 --- a/apps/web/src/components/errors/signal-spark.tsx +++ b/apps/web/src/components/errors/signal-spark.tsx @@ -1,6 +1,5 @@ -import { useId } from "react" - import type { IssueSeverity } from "@maple/domain/http" +import { MiniBars } from "@maple/ui/components/ui/mini-bars" import { cn } from "@maple/ui/lib/utils" import { SEVERITY_TEXT } from "./severity-badge" @@ -31,22 +30,15 @@ export interface SignalSparkProps { label?: string } -const BAR_GAP = 1 -const BAR_WIDTH = 2 -const VIEW_HEIGHT = 20 +/** One-pixel gaps between two-pixel bars, as a percent of the strip's width. */ +const gapPercent = (count: number): number => 100 / (count * 3 - 1) /** - * A bucket that saw nothing still draws this much, so the comb has a floor and - * the bars stand ON something. Without it a quiet window with two spikes drew - * two marks hovering in an empty box, with no baseline to read them against — - * the shape looked broken rather than sparse. + * A bucket that saw nothing still draws a 1/20 tick, so the comb has a floor and + * the bars stand ON something; a bucket that saw errors clears 2.5/20, because + * the difference between "one error" and "none" is the whole point of the shape. + * A window that saw nothing at all stays blank: a floor under no data would claim some. */ -const EMPTY_BAR_HEIGHT = 1 - -/** A bucket that saw errors clears the floor, because the difference between - * "one error" and "none" is the whole point of the shape. */ -const MIN_BAR_HEIGHT = 2.5 - export function SignalSpark({ values, severity, @@ -55,8 +47,6 @@ export function SignalSpark({ className, label, }: SignalSparkProps) { - const gradientId = useId() - if (values.length === 0) { return ( - {label ?? "Occurrences over the selected window"} - {/* Vertical ramp so a tall bar is not just taller but hotter — the - difference between a spike and a plateau survives being 20px high. */} - - - - - - - {values.map((value, index) => { - // A window that saw nothing at all stays blank: a floor drawn under no - // data is a chart claiming to have some. The floor is the baseline of a - // shape that exists, not decoration on an empty cell. - if (peak === 0) return null - - const empty = value === 0 - const height = empty - ? EMPTY_BAR_HEIGHT - : Math.max(MIN_BAR_HEIGHT, (value / peak) * VIEW_HEIGHT) - const recent = index >= tailStart - return ( - - ) - })} - + 0 ? 5 : 0} + minHeight={12.5} + gap={gapPercent(values.length)} + tail={tailFraction} + className={cn("h-5 w-full overflow-visible", tone, className)} + label={label ?? "Occurrences over the selected window"} + /> ) } diff --git a/apps/web/src/components/errors/signal-state-chip.tsx b/apps/web/src/components/errors/signal-state-chip.tsx index ebd8119f91..c87203bbe6 100644 --- a/apps/web/src/components/errors/signal-state-chip.tsx +++ b/apps/web/src/components/errors/signal-state-chip.tsx @@ -46,7 +46,7 @@ export function SignalStateChip({ return ( void) { payload: new ErrorIssueTransitionRequest({ toState }), reactivityKeys: [...INVALIDATE, issueKey(issueId)], }) - if (Exit.isSuccess(result)) { - onSuccess?.() - toastManager.add({ title: `Moved to ${WORKFLOW_LABEL[toState]}`, type: "success" }) - } else { - logFailure("transitionTo", result) - showErrorToast(result, { title: "State change failed" }) - } + const ok = toastExit(result, { + success: `Moved to ${WORKFLOW_LABEL[toState]}`, + error: "State change failed", + }) + if (ok) onSuccess?.() + else logFailure("transitionTo", result) return result } @@ -124,13 +124,8 @@ export function useIssueMutations(onSuccess?: () => void) { payload: new ErrorIssueClaimRequest({}), reactivityKeys: [...INVALIDATE, issueKey(issueId)], }) - if (Exit.isSuccess(result)) { - onSuccess?.() - toastManager.add({ title: "Claimed", type: "success" }) - } else { - logFailure("claim", result) - showErrorToast(result, { title: "Claim failed" }) - } + if (toastExit(result, { success: "Claimed", error: "Claim failed" })) onSuccess?.() + else logFailure("claim", result) return result } @@ -140,7 +135,7 @@ export function useIssueMutations(onSuccess?: () => void) { const outcome = batchOutcome(issueIds.length, exit, (failure) => logFailure("claimMany", failure)) if (outcome.failed === 0) { onSuccess?.() - toastManager.add({ title: `Claimed ${issueIds.length} issues`, type: "success" }) + toastManager.add({ title: `Claimed ${countLabel(issueIds.length, "issue")}`, type: "success" }) } else if (outcome.succeeded > 0) { onSuccess?.() showErrorToast(outcome.firstFailure, { @@ -158,13 +153,8 @@ export function useIssueMutations(onSuccess?: () => void) { payload: new ErrorIssueReleaseRequest({}), reactivityKeys: [...INVALIDATE, issueKey(issueId)], }) - if (Exit.isSuccess(result)) { - onSuccess?.() - toastManager.add({ title: "Released", type: "success" }) - } else { - logFailure("release", result) - showErrorToast(result, { title: "Release failed" }) - } + if (toastExit(result, { success: "Released", error: "Release failed" })) onSuccess?.() + else logFailure("release", result) return result } @@ -174,16 +164,12 @@ export function useIssueMutations(onSuccess?: () => void) { payload: new ErrorIssueSetSeverityRequest({ severity: value }), reactivityKeys: [...INVALIDATE, issueKey(issueId)], }) - if (Exit.isSuccess(result)) { - onSuccess?.() - toastManager.add({ - title: value === null ? "Severity cleared" : `Severity set to ${value}`, - type: "success", - }) - } else { - logFailure("setSeverity", result) - showErrorToast(result, { title: "Severity change failed" }) - } + const ok = toastExit(result, { + success: value === null ? "Severity cleared" : `Severity set to ${value}`, + error: "Severity change failed", + }) + if (ok) onSuccess?.() + else logFailure("setSeverity", result) return result } @@ -195,7 +181,10 @@ export function useIssueMutations(onSuccess?: () => void) { ) if (outcome.failed === 0) { onSuccess?.() - toastManager.add({ title: `Updated severity for ${issueIds.length} issues`, type: "success" }) + toastManager.add({ + title: `Updated severity for ${countLabel(issueIds.length, "issue")}`, + type: "success", + }) } else if (outcome.succeeded > 0) { onSuccess?.() showErrorToast(outcome.firstFailure, { diff --git a/apps/web/src/components/errors/workflow-badge.tsx b/apps/web/src/components/errors/workflow-badge.tsx index 3c7b0c879c..b9554d3bb7 100644 --- a/apps/web/src/components/errors/workflow-badge.tsx +++ b/apps/web/src/components/errors/workflow-badge.tsx @@ -4,6 +4,10 @@ import { TONE_SOFT } from "@maple/ui/lib/tone" import { WORKFLOW_LABEL } from "@/components/icons/workflow-ring" +/** Work under way (in progress, in review, verifying, an open PR): the severity-debug blue. */ +export const IN_FLIGHT_SOFT = "bg-severity-debug/12 text-severity-debug" +export const IN_FLIGHT_FILL = "bg-severity-debug" + /** * Tone only — the wording comes from {@link WORKFLOW_LABEL}, which the state * picker, the bulk bar and the workflow ring already share. This file used to @@ -16,12 +20,11 @@ const WORKFLOW_TONE: Record = { // as more urgent than an untriaged issue rather than the same. regressed: TONE_SOFT.crit, todo: TONE_SOFT.neutral, - // In-flight stages keep their own hues: they are stage identity, not a status level. - in_progress: "bg-blue-500/10 text-blue-600 dark:text-blue-400", - in_review: "bg-purple-500/10 text-purple-600 dark:text-purple-400", - // Teal, distinct from both `in_review` (a human is looking) and `done` (it is - // over): a merged fix is being watched, and nobody needs to act yet. - verifying: "bg-teal-500/10 text-teal-600 dark:text-teal-400", + // In-flight stages share one "under way" hue, distinct from `done` (it is over) + // and from the warn/crit stages that need someone to act. + in_progress: IN_FLIGHT_SOFT, + in_review: IN_FLIGHT_SOFT, + verifying: IN_FLIGHT_SOFT, done: TONE_SOFT.ok, cancelled: TONE_SOFT.neutral, wontfix: TONE_SOFT.neutral, diff --git a/apps/web/src/components/filters/pinned-namespace-notice.tsx b/apps/web/src/components/filters/pinned-namespace-notice.tsx index 63a8156e69..ff0562a7d7 100644 --- a/apps/web/src/components/filters/pinned-namespace-notice.tsx +++ b/apps/web/src/components/filters/pinned-namespace-notice.tsx @@ -1,7 +1,8 @@ import { useRouter } from "@tanstack/react-router" import { LayersIcon } from "@/components/icons" -import { FILTER_SECTION_LABEL } from "@maple/ui/components/filters/filter-styles" +import { eyebrowVariants } from "@maple/ui/components/ui/eyebrow" import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" import { setGlobalNamespace } from "@/lib/services/common/global-namespace" /** @@ -18,7 +19,7 @@ export function PinnedNamespaceNotice({ namespace }: { namespace: string }) {
    Namespace @@ -36,12 +37,9 @@ export function PinnedNamespaceNotice({ namespace }: { namespace: string }) {
    {namespace} - + Pinned - +
    ) diff --git a/apps/web/src/components/funnels/funnel-step-builder.tsx b/apps/web/src/components/funnels/funnel-step-builder.tsx index 8a9ca87962..5960ace14e 100644 --- a/apps/web/src/components/funnels/funnel-step-builder.tsx +++ b/apps/web/src/components/funnels/funnel-step-builder.tsx @@ -1,5 +1,7 @@ import { Button } from "@maple/ui/components/ui/button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Panel } from "@maple/ui/components/ui/panel" import { Combobox, ComboboxContent, @@ -196,17 +198,12 @@ function StepRow({ const filterError = filterParse !== null && !filterParse.ok ? filterParse.error : null return ( -
    +
    {!single && ( - - - +
    )}
    @@ -369,10 +356,10 @@ function StepRow({ ariaLabel={`Step ${index + 1} attribute filter`} />
    - {filterError ?

    {filterError}

    : null} + {filterError ?

    {filterError}

    : null}
    ) : null} -
    + ) } @@ -445,7 +432,7 @@ function SuggestingInput({ {name} {countOf.get(name) !== undefined ? ( - + {formatNumber(countOf.get(name)!)} ) : null} diff --git a/apps/web/src/components/header/connect-button.tsx b/apps/web/src/components/header/connect-button.tsx index fc212887b5..463bc201d5 100644 --- a/apps/web/src/components/header/connect-button.tsx +++ b/apps/web/src/components/header/connect-button.tsx @@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router" import { Button } from "@maple/ui/components/ui/button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Panel } from "@maple/ui/components/ui/panel" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Popover, @@ -97,7 +98,7 @@ function ConnectPanel() { function McpCard() { return ( -
    +
    -
    + ) } diff --git a/apps/web/src/components/header/onboarding-checklist-button.tsx b/apps/web/src/components/header/onboarding-checklist-button.tsx index 7220e1f93d..80d2b346e7 100644 --- a/apps/web/src/components/header/onboarding-checklist-button.tsx +++ b/apps/web/src/components/header/onboarding-checklist-button.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { Link } from "@tanstack/react-router" import type { V2OnboardingChecklist, V2OnboardingChecklistStep } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Meter } from "@maple/ui/components/ui/meter" import { Badge } from "@maple/ui/components/ui/badge" import { Popover, PopoverPopup, PopoverTrigger } from "@maple/ui/components/ui/popover" @@ -26,7 +27,6 @@ export function formatCountdown(deadlineMs: number, nowMs: number): string { return formatRemaining(deadlineMs - nowMs, { fixedHours: true }) } - const formatCredits = (checklist: Pick) => `$${checklist.reward_amount_usd}` @@ -135,7 +135,7 @@ function ProgressChip({ completed, total }: { completed: number; total: number } size="xs" mono className={cn( - "text-[11px] font-semibold sm:text-[11px]", + "text-2xs font-semibold sm:text-2xs", completed === total ? "bg-primary text-primary-foreground" : "bg-primary/15 text-primary", )} > @@ -179,15 +179,9 @@ function RewardCallout({ and get {credits} in credits. - +
    ) @@ -264,12 +258,12 @@ export function OnboardingChecklistPanel({ {claimError ?? "Every step is done."} - @@ -298,7 +292,7 @@ export function OnboardingChecklistPanel({ function OptionalTag() { return ( - + Optional ) @@ -335,7 +329,7 @@ function StepRow({ className="group flex items-center gap-3 rounded-md px-2 py-2 text-sm text-foreground outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring" > {index} diff --git a/apps/web/src/components/icons/concept.ts b/apps/web/src/components/icons/concept.ts new file mode 100644 index 0000000000..dae8d3512d --- /dev/null +++ b/apps/web/src/components/icons/concept.ts @@ -0,0 +1,15 @@ +// The one glyph per product concept, matching the sidebar (dashboard/nav-items.ts). +// A trace, log, error, service or metric reads with the same icon wherever it appears. + +import { ChartLineIcon, CircleWarningIcon, FileIcon, PulseIcon, ServerIcon } from "./index" +import type { IconComponent } from "./icon" + +export type Concept = "trace" | "log" | "error" | "service" | "metric" + +export const CONCEPT_ICON: Record = { + trace: PulseIcon, + log: FileIcon, + error: CircleWarningIcon, + service: ServerIcon, + metric: ChartLineIcon, +} satisfies Record diff --git a/apps/web/src/components/infra/chart-utils.test.ts b/apps/web/src/components/infra/chart-utils.test.ts index 2031356f9f..2d1fa64640 100644 --- a/apps/web/src/components/infra/chart-utils.test.ts +++ b/apps/web/src/components/infra/chart-utils.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "vitest" -import { - formatSeconds, - formatValueWithUnit, - isoToLabel, - makeBucketLabeler, - transformRows, -} from "./chart-utils" +import { formatSeconds, formatValueWithUnit, transformRows } from "./chart-utils" describe("formatValueWithUnit", () => { it("renders a percentage with a % sign", () => { @@ -51,46 +45,14 @@ describe("formatSeconds", () => { }) }) -describe("makeBucketLabeler", () => { - it("uses plain time-of-day labels while the buckets span a single day", () => { - const buckets = ["2026-07-03T10:00:00Z", "2026-07-03T22:00:00Z"] - const label = makeBucketLabeler(buckets) - expect(label(buckets[0]!)).toBe(isoToLabel(buckets[0]!)) - expect(label(buckets[0]!)).not.toMatch(/Jul/) - }) - - it("prefixes the date and compacts the time once the buckets cross 24h", () => { - const buckets = ["2026-07-01T10:00:00Z", "2026-07-04T10:00:00Z"] - const label = makeBucketLabeler(buckets) - const onTheHour = new Date(2026, 6, 3, 15, 0) - const offTheHour = new Date(2026, 6, 3, 14, 35) - const expectedDate = onTheHour.toLocaleDateString("en-US", { month: "short", day: "numeric" }) - expect(label(onTheHour.toISOString())).toBe(`${expectedDate}, 3pm`) - expect(label(offTheHour.toISOString())).toBe(`${expectedDate}, 2:35pm`) - expect(label(new Date(2026, 6, 3, 0, 0).toISOString())).toBe(`${expectedDate}, 12am`) - }) - - it("labels in the given zone", () => { - const buckets = ["2026-07-01T10:00:00Z", "2026-07-04T10:00:00Z"] - // 15:00Z is 00:00 the next day in Tokyo. - expect(makeBucketLabeler(buckets, "Asia/Tokyo")("2026-07-03T15:00:00Z")).toBe("Jul 4, 12am") - expect(isoToLabel("2026-07-03T15:30:00Z", "Asia/Tokyo")).toBe("12:30 AM") - }) - - it("falls back to time-of-day for empty or unparsable input", () => { - expect(makeBucketLabeler([])("2026-07-03T14:35:00Z")).toBe(isoToLabel("2026-07-03T14:35:00Z")) - }) -}) - describe("transformRows", () => { it("labels points with the provided labeler", () => { const rows = [ { bucket: "2026-07-01T10:00:00Z", attributeValue: "a", value: 1 }, { bucket: "2026-07-04T10:00:00Z", attributeValue: "a", value: 2 }, ] - const { data } = transformRows(rows, makeBucketLabeler(rows.map((r) => r.bucket))) - expect(data[0]?.time).toMatch(/^[A-Z][a-z]{2} \d{1,2}, /) - expect(data[1]?.time).toMatch(/^[A-Z][a-z]{2} \d{1,2}, /) - expect(data[0]?.time).not.toBe(data[1]?.time) + const { data } = transformRows(rows, (iso) => iso.slice(0, 10)) + expect(data[0]?.time).toBe("2026-07-01") + expect(data[1]?.time).toBe("2026-07-04") }) }) diff --git a/apps/web/src/components/infra/chart-utils.ts b/apps/web/src/components/infra/chart-utils.ts index e824001d11..eab77e49e4 100644 --- a/apps/web/src/components/infra/chart-utils.ts +++ b/apps/web/src/components/infra/chart-utils.ts @@ -4,10 +4,8 @@ // value formatting used by tooltips, legend chips, and axes. Series colors come // from `resolveSeriesColors` — a host/pod/zone keeps its color across windows. -import { zonedDateParts } from "@maple/query-engine/datetime" -import { bucketTimeScale, timeseriesXAxis, type TimeseriesAxisContext } from "@maple/ui/components/plot" +import { bucketDate } from "@maple/ui/components/plot" import { - formatBucketLabel, formatBytes, formatBytesPerSecond, formatLatency, @@ -81,9 +79,6 @@ export function formatValueWithUnit(value: number, unit: ChartUnit): string { } } -/** Shown when a series query returns no points for the selected window. */ -export const CHART_EMPTY_MESSAGE = "No data for this metric in the selected window." - /** * Series key for a gauge with no group-by attribute (a single line). Charts * swap this placeholder for the metric's human `seriesLabel` in legends and @@ -110,124 +105,6 @@ export function isoToLabel(iso: string, timeZone?: string): string { return d.toLocaleTimeString("en-US", { timeZone, hour: "2-digit", minute: "2-digit" }) } -/** Compact time-of-day for a dated label: "3pm", or "3:35pm" off the hour. */ -function compactTimeOfDay(d: Date, timeZone: string | undefined): string { - const { hour: hours24, minute: minutes } = timeZone - ? zonedDateParts(d.getTime(), timeZone) - : { hour: d.getHours(), minute: d.getMinutes() } - const hour = hours24 % 12 === 0 ? 12 : hours24 % 12 - const suffix = hours24 < 12 ? "am" : "pm" - return minutes === 0 ? `${hour}${suffix}` : `${hour}:${String(minutes).padStart(2, "0")}${suffix}` -} - -/** - * Window-aware axis labeler: plain time-of-day while the plotted buckets span - * a single day, "Jul 3, 2pm" once they cross 24h — the multi-day presets - * (4d/10d/6w/…) make bare times ambiguous. The time is compact there because - * the date already takes the width; multi-day buckets land on the hour, so - * the minutes only appear when they carry information. - */ -export function makeBucketLabeler( - bucketIsos: ReadonlyArray, - timeZone?: string, -): (iso: string) => string { - let min = Number.POSITIVE_INFINITY - let max = Number.NEGATIVE_INFINITY - for (const iso of bucketIsos) { - const ms = toEpochMs(iso) - if (Number.isFinite(ms)) { - min = Math.min(min, ms) - max = Math.max(max, ms) - } - } - if (max - min <= 24 * 60 * 60 * 1000) return (iso) => isoToLabel(iso, timeZone) - return (iso) => { - const d = new Date(toEpochMs(iso)) - return `${d.toLocaleDateString("en-US", { timeZone, month: "short", day: "numeric" })}, ${compactTimeOfDay(d, timeZone)}` - } -} - -/** A warehouse bucket as an instant — tz-less buckets are read as UTC, never as local time. */ -export function bucketDate(iso: string): Date { - return new Date(toEpochMs(iso)) -} - -/** - * The x axis for a chart over warehouse buckets: a TIME scale over each - * bucket's instant, pinned to the extent of `bucketIsos`. - * - * Every infra chart used to plot the bucket's formatted LABEL on a point scale, - * and a label is not an identity: a 24-hour window starting at 17:00 has - * "05:00 PM" at both ends, so the scale put the first and last buckets on the - * same x — the line drew straight back across the plot, a threshold label - * anchored to the "last" bucket landed on the y-axis ticks, and the end labels - * clipped. A multi-day window collapsed once per day. Plotting the instant makes - * the position honest; the shared axis builder puts ticks on round clock - * boundaries and keeps the end labels inside the plot. - * - * Pass the union of every sibling's buckets when charts are read down one - * vertical line, so they agree on where a minute sits. `timeZone` is the - * viewer's selected zone (`useTimezonePreference`): ticks and labels follow it. - */ -export function makeBucketAxis(bucketIsos: ReadonlyArray, timeZone?: string) { - const epochs = bucketIsos - .map((iso) => toEpochMs(iso)) - .filter((ms) => Number.isFinite(ms)) - .toSorted((a, b) => a - b) - const first = epochs[0] - const last = epochs[epochs.length - 1] - const domainMs: readonly [number, number] | undefined = - first !== undefined && last !== undefined ? [first, last] : undefined - - // The bucket width is the smallest positive gap, so an irregular union of two - // cadences still reports the finer one rather than whatever came first. - let stepMs: number | undefined - for (let index = 1; index < epochs.length; index++) { - const gap = epochs[index]! - epochs[index - 1]! - if (gap > 0 && (stepMs === undefined || gap < stepMs)) stepMs = gap - } - - const context: TimeseriesAxisContext = { - rangeMs: domainMs ? domainMs[1] - domainMs[0] : 0, - bucketSeconds: stepMs === undefined ? undefined : stepMs / 1000, - domainMs, - timeZone, - } - const axis = timeseriesXAxis(context) - - return { - /** Feed to `defineChart({ scales: { x } })`. */ - x: - domainMs && domainMs[0] < domainMs[1] - ? { - ...axis, - scale: bucketTimeScale([new Date(domainMs[0]), new Date(domainMs[1])], timeZone), - } - : axis, - /** - * `x` padded by half a bucket each way, for marks with WIDTH (bars): they - * are centred on the bucket, so the unpadded extent cuts the end bars in - * half — see `timeseriesBandXAxis`. - */ - xBand: - domainMs && stepMs !== undefined - ? { - ...axis, - scale: bucketTimeScale( - [new Date(domainMs[0] - stepMs / 2), new Date(domainMs[1] + stepMs / 2)], - timeZone, - ), - } - : axis, - /** `[first, last]` epoch ms, absent when there is nothing to plot. */ - domainMs, - /** The bucket width in ms — the smallest positive gap — absent under two buckets. */ - stepMs, - /** The tooltip heading for a bucket: the full date, since the ticks stay terse. */ - heading: (bucketIso: string) => formatBucketLabel(bucketIso, context, "tooltip"), - } -} - /** Pivot long-form `{bucket, attributeValue, value}` rows into per-bucket points keyed by series. */ export function transformRows( rows: ReadonlyArray<{ bucket: string; attributeValue: string; value: number }>, diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx index a17d24167c..bd000135a8 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx @@ -37,7 +37,6 @@ import { cloudflareZoneBreakdownResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" -import { formatNumber } from "@maple/ui/lib/format" import { XmarkIcon } from "@/components/icons" import { MonoLinkButton } from "../primitives/mono-link-button" import { CompactFilterInput } from "./compact-filter-input" @@ -48,7 +47,8 @@ import { Badge } from "@maple/ui/components/ui/badge" import { ListFooter } from "@maple/ui/components/ui/list-footer" import { Panel, PanelHeader, PanelTitle } from "@maple/ui/components/ui/panel" import { errorRateClass } from "@maple/ui/lib/error-rate" -import { formatBytes, formatPercent } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatBytes, formatNumber, formatPercent } from "@maple/ui/lib/format" +import { formatDateInTimezone } from "@/lib/timezone-format" import { StackedBreakdownChart } from "./cloudflare-zone-detail-charts" import { CACHE_STATUS_COLORS, CACHE_STATUS_ORDER, STATUS_CLASS_COLORS, STATUS_CLASS_ORDER } from "./constants" import { PanelScope } from "./panel-scope" @@ -119,12 +119,8 @@ const ROW_CLASS = "flex items-center gap-4 border-b border-border/40 px-4 py-3 last:border-0 hover:bg-muted/40" /** ISO-8601 UTC → "Jul 28". */ -const formatCollectedFrom = (iso: string, timeZone: string) => { - const date = new Date(iso) - return Number.isNaN(date.getTime()) - ? null - : date.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone }) -} +const formatCollectedFrom = (iso: string, timeZone: string) => + Number.isNaN(new Date(iso).getTime()) ? null : formatDateInTimezone(iso, { timeZone, withYear: false }) export function CloudflareBreakdownPanel({ serviceName, @@ -344,35 +340,35 @@ function BreakdownTable({ type="button" onClick={() => onToggleFilter(dimension.filterKey, row.key)} aria-pressed={selected} - title={`Filter this page by ${row.key || "—"}`} + title={`Filter this page by ${row.key || EMPTY_VALUE}`} className={cn( - "max-w-full truncate rounded-xs font-mono text-[13px] underline-offset-2 hover:underline focus-visible:outline-1 focus-visible:outline-ring", + "max-w-full truncate rounded-xs font-mono text-xs underline-offset-2 hover:underline focus-visible:outline-1 focus-visible:outline-ring", selected ? "text-primary" : "text-foreground", )} > - {row.key || "—"} + {row.key || EMPTY_VALUE} ) : ( - - {row.key || "—"} + + {row.key || EMPTY_VALUE} )}
    {formatNumber(row.requests)}
    {formatPercent(row.errorRate)}
    -
    +
    {formatBytes(row.bytes)}
    @@ -419,7 +415,7 @@ function StoredBreakdown({
    } error={() => ( -

    +

    Couldn't load the {dimension.column.toLowerCase()} breakdown.

    )} @@ -446,7 +442,7 @@ function StoredBreakdown({ meta={ // Below 99.5% the fold is actually hiding something; above it, saying so is noise. data.coverage < 0.995 && data.totals.length > 0 ? ( - + {formatPercent(data.coverage)} of zone requests ) : null @@ -520,7 +516,7 @@ function Footer({ total={total} noun={dimension.noun} align="start" - className="justify-start gap-x-3 gap-y-1 px-3 py-2 font-mono text-[10px]" + className="justify-start gap-x-3 gap-y-1 px-3 py-2 font-mono text-3xs" > {collectedFrom ? collected from {collectedFrom} : null} {onSearchLive ? ( @@ -577,7 +573,7 @@ function LiveBreakdown({ onChange={onSearchChange} count={count} meta={ - + Live from Cloudflare · no history } @@ -600,7 +596,7 @@ function LiveBreakdown({ error={() => ( <> {toolbar(0)} -

    +

    Couldn't reach Cloudflare's analytics API for this zone right now.

    @@ -611,7 +607,7 @@ function LiveBreakdown({ return ( <> {toolbar(0)} -

    +

    Cloudflare can't serve this breakdown: {data.unavailableReason}

    @@ -637,7 +633,7 @@ function LiveBreakdown({ emptyMessage={`No ${dimension.noun} match "${query}" in this window.`} interactive={false} /> -

    +

    Cloudflare's top {rows.length} for "{query}" · clear the filter to return to stored data

    diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-edge-share-band.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-edge-share-band.tsx index d801ae3988..57535ca32a 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-edge-share-band.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-edge-share-band.tsx @@ -1,11 +1,11 @@ import { useMemo } from "react" import { cn } from "@maple/ui/lib/utils" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { SegmentedBar } from "@maple/ui/components/ui/meter" import type { CloudflareZoneCacheBucket } from "@/api/warehouse/cloudflare-infra" -import { formatNumber } from "@maple/ui/lib/format" -import { formatPercent } from "@maple/ui/lib/format" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import { Panel } from "@maple/ui/components/ui/panel" import { EDGE_SERVED_STATUSES } from "./constants" /** @@ -48,59 +48,54 @@ export function CloudflareEdgeShareBand({ cacheBuckets, className }: CloudflareE if (total === 0) return null return ( -
    +
    - + {formatPercent(edgeShare)} - served at the edge + served at the edge
    - from origin - + from origin + {formatPercent(originShare)}
    -
    - {segments.map((seg) => ( - - } - tabIndex={0} - className="h-full min-w-[2px]" - style={{ width: `${seg.share * 100}%`, background: seg.color }} - aria-label={`${seg.label}: ${formatPercent(seg.share)}`} - /> - - {seg.label} · {formatNumber(seg.requests)} requests ({formatPercent(seg.share)}) - - - ))} - - } - tabIndex={0} - className="h-full flex-1" - style={{ background: ORIGIN_COLOR }} - aria-label={`Origin: ${formatPercent(originShare)}`} - /> - - Origin · {formatNumber(originRequests)} requests ({formatPercent(originShare)}) - - -
    + `${seg.label}: ${formatPercent(seg.share)}`), + `Origin: ${formatPercent(originShare)}`, + ].join(", ")} + segments={[ + ...segments.map((seg) => ({ + key: seg.status, + value: seg.requests, + color: seg.color, + title: `${seg.label} · ${formatNumber(seg.requests)} requests (${formatPercent(seg.share)})`, + })), + { + key: "origin", + value: originRequests, + color: ORIGIN_COLOR, + title: `Origin · ${formatNumber(originRequests)} requests (${formatPercent(originShare)})`, + }, + ]} + />
    {segments.map((seg) => ( - {seg.label} - + {seg.label} + {formatNumber(seg.requests)} @@ -108,15 +103,15 @@ export function CloudflareEdgeShareBand({ cacheBuckets, className }: CloudflareE - Origin - + Origin + {formatNumber(originRequests)}
    -
    + ) } diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx index 09da1c6382..aa272f6025 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx @@ -18,7 +18,7 @@ const ROW_CLASS = const numCell = (value: string, hidden?: boolean) => (
    +
    +
    + +
    - + ; width: number }> = [] const edges: Array = [] @@ -291,7 +293,7 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance data: { glyph: isAlert ? "check" : "issue", eyebrow: isAlert ? "CHECK" : "ISSUE", - title: originTitle(investigation) ?? issueId ?? "—", + title: originTitle(investigation) ?? issueId ?? EMPTY_VALUE, status: snapshot.status.toUpperCase(), statusTone: "crit", ...(occurrences != null && Number.isFinite(occurrences) @@ -505,7 +507,7 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance const height = Math.max(0, ...columns.map(columnHeight)) const actionHeading = proposing - ? `PROPOSES · ${actions.length} ${actions.length === 1 ? "ACTION" : "ACTIONS"} BY IMPACT` + ? `PROPOSES · ${countLabel(actions.length, "ACTION", "ACTIONS")} BY IMPACT` : // No count while the ghosts stand there — a count is a claim about a report // that has not been written. awaitingVerdict @@ -547,7 +549,7 @@ export function buildProvenanceGraph(investigation: V2Investigation): Provenance width: Math.max(0, x - GUTTER), height, actionHeading, - caption: caption(investigation), + caption: caption(investigation, timeZone), runningSince: running && Number.isFinite(openedMs) ? openedMs : null, } } @@ -621,21 +623,21 @@ const elapsedLabel = (investigation: V2Investigation): string | null => { } /** `14:02 → 14:03 · 38s` across the top of the canvas. */ -const caption = (investigation: V2Investigation): string | null => { - const opened = clockTime(investigation.created_at) +const caption = (investigation: V2Investigation, timeZone: string): string | null => { + const opened = clockTime(investigation.created_at, timeZone) if (!opened) return null const endedAt = investigation.diagnosed_at ?? (endsWithoutDiagnosis(investigation.status) ? investigation.updated_at : null) - const ended = clockTime(endedAt) + const ended = clockTime(endedAt, timeZone) const elapsed = elapsedLabel(investigation) if (!ended) return `${opened} → running` return `${opened} → ${ended}${elapsed ? ` · ${elapsed}` : ""}` } -const clockTime = (value: string | null | undefined): string | null => { +const clockTime = (value: string | null | undefined, timeZone: string): string | null => { if (!value) return null const ms = toEpochMs(value) if (!Number.isFinite(ms)) return null - return new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) + return formatTimeInTimezone(ms, { timeZone }) } diff --git a/apps/web/src/components/investigations/flow/provenance-loading.tsx b/apps/web/src/components/investigations/flow/provenance-loading.tsx index 1a257a2692..8a35c1e63c 100644 --- a/apps/web/src/components/investigations/flow/provenance-loading.tsx +++ b/apps/web/src/components/investigations/flow/provenance-loading.tsx @@ -19,6 +19,7 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { CSSProperties } from "react" import { ACTION_WIDTH, SPINE_WIDTH } from "./provenance-graph" +import { Panel } from "@maple/ui/components/ui/panel" /* The real graph's geometry. The widths are imported; the rest is the private arithmetic of `provenance-graph.ts`, mirrored rather than exported — the ghost @@ -204,10 +205,7 @@ export function ProvenanceCanvasLoading() { ] return ( -
    +
    Provenance @@ -266,6 +264,6 @@ export function ProvenanceCanvasLoading() { ), )} -
    + ) } diff --git a/apps/web/src/components/investigations/impact-strip.tsx b/apps/web/src/components/investigations/impact-strip.tsx index a5954ef971..fc946c1879 100644 --- a/apps/web/src/components/investigations/impact-strip.tsx +++ b/apps/web/src/components/investigations/impact-strip.tsx @@ -1,8 +1,10 @@ import { FactLane, FactStrip } from "@/components/errors/fact-strip" import type { V2Investigation } from "@maple/domain/http/v2" -import { formatDuration, formatNumber } from "@maple/ui/lib/format" -import { getServiceColor } from "@maple/ui/lib/colors" +import { formatDuration, formatNumber, pluralize } from "@maple/ui/lib/format" +import { ServiceDot } from "@maple/ui/components/service-dot" import { toEpochMs } from "@maple/ui/lib/time-format" +import { formatTimeInTimezone } from "@/lib/timezone-format" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" /** * Four named lanes under the verdict, each reading a field that has been on the @@ -27,7 +29,8 @@ export function ImpactStrip({ investigation }: { investigation: V2Investigation const isSettled = investigation.status !== "investigating" const services = servicesTouched(investigation) - const window = incidentWindow(snapshot) + const { effectiveTimezone } = useTimezonePreference() + const window = incidentWindow(snapshot, effectiveTimezone) const occurrences = snapshot.occurrenceCount return ( @@ -44,11 +47,7 @@ export function ImpactStrip({ investigation }: { investigation: V2Investigation {services.map((service) => ( - + {service} ))} @@ -62,9 +61,7 @@ export function ImpactStrip({ investigation }: { investigation: V2Investigation {formatNumber(occurrences)}{" "} - - {occurrences === 1 ? "event" : "events"} - + {pluralize(occurrences, "event")} ) : null} @@ -95,19 +92,18 @@ export function servicesTouched(investigation: V2Investigation): ReadonlyArray - new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) +const clockTime = (epochMs: number, timeZone: string) => formatTimeInTimezone(epochMs, { timeZone }) diff --git a/apps/web/src/components/investigations/investigation-header.tsx b/apps/web/src/components/investigations/investigation-header.tsx index aecf8b16c2..f6bc48f21a 100644 --- a/apps/web/src/components/investigations/investigation-header.tsx +++ b/apps/web/src/components/investigations/investigation-header.tsx @@ -1,4 +1,3 @@ -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { V2Investigation } from "@maple/domain/http/v2" import type { IssueSeverity } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" @@ -11,7 +10,7 @@ import { import { ArrowPathIcon, DotsVerticalIcon } from "@/components/icons" import { SeverityBadge } from "@/components/errors/severity-badge" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DetailHeader } from "@/components/common/detail-header" import { investigationHeadline, investigationScope } from "./investigation-display" import { InvestigationStatusBadge, investigationKindLabel } from "./investigation-status" @@ -44,41 +43,34 @@ export function InvestigationHeader({ const severity = asIssueSeverity(investigation.severity ?? investigation.snapshot.severity) return ( - - - Investigation - - · + + + {severity ? : null} + {/* `scope` is free text and a system-seeded investigation can carry a + whole paragraph of it. One line, always. */} + {scope ? ( + + {scope} - {investigationKindLabel(investigation.subject)} - - {headline} -
    - - {severity ? : null} - {/* `scope` is free text and a system-seeded investigation can carry a - whole paragraph of it. One line, always. */} - {scope ? ( - - {scope} - - ) : null} -
    - + ) : null} + } - > - -
    + actions={ + + } + /> ) } diff --git a/apps/web/src/components/investigations/investigation-meta.tsx b/apps/web/src/components/investigations/investigation-meta.tsx index b694292ee2..b8e81dc371 100644 --- a/apps/web/src/components/investigations/investigation-meta.tsx +++ b/apps/web/src/components/investigations/investigation-meta.tsx @@ -13,17 +13,17 @@ * under the finding, and an unaudited diagnosis is the thing to avoid — not an * unread one. */ -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { ReactNode } from "react" import { Link } from "@tanstack/react-router" import type { V2Investigation } from "@maple/domain/http/v2" import type { IssueEscalationAttemptDocument } from "@maple/domain/http" import { cn } from "@maple/ui/lib/utils" -import { formatNumber } from "@maple/ui/lib/format" +import { countLabel, EMPTY_VALUE, formatNumber } from "@maple/ui/lib/format" import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" import { investigationOriginLabel } from "./investigation-status" +import { SectionHeading } from "@/components/common/section-heading" export function InvestigationMeta({ investigation }: { investigation: V2Investigation }) { const { snapshot, subject } = investigation @@ -31,7 +31,7 @@ export function InvestigationMeta({ investigation }: { investigation: V2Investig return (
    - Linked + {/* * A wrapping row of labelled pairs rather than the rail's stacked list. * The rail stacked them because it was 320px wide; across the full page a @@ -95,7 +95,7 @@ function Provenance({ investigation }: { investigation: V2Investigation }) { const tokens = input === null && output === null ? null - : `${input === null ? "—" : formatNumber(input)} in · ${output === null ? "—" : formatNumber(output)} out` + : `${input === null ? EMPTY_VALUE : formatNumber(input)} in · ${output === null ? EMPTY_VALUE : formatNumber(output)} out` const opened = investigation.seeded_by === "system" @@ -103,7 +103,7 @@ function Provenance({ investigation }: { investigation: V2Investigation }) { : "Opened by a member of your team" return ( -

    +

    {[model, tokens, opened].filter(Boolean).join(" · ")}

    ) @@ -152,7 +152,7 @@ function Escalation({ > {ESCALATION_LABEL[attempt.status]} {attempt.deliveries.length > 0 - ? ` · ${attempt.deliveries.length} destination${attempt.deliveries.length === 1 ? "" : "s"}` + ? ` · ${countLabel(attempt.deliveries.length, "destination")}` : ""} {failed > 0 ? ` · ${failed} failed` : ""} diff --git a/apps/web/src/components/investigations/investigation-table.tsx b/apps/web/src/components/investigations/investigation-table.tsx index 26b664b5cd..5a76e71d2b 100644 --- a/apps/web/src/components/investigations/investigation-table.tsx +++ b/apps/web/src/components/investigations/investigation-table.tsx @@ -16,6 +16,8 @@ import { investigationSeverity, } from "./investigation-display" import { InvestigationKindMarker, InvestigationStatusBadge } from "./investigation-status" +import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { ListRow } from "@maple/ui/components/ui/list-row" /** * Not a table any more. @@ -53,15 +55,20 @@ function InvestigationRow({ investigation }: { investigation: V2Investigation }) } return ( -
  • - - - -
    -
    + } + className="cursor-pointer gap-3.5 py-3.5" + leading={ + + + + } + title={ + {scope ? ( - + {scope} ) : null} -
    - -
    - - - - - - - - - - -
  • + + } + meta={} + trailing={ + + + + + + + + + + + + + } + /> ) } @@ -109,7 +120,7 @@ function RowFinding({ finding }: { finding: ReturnType— + return {EMPTY_VALUE} } return ( - - {count} {count === 1 ? "step" : "steps"} - + {countLabel(count, "step")} {stalled ? ( <> diff --git a/apps/web/src/components/investigations/signals-card.tsx b/apps/web/src/components/investigations/signals-card.tsx index 9624dcb597..15eb1794f2 100644 --- a/apps/web/src/components/investigations/signals-card.tsx +++ b/apps/web/src/components/investigations/signals-card.tsx @@ -22,7 +22,7 @@ import { Link } from "@tanstack/react-router" import type { V2Investigation } from "@maple/domain/http/v2" import { cn } from "@maple/ui/lib/utils" import { formatErrorRate, formatLatency, formatNumber } from "@maple/ui/lib/format" -import { getServiceColor } from "@maple/ui/lib/colors" +import { ServiceDot } from "@maple/ui/components/service-dot" import { SEVERITY_COLORS } from "@maple/ui/lib/severity" import { toEpochMs } from "@maple/ui/lib/time-format" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -34,6 +34,7 @@ import { } from "@/lib/services/atoms/warehouse-query-atoms" import { formatForTinybird } from "@/lib/time-utils" import { servicesTouched } from "./impact-strip" +import { Panel } from "@maple/ui/components/ui/panel" /** The design's strip is 32 bars wide; the bucket falls out of the window, not the other way round. */ const BUCKETS = 32 @@ -45,13 +46,9 @@ export function SignalsCard({ investigation }: { investigation: V2Investigation if (!window || !service) return null return ( -
    +
    - + {service} {window.label ? ( @@ -71,7 +68,7 @@ export function SignalsCard({ investigation }: { investigation: V2Investigation -
    + ) } diff --git a/apps/web/src/components/investigations/verdict-card.tsx b/apps/web/src/components/investigations/verdict-card.tsx index 9c77369580..c466e3c8d4 100644 --- a/apps/web/src/components/investigations/verdict-card.tsx +++ b/apps/web/src/components/investigations/verdict-card.tsx @@ -8,11 +8,13 @@ import { cn } from "@maple/ui/lib/utils" import { toEpochMs } from "@maple/ui/lib/time-format" import { SEVERITY_LABEL } from "@/components/errors/severity-badge" +import { StatFigure } from "@/components/common/stat-rail" import { CircleQuestionIcon, CircleXmarkIcon } from "@/components/icons" import { useTickingNow } from "@/hooks/use-ticking-now" import { type Elapsed, reportHeadline, splitDuration } from "./investigation-display" import { RunProgress } from "./run-progress" import { ConfidenceMeter } from "./confidence-meter" +import { EMPTY_VALUE } from "@maple/ui/lib/format" /** * What the investigation concluded, or how far it has got trying. One card, four @@ -94,14 +96,7 @@ function Stat({ label, children, last }: { label: string; children: ReactNode; l /** A big number with a small unit riding its baseline. */ function BigStat({ value, unit }: { value: string; unit: string }) { - return ( - - - {value} - - {unit} - - ) + return } /* ------------------------------------------------------------------------------------------------- @@ -153,14 +148,14 @@ function DiagnosedVerdict({ investigation }: { investigation: V2Investigation }) ) : ( // The report judged no severity. "—" rather than a stand-in level, // which would read as an assessment nobody made. - — + {EMPTY_VALUE} )} {timeToDiagnosis ? ( ) : ( - — + {EMPTY_VALUE} )} @@ -290,7 +285,7 @@ function FailedVerdict({ investigation }: { investigation: V2Investigation }) { {ranFor ? ( ) : ( - — + {EMPTY_VALUE} )} @@ -369,7 +364,7 @@ function InconclusiveVerdict({ investigation }: { investigation: V2Investigation {ranFor ? ( ) : ( - — + {EMPTY_VALUE} )} {/* What was eliminated is the run's actual output, so it gets the stat. */} @@ -383,7 +378,7 @@ function InconclusiveVerdict({ investigation }: { investigation: V2Investigation {report ? ( ) : ( - — + {EMPTY_VALUE} )} @@ -482,7 +477,7 @@ function elapsedBetween(from: string, to: string | null): Elapsed | null { function LiveElapsedStat({ from }: { from: string }) { const now = useTickingNow(true) const start = toEpochMs(from) - if (!Number.isFinite(start)) return — + if (!Number.isFinite(start)) return {EMPTY_VALUE} const { value, unit } = splitDuration(Math.max(0, now - start)) return } diff --git a/apps/web/src/components/layout/auth-layout.tsx b/apps/web/src/components/layout/auth-layout.tsx index 5fbeef55f6..10d0ffc54a 100644 --- a/apps/web/src/components/layout/auth-layout.tsx +++ b/apps/web/src/components/layout/auth-layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react" +import { cn } from "@maple/ui/lib/utils" -export function AuthLayout({ children, maxWidth = "max-w-sm" }: { children: ReactNode; maxWidth?: string }) { +function AuthLayoutRoot({ children, maxWidth = "max-w-sm" }: { children: ReactNode; maxWidth?: string }) { return (
    {/* Grid pattern — stronger lines, radial fade. Desktop only: on phones the form sits straight on the page and the lines run through it. */} @@ -38,3 +39,16 @@ export function AuthLayout({ children, maxWidth = "max-w-sm" }: { children: Reac
    ) } + +/** The card's one heading. */ +function Title({ children, className }: { children: ReactNode; className?: string }) { + return

    {children}

    +} + +/** The muted line under `Title`. */ +function Description({ children, className }: { children: ReactNode; className?: string }) { + return

    {children}

    +} + +/** Centred brand card for signed-out and interstitial screens (auth, consent, unsubscribe, errors). */ +export const AuthLayout = Object.assign(AuthLayoutRoot, { Title, Description }) diff --git a/apps/web/src/components/layout/dashboard-layout.tsx b/apps/web/src/components/layout/dashboard-layout.tsx index aeada9f825..566d9450f5 100644 --- a/apps/web/src/components/layout/dashboard-layout.tsx +++ b/apps/web/src/components/layout/dashboard-layout.tsx @@ -13,8 +13,7 @@ import { } from "@maple/ui/components/ui/breadcrumb" import { PageLayout } from "@maple/ui/components/ui/page-layout" import { Button } from "@maple/ui/components/ui/button" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" -import { Kbd } from "@maple/ui/components/ui/kbd" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { ChatBubbleSparkleIcon, LayoutLeftIcon, LayoutRightIcon } from "@/components/icons" import { openGlobalChat } from "@/components/chat/global-chat-sheet" import { ConnectButton } from "@/components/header/connect-button" @@ -26,6 +25,7 @@ import { AppUpdateBanner } from "@/components/layout/app-update-banner" import { Link } from "@tanstack/react-router" import { parseSearchFromHref } from "@/lib/href" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" +import { cn } from "@maple/ui/lib/utils" /* ------------------------------------------------------------------------------------------------- * DashboardLayout — the app's page shell, as a compound component. @@ -46,7 +46,7 @@ import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" * composition. * -----------------------------------------------------------------------------------------------*/ -interface BreadcrumbEntry { +export interface BreadcrumbEntry { label: string href?: string } @@ -74,7 +74,13 @@ function Root({ children }: { children: React.ReactNode }) { * cluster (AI chat, connection status, the mobile filter trigger). `children` are * extra page-specific actions, appended to that cluster. */ -function Breadcrumbs({ items, children }: { items: BreadcrumbEntry[]; children?: React.ReactNode }) { +function Breadcrumbs({ + items, + children, +}: { + items: ReadonlyArray + children?: React.ReactNode +}) { return (
    @@ -112,23 +118,9 @@ function Breadcrumbs({ items, children }: { items: BreadcrumbEntry[]; children?:
    - - - } - > - - - - Ask Maple AI C - - + + + {/* Self-gating: renders only when the sidebar has collapsed to a sheet *and* a @@ -199,9 +191,67 @@ function Header({ titleContent, children }: { titleContent?: React.ReactNode; ch ) } -/** The scrolling page body. */ -function Scroll({ children, className }: { children: React.ReactNode; className?: string }) { - return {children} +/** Centred column caps for a page body. `reading` suits prose and detail pages, `narrow` forms. */ +const PAGE_WIDTH = { + full: null, + reading: "mx-auto max-w-4xl", + narrow: "mx-auto max-w-3xl", +} as const + +/** Vertical rhythm between a body's top-level blocks. */ +const PAGE_GAP = { + none: null, + sm: "gap-3", + md: "gap-4", + lg: "gap-6", +} as const + +export type PageWidth = keyof typeof PAGE_WIDTH +export type PageGap = keyof typeof PAGE_GAP + +/** + * The scrolling page body. `width` centres the content in a capped column and `gap` + * spaces its top-level children, so routes stop wrapping the body in + * `mx-auto max-w-* space-y-*` divs of their own. + */ +function Scroll({ + children, + className, + width = "full", + gap = "none", +}: { + children: React.ReactNode + className?: string + width?: PageWidth + gap?: PageGap +}) { + if (width === "full") { + return ( + {children} + ) + } + return ( + +
    + {children} +
    +
    + ) +} + +/** + * Page-level tabs (view switch, section tabs). Goes inside `Sticky`, after `Header`, so + * every tabbed page puts its tab strip in the same place and it never scrolls away. + */ +function Tabs({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
    + {children} +
    + ) } /** @@ -244,6 +294,7 @@ export const DashboardLayout = { Sticky, Header, Scroll, + Tabs, Fill, RightPanel, /** Escape hatch for a page whose title is more than a string (a badge, a service dot). */ diff --git a/apps/web/src/components/layout/dashboard-page.tsx b/apps/web/src/components/layout/dashboard-page.tsx new file mode 100644 index 0000000000..32ddd8872d --- /dev/null +++ b/apps/web/src/components/layout/dashboard-page.tsx @@ -0,0 +1,145 @@ +import type * as React from "react" + +import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" +import type { TimeRangeSearch } from "@/components/time-range-picker/search" +import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" +import type { TimeRange } from "@/components/time-range-picker/types" +import type { TimePreset } from "@/lib/time-utils" + +import { DashboardLayout, type BreadcrumbEntry, type PageGap, type PageWidth } from "./dashboard-layout" + +/** A page whose data is windowed by the URL's `startTime`/`endTime`/`timePreset`. */ +export interface PageTimeScope { + /** The route's decoded search; only the window is read. */ + search: TimeRangeSearch + /** The preset when the URL carries none ("12h" lists, "1h" detail pages). */ + defaultPreset: string + onChange: (range: TimeRange, options?: { replace?: boolean }) => void + /** The effective window, shown when the URL carries none. */ + startTime?: string + endTime?: string + presets?: ReadonlyArray + maxRangeSeconds?: number +} + +export interface DashboardPageProps { + breadcrumbs: ReadonlyArray + /** Extra controls in the top bar, after the persistent cluster. */ + topbarActions?: React.ReactNode + /** Header left side: a `DashboardLayout.Title`, view tabs, a status strip. */ + titleContent?: React.ReactNode + /** Header right side, before the time controls. */ + headerActions?: React.ReactNode + /** Replaces the generated `Header` outright (a `DetailHeader`, a skeleton). */ + header?: React.ReactNode + /** Wires the URL window: refresh provider plus the time controls in the header. */ + time?: PageTimeScope + /** Page tabs, pinned under the header. */ + tabs?: React.ReactNode + /** Anything else pinned above the scroll area (a toolbar, a banner). */ + sticky?: React.ReactNode + filters?: React.ReactNode + filtersWidth?: string + rightPanel?: React.ReactNode + rightPanelTitle?: string + rightPanelWidth?: string + /** The body owns its scrolling (a transcript, a virtualized table): `Fill` instead of `Scroll`. */ + fill?: boolean + width?: PageWidth + gap?: PageGap + /** Extra classes for the `Scroll` area (padding overrides). Ignored with `fill`. */ + scrollClassName?: string + children: React.ReactNode +} + +/** + * The whole `DashboardLayout` tree for the common page: breadcrumbs, a sticky header + * (optionally carrying the URL time window), tabs, filter and context rails, and the body. + * Compose `DashboardLayout.*` by hand only for layouts this cannot express. + */ +export function DashboardPage({ + breadcrumbs, + topbarActions, + titleContent, + headerActions, + header, + time, + tabs, + sticky, + filters, + filtersWidth, + rightPanel, + rightPanelTitle, + rightPanelWidth, + fill = false, + width, + gap, + scrollClassName, + children, +}: DashboardPageProps) { + const timeControls = time ? ( + + ) : null + const actions = + headerActions && timeControls ? ( +
    + {headerActions} + {timeControls} +
    + ) : ( + (headerActions ?? timeControls) + ) + const headerNode = + header ?? + (titleContent || actions ? ( + {actions} + ) : null) + const hasSticky = headerNode || tabs || sticky + + const page = ( + + {topbarActions} + + {filters ? ( + {filters} + ) : null} + + {hasSticky ? ( + + {headerNode} + {tabs ? {tabs} : null} + {sticky} + + ) : null} + {fill ? ( + {children} + ) : ( + + {children} + + )} + + {rightPanel ? ( + + {rightPanel} + + ) : null} + + + ) + + if (!time) return page + return ( + + {page} + + ) +} diff --git a/apps/web/src/components/layout/result-page.tsx b/apps/web/src/components/layout/result-page.tsx new file mode 100644 index 0000000000..2d13319e53 --- /dev/null +++ b/apps/web/src/components/layout/result-page.tsx @@ -0,0 +1,132 @@ +import type * as React from "react" + +import { ErrorState } from "@/components/common/error-state" +import { ResourceNotFound } from "@/components/common/resource-not-found" +import { Result } from "@/lib/effect-atom" + +import type { BreadcrumbEntry } from "./dashboard-layout" +import { DashboardPage, type DashboardPageProps } from "./dashboard-page" + +type ValueSlots = + | "breadcrumbs" + | "children" + | "header" + | "titleContent" + | "headerActions" + | "tabs" + | "rightPanel" + +export interface ResultPageProps extends Omit { + /** The parent trail; the last crumb is appended per state. */ + breadcrumbs: ReadonlyArray + result: Result.Result + /** Narrows the loaded value; `null`/`undefined` means "not found". Pass `(v) => v` when it can't be empty. */ + select: (value: A) => B | null | undefined + /** Last crumb once loaded (the resource's name). */ + crumb: (value: B) => string + /** Body before the first value. */ + loading: React.ReactNode + /** Sticky header before the first value, usually a `DetailHeaderSkeleton`. */ + loadingHeader?: React.ReactNode + /** Body when `select` comes back empty, usually a `ResourceNotFound`. */ + notFound?: React.ReactNode + /** Errors that mean "no such resource" (a typed 404) render `notFound` instead of the error. */ + isNotFoundError?: (error: E) => boolean + /** Short-circuits to this not-found body, e.g. when the route param can't be decoded. */ + invalid?: React.ReactNode + /** Replaces the default `ErrorState` body. */ + error?: (error: E) => React.ReactNode + errorTitle?: string + onRetry?: () => void + /** Loaded-state slots, mirroring `DashboardPage`'s. */ + header?: (value: B) => React.ReactNode + titleContent?: (value: B) => React.ReactNode + headerActions?: (value: B) => React.ReactNode + tabs?: (value: B) => React.ReactNode + rightPanel?: (value: B) => React.ReactNode + children: (value: B, state: { readonly waiting: boolean }) => React.ReactNode +} + +interface PageState { + crumb: string + body: React.ReactNode + header?: React.ReactNode + titleContent?: React.ReactNode + headerActions?: React.ReactNode + tabs?: React.ReactNode + rightPanel?: React.ReactNode +} + +const DEFAULT_NOT_FOUND = + +/** + * A detail route's one shell. Loading, error, not-found and loaded states swap only the + * body, the header and the last breadcrumb, so the sidebar shell reconciles in place + * instead of every branch rebuilding (and remounting) its own `DashboardLayout.Root`. + */ +export function ResultPage({ + breadcrumbs, + result, + select, + crumb, + loading, + loadingHeader, + notFound = DEFAULT_NOT_FOUND, + isNotFoundError, + invalid, + error, + errorTitle, + onRetry, + header, + titleContent, + headerActions, + tabs, + rightPanel, + children, + ...page +}: ResultPageProps) { + const notFoundState: PageState = { crumb: "Not found", body: notFound } + const state: PageState = + invalid !== undefined + ? { crumb: "Not found", body: invalid } + : Result.builder(result) + .onSuccess((value, success): PageState => { + const selected = select(value) + if (selected === null || selected === undefined) return notFoundState + return { + crumb: crumb(selected), + body: children(selected, { waiting: success.waiting }), + header: header?.(selected), + titleContent: titleContent?.(selected), + headerActions: headerActions?.(selected), + tabs: tabs?.(selected), + rightPanel: rightPanel?.(selected), + } + }) + .onError((cause): PageState => { + if (isNotFoundError?.(cause)) return notFoundState + return { + crumb: "Error", + body: error ? ( + error(cause) + ) : ( + + ), + } + }) + .orElse((): PageState => ({ crumb: "Loading…", body: loading, header: loadingHeader })) + + return ( + + {state.body} + + ) +} diff --git a/apps/web/src/components/layout/section-header.tsx b/apps/web/src/components/layout/section-header.tsx deleted file mode 100644 index 30d73eb6e8..0000000000 --- a/apps/web/src/components/layout/section-header.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { cn } from "@maple/ui/lib/utils" - -/** - * Canonical section heading: an `Eyebrow` rendered as an `

    ` whose `id` is meant - * to be referenced by an `aria-labelledby` on the group it heads. Pass - * `className="mb-0"` when it sits inline (e.g. in a flex row beside an action). - */ -export function SectionHeader({ id, label, className }: { id?: string; label: string; className?: string }) { - return ( - - {label} - - ) -} diff --git a/apps/web/src/components/logs/highlighted-text.tsx b/apps/web/src/components/logs/highlighted-text.tsx index 614af75515..9f4e9b3470 100644 --- a/apps/web/src/components/logs/highlighted-text.tsx +++ b/apps/web/src/components/logs/highlighted-text.tsx @@ -44,7 +44,7 @@ export const HighlightedText = React.memo(function HighlightedText({ text, query segment.match ? ( {segment.text} diff --git a/apps/web/src/components/logs/log-hero-header.tsx b/apps/web/src/components/logs/log-hero-header.tsx index 384b9de8a6..5ebfef2e90 100644 --- a/apps/web/src/components/logs/log-hero-header.tsx +++ b/apps/web/src/components/logs/log-hero-header.tsx @@ -89,7 +89,16 @@ export function LogHeroHeader({ log, showClose = true }: LogHeroHeaderProps) { {log.serviceName} {showClose && ( - }> + + } + > )} diff --git a/apps/web/src/components/logs/log-meta-strip.tsx b/apps/web/src/components/logs/log-meta-strip.tsx index ac05b60a32..9d223101aa 100644 --- a/apps/web/src/components/logs/log-meta-strip.tsx +++ b/apps/web/src/components/logs/log-meta-strip.tsx @@ -3,6 +3,7 @@ import { ClockIcon, ExternalLinkIcon, LinkIcon, PulseIcon } from "@/components/i import { CopyableValue } from "@/components/attributes" import { CopyButton } from "@maple/ui/components/ui/copy-button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { shortId } from "@maple/ui/lib/ids" import { formatTimestampInTimezone } from "@/lib/timezone-format" import { encodeLogKey } from "@/lib/log-key" @@ -39,7 +40,7 @@ export function LogMetaStrip({ log, timeZone, showOpenFullPage = true }: LogMeta to="/traces/$traceId" params={{ traceId: log.traceId }} search={{ t: log.timestamp }} - className="inline-flex shrink-0 items-center gap-1 rounded border border-primary/20 bg-primary/5 px-1.5 py-0.5 font-mono text-[11px] text-primary hover:bg-primary/10 transition-colors" + className="inline-flex shrink-0 items-center gap-1 rounded border border-primary/20 bg-primary/5 px-1.5 py-0.5 font-mono text-2xs text-primary hover:bg-primary/10 transition-colors" title={`View trace ${log.traceId}`} > @@ -48,7 +49,7 @@ export function LogMetaStrip({ log, timeZone, showOpenFullPage = true }: LogMeta )} {log.spanId && ( - + span:{shortId(log.spanId, "span")} )} @@ -56,15 +57,14 @@ export function LogMetaStrip({ log, timeZone, showOpenFullPage = true }: LogMeta {/* Icon-only actions keep the strip on a single line in the narrow drawer. */}
    {showOpenFullPage && ( - } > - + )}
    -
    +			
     				
     			

    diff --git a/apps/web/src/components/logs/log-row-expanded.tsx b/apps/web/src/components/logs/log-row-expanded.tsx index e94027f37a..fac0ac6501 100644 --- a/apps/web/src/components/logs/log-row-expanded.tsx +++ b/apps/web/src/components/logs/log-row-expanded.tsx @@ -38,14 +38,14 @@ export function LogRowExpanded({ log, highlight, onOpenDetail }: LogRowExpandedP label="Log JSON" idleLabel="JSON" iconSize={10} - className="h-5 px-1.5 text-[10px]" + className="h-5 px-1.5 text-3xs" onClick={(e) => e.stopPropagation()} /> {/* Body + attributes share one height-bounded scroll area so even a very wide event stays a predictable size and scrolls vertically in place. */}
    -

    +

    diff --git a/apps/web/src/components/logs/log-search-help.tsx b/apps/web/src/components/logs/log-search-help.tsx index 66333d4c19..6aa15626b4 100644 --- a/apps/web/src/components/logs/log-search-help.tsx +++ b/apps/web/src/components/logs/log-search-help.tsx @@ -53,7 +53,7 @@ export function LogSearchHelp() {

    What you can search

    -

    +

    One box. It reads the shape of what you type and picks the lookup.

    @@ -63,10 +63,10 @@ export function LogSearchHelp() {
    -
    +
    {lookup.example}
    -
    +
    {lookup.does}{" "} ({lookup.note})
    @@ -76,11 +76,11 @@ export function LogSearchHelp() { {/* The text row's claim, shown rather than asserted — and rendered through the same splitter the stream uses, so it cannot drift. */} -
    +
    -

    +

    A trace scope becomes a chip you can remove. Wrap an id in{" "} "quotes" to search it as text instead.

    diff --git a/apps/web/src/components/logs/log-search-input.tsx b/apps/web/src/components/logs/log-search-input.tsx index 9ad2d55539..b5bab6a7bd 100644 --- a/apps/web/src/components/logs/log-search-input.tsx +++ b/apps/web/src/components/logs/log-search-input.tsx @@ -1,4 +1,4 @@ -import { FILTER_SECTION_LABEL } from "@maple/ui/components/filters/filter-styles" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Kbd } from "@maple/ui/components/ui/kbd" import { SearchInput } from "@maple/ui/components/ui/search-input" @@ -18,7 +18,7 @@ export function LogSearchInput({ value, onChange }: LogSearchInputProps) { return (
    - Search + Search
    + {MODE[mode].label} - + ) } diff --git a/apps/web/src/components/logs/log-text-button.tsx b/apps/web/src/components/logs/log-text-button.tsx index ba73d402df..a2681be6c4 100644 --- a/apps/web/src/components/logs/log-text-button.tsx +++ b/apps/web/src/components/logs/log-text-button.tsx @@ -6,7 +6,7 @@ export function LogTextButton({ className, ...props }: React.ComponentProps<"but
    ))} @@ -210,10 +207,3 @@ function BreakdownBars({ }) .render() } - -function formatBreakdownValue(value: number): string { - if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` - if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K` - if (Number.isInteger(value)) return String(value) - return value.toFixed(2) -} diff --git a/apps/web/src/components/metrics/metric-detail.tsx b/apps/web/src/components/metrics/metric-detail.tsx index ca88954627..57d38b34cf 100644 --- a/apps/web/src/components/metrics/metric-detail.tsx +++ b/apps/web/src/components/metrics/metric-detail.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react" import { Result } from "@/lib/effect-atom" import { getChartById } from "@maple/ui/components/charts/registry" import { ChartSkeleton } from "@maple/ui/components/charts/_shared/chart-skeleton" +import { ChartLoading } from "@maple/ui/components/charts/_shared/chart-state" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { ErrorState } from "@/components/common/error-state" @@ -164,7 +165,7 @@ export function MetricDetail({ metricName, state, startTime, endTime, onPatch }: .onInitial(() => (
    - +
    )) .onError((error) => ) @@ -172,17 +173,18 @@ export function MetricDetail({ metricName, state, startTime, endTime, onPatch }: const summary = summarizeCatalogRows(response.data, metricName, state.type) if (!summary) { return ( -
    -

    No data for this metric in the selected range

    -

    - {metricName} has no datapoints between{" "} - {startTime} and {endTime}. Widen the time range, or check that the service - emitting it is still running. + +

    + No data for this metric in the selected range +

    +

    + {metricName} has no datapoints in this window. + Widen the time range, or check that the service emitting it is still running.

    -
    + ) } return ( @@ -280,7 +282,9 @@ function MetricChart({ {queryLabel}} - action={unit ? unit: {unit} : undefined} + action={ + unit ? unit: {unit} : undefined + } />
    {Result.builder(result) diff --git a/apps/web/src/components/metrics/metric-graduation-actions.tsx b/apps/web/src/components/metrics/metric-graduation-actions.tsx index f3cb6f92d9..df40b4b667 100644 --- a/apps/web/src/components/metrics/metric-graduation-actions.tsx +++ b/apps/web/src/components/metrics/metric-graduation-actions.tsx @@ -1,4 +1,5 @@ import * as React from "react" +import { Effect, Exit } from "effect" import { useNavigate } from "@tanstack/react-router" import { countLabel } from "@maple/ui/lib/format" @@ -15,6 +16,7 @@ import { defaultWidgetLayout } from "@maple/domain/http" import { BellIcon, GridSquareCirclePlusIcon, LinkIcon } from "@/components/icons" import { useDashboardStore } from "@/hooks/use-dashboard-store" import { useAsyncAction } from "@/hooks/use-mutation-action" +import { ErrorState } from "@/components/common/error-state" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { encodeAlertChartToSearchParam } from "@/lib/alerts/widget-chart-param" import type { WidgetDataSource } from "@/components/dashboard-builder/types" @@ -91,15 +93,16 @@ function AddToDashboardDialog({ const navigate = useNavigate() const { dashboards, readOnly, addWidget, importDashboard } = useDashboardStore() const [newName, setNewName] = React.useState("") - const [error, setError] = React.useState(null) + const [error, setError] = React.useState<{ readonly title: string; readonly cause: unknown } | null>(null) const widgetDisplay = { title: draft.metricName, chartId: "query-builder-area" } const addToDashboard = (dashboardId: string) => { - try { - addWidget(dashboardId, "chart", buildWidgetDataSource(draft), widgetDisplay) - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Failed to add widget") + const exit = Effect.runSyncExit( + Effect.try(() => addWidget(dashboardId, "chart", buildWidgetDataSource(draft), widgetDisplay)), + ) + if (Exit.isFailure(exit)) { + setError({ title: "Failed to add widget", cause: exit }) return } onOpenChange(false) @@ -118,28 +121,32 @@ function AddToDashboardDialog({ */ const [createDashboard, creating] = useAsyncAction(async (name: string) => { setError(null) - try { - const dashboard = await importDashboard({ - name, - timeRange: { type: "relative", value: "12h" }, - widgets: [ - { - id: crypto.randomUUID(), - visualization: "chart", - dataSource: buildWidgetDataSource(draft), - display: widgetDisplay, - layout: { x: 0, y: 0, ...defaultWidgetLayout("chart") }, - }, - ], - }) - onOpenChange(false) - void navigate({ - to: "/dashboards/$dashboardId", - params: { dashboardId: dashboard.id }, - }) - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Failed to create dashboard") + const exit = await Effect.runPromiseExit( + Effect.tryPromise(() => + importDashboard({ + name, + timeRange: { type: "relative", value: "12h" }, + widgets: [ + { + id: crypto.randomUUID(), + visualization: "chart", + dataSource: buildWidgetDataSource(draft), + display: widgetDisplay, + layout: { x: 0, y: 0, ...defaultWidgetLayout("chart") }, + }, + ], + }), + ), + ) + if (Exit.isFailure(exit)) { + setError({ title: "Failed to create dashboard", cause: exit }) + return } + onOpenChange(false) + void navigate({ + to: "/dashboards/$dashboardId", + params: { dashboardId: exit.value.id }, + }) }) const handleCreate = async () => { @@ -201,7 +208,14 @@ function AddToDashboardDialog({
    - {error &&

    {error}

    } + {error && ( + + )}
    )} diff --git a/apps/web/src/components/metrics/metric-preview-card.tsx b/apps/web/src/components/metrics/metric-preview-card.tsx index 1143d38487..6e4dd5a915 100644 --- a/apps/web/src/components/metrics/metric-preview-card.tsx +++ b/apps/web/src/components/metrics/metric-preview-card.tsx @@ -68,13 +68,13 @@ export function MetricPreviewCard({ entry, points, loading, onOpen }: MetricPrev className="h-full w-full" /> ) : ( -
    +
    {loading ? "Loading…" : "Not enough datapoints for a preview"}
    )}
    -
    +
    {entry.serviceNames.length === 1 ? entry.serviceNames[0] @@ -82,7 +82,7 @@ export function MetricPreviewCard({ entry, points, loading, onOpen }: MetricPrev {entry.metricUnit && ( - + {entry.metricUnit} )} diff --git a/apps/web/src/components/metrics/metric-query-controls.tsx b/apps/web/src/components/metrics/metric-query-controls.tsx index 088623d783..661f347621 100644 --- a/apps/web/src/components/metrics/metric-query-controls.tsx +++ b/apps/web/src/components/metrics/metric-query-controls.tsx @@ -94,7 +94,7 @@ export function MetricQueryControls({ value={aggregation} onValueChange={(value) => onPatch({ agg: value ?? aggregation })} > - + diff --git a/apps/web/src/components/metrics/metrics-table.tsx b/apps/web/src/components/metrics/metrics-table.tsx index d5bdbc08ba..6dd9f9d94e 100644 --- a/apps/web/src/components/metrics/metrics-table.tsx +++ b/apps/web/src/components/metrics/metrics-table.tsx @@ -147,7 +147,7 @@ export function MetricsTable({ className="text-xs" /> {metric.metricDescription && ( - + {metric.metricDescription} )} diff --git a/apps/web/src/components/onboarding/onboarding-header-actions.tsx b/apps/web/src/components/onboarding/onboarding-header-actions.tsx index e0dbfc97e3..bdef4deee6 100644 --- a/apps/web/src/components/onboarding/onboarding-header-actions.tsx +++ b/apps/web/src/components/onboarding/onboarding-header-actions.tsx @@ -15,7 +15,7 @@ import { clearSelfHostedSessionToken } from "@/lib/services/common/self-hosted-a import { ClerkOrgSwitcherMenu, OrgAvatar } from "@/components/dashboard/org-switcher-menu" import { UserAvatar } from "@/components/dashboard/user-avatar" -const AVATAR_CLASS = "size-5 rounded-md text-[10px]" +const AVATAR_CLASS = "size-5 rounded-md text-3xs" export function OnboardingOrgSwitcher() { if (!isClerkAuthEnabled) return null diff --git a/apps/web/src/components/onboarding/step-plan.tsx b/apps/web/src/components/onboarding/step-plan.tsx index e2b77e9bd4..3f7988cbd6 100644 --- a/apps/web/src/components/onboarding/step-plan.tsx +++ b/apps/web/src/components/onboarding/step-plan.tsx @@ -3,6 +3,7 @@ import { useMapleCustomer } from "@/hooks/use-maple-customer" import { TRIAL_DURATION_DAYS } from "@/lib/billing/plans" import { PricingCards } from "@/components/settings/pricing-cards" import { Button } from "@maple/ui/components/ui/button" +import { Panel } from "@maple/ui/components/ui/panel" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { ArrowLeftIcon } from "@/components/icons" @@ -24,7 +25,7 @@ export function StepPlanLayout({ onBack, children }: { onBack?: () => void; chil Pick a plan

    Pick a plan to keep going

    -

    +

    Start a {TRIAL_DURATION_DAYS}-day free trial: we'll save your card now and won't charge until day {TRIAL_DURATION_DAYS}. Cancel anytime from settings.

    @@ -53,11 +54,7 @@ function PricingSkeleton() { className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-3xl mx-auto" > {[0, 1].map((i) => ( -
    +
    @@ -66,8 +63,8 @@ function PricingSkeleton() {
    - -
    + + ))}
    ) diff --git a/apps/web/src/components/onboarding/step-region.tsx b/apps/web/src/components/onboarding/step-region.tsx index 111cd299bb..be579522b2 100644 --- a/apps/web/src/components/onboarding/step-region.tsx +++ b/apps/web/src/components/onboarding/step-region.tsx @@ -12,6 +12,7 @@ import { useAsyncAction } from "@/hooks/use-mutation-action" import { useAtomSet } from "@/lib/effect-atom" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { OptionCard } from "@/components/common/option-card" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { RegionFlag } from "@/components/region/region-flag" import { currentRegion, type MapleRegion, regionAppUrl } from "@/lib/region" @@ -125,7 +126,7 @@ export function StepRegion() { } footer={ {errorMessage ?? "An organization stays in the region it starts in."} diff --git a/apps/web/src/components/onboarding/step-team.tsx b/apps/web/src/components/onboarding/step-team.tsx index a2e84aca0c..66f30fa2e3 100644 --- a/apps/web/src/components/onboarding/step-team.tsx +++ b/apps/web/src/components/onboarding/step-team.tsx @@ -4,6 +4,8 @@ import { Button } from "@maple/ui/components/ui/button" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" import { ArrowLeftIcon, CircleCheckIcon } from "@/components/icons" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { useKeyedAsyncAction } from "@/hooks/use-mutation-action" import type { OnboardingTeamActions, OnboardingTeamError } from "./team-actions" export function StepTeam({ @@ -25,7 +27,6 @@ export function StepTeam({ const [savedName, setSavedName] = useState(initialName) const [email, setEmail] = useState("") const [invited, setInvited] = useState(initialInvitations) - const [pending, setPending] = useState<"rename" | "invite" | null>(null) const [feedback, setFeedback] = useState<{ message: string; error: boolean } | null>(null) const inFlight = useRef(false) const emailAlreadyInvited = invited.some( @@ -33,18 +34,23 @@ export function StepTeam({ ) const dirtyName = name.trim() !== savedName + // One operation at a time: `inFlight` blocks a double submit before the pending state renders. + const operation = useKeyedAsyncAction( + (_op: "rename" | "invite", effect: Effect.Effect) => + Effect.runPromiseExit(effect), + ) + const busy = operation.anyPending + function run( - operation: "rename" | "invite", + op: "rename" | "invite", effect: Effect.Effect, onSuccess: () => void, ) { if (inFlight.current) return inFlight.current = true - setPending(operation) setFeedback(null) - void Effect.runPromiseExit(effect).then((result) => { + void operation.run(op, effect).then((result) => { inFlight.current = false - setPending(null) if (Exit.isSuccess(result)) { onSuccess() } else { @@ -95,14 +101,14 @@ export function StepTeam({ onChange={(event) => setName(event.target.value)} required maxLength={256} - disabled={!actions || pending !== null} + disabled={!actions || busy} className="min-w-40 flex-1" /> @@ -134,15 +140,15 @@ export function StepTeam({ value={email} onChange={(event) => setEmail(event.target.value)} required - disabled={!actions || pending !== null} + disabled={!actions || busy} aria-describedby="onboarding-invite-help" className="min-w-40 flex-1" /> @@ -165,16 +171,16 @@ export function StepTeam({ )}
    -

    +

    {feedback?.message}

    - -
    diff --git a/apps/web/src/components/query-builder/where-clause-editor.tsx b/apps/web/src/components/query-builder/where-clause-editor.tsx index 963f1bd2f4..7783e226cc 100644 --- a/apps/web/src/components/query-builder/where-clause-editor.tsx +++ b/apps/web/src/components/query-builder/where-clause-editor.tsx @@ -2,6 +2,7 @@ import * as React from "react" import * as ReactDOM from "react-dom" import { Textarea } from "@maple/ui/components/ui/textarea" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { WhereClauseAutocompleteScope, WhereClauseAutocompleteValues, @@ -170,7 +171,7 @@ export function WhereClauseEditor({ onClick={() => handleApplySuggestion(index)} > {suggestion.label} - {suggestion.kind} + {suggestion.kind} ))}
    diff --git a/apps/web/src/components/region/wrong-region-screen.tsx b/apps/web/src/components/region/wrong-region-screen.tsx index 11cd626822..e453a9ae24 100644 --- a/apps/web/src/components/region/wrong-region-screen.tsx +++ b/apps/web/src/components/region/wrong-region-screen.tsx @@ -20,13 +20,13 @@ export function WrongRegionScreen({ region }: { region: MapleRegion }) { return ( -

    + {orgName} is in the {target.short} region -

    -

    + + Its data is stored in the {target.name}. You are on the {here.short} dashboard, which keeps a separate set of organizations. -

    +
    {url !== undefined && ( diff --git a/apps/web/src/components/releases/release-changeset.tsx b/apps/web/src/components/releases/release-changeset.tsx index 10076ab8b4..2b64c4bbad 100644 --- a/apps/web/src/components/releases/release-changeset.tsx +++ b/apps/web/src/components/releases/release-changeset.tsx @@ -129,7 +129,7 @@ function ReleaseChangesetLoaded({ rangesKey, base }: { rangesKey: string; base: const range = Result.isSuccess(result) ? result.value.ranges[0] : undefined const action = ( - + since {shortReleaseLabel(base)} ) @@ -183,12 +183,12 @@ function ReleaseChangesetLoaded({ rangesKey, base }: { rangesKey: string; base: > {subject} - + {shortReleaseLabel(commit.sha)} {author}
    - + {formatRelativeTimeOrDate( new Date(commit.committedAt).toISOString(), undefined, @@ -199,7 +199,7 @@ function ReleaseChangesetLoaded({ rangesKey, base }: { rangesKey: string; base: ) })} {hidden > 0 ? ( -
    +
    {hidden} older {pluralize(hidden, "commit")} not shown
    ) : null} diff --git a/apps/web/src/components/releases/release-deploy-overview.tsx b/apps/web/src/components/releases/release-deploy-overview.tsx index 26725f01c2..aff371fc06 100644 --- a/apps/web/src/components/releases/release-deploy-overview.tsx +++ b/apps/web/src/components/releases/release-deploy-overview.tsx @@ -3,7 +3,15 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueDocument } from "@maple/domain/http" import { ServiceDot } from "@maple/ui/components/service-dot" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { countLabel, formatErrorRate, formatLatency, formatNumber, pluralize } from "@maple/ui/lib/format" +import { + countLabel, + EMPTY_VALUE, + formatErrorRate, + formatLatency, + formatNumber, + formatPercent, + pluralize, +} from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { RelativeTime } from "@/components/common/relative-time" @@ -32,6 +40,7 @@ import { import { EmptyMessage } from "@maple/ui/components/ui/empty" import { eyebrowVariants } from "@maple/ui/components/ui/eyebrow" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { Panel } from "@maple/ui/components/ui/panel" const ISSUE_LIMIT = 100 const NO_COUNTS: ReadonlyMap = new Map() @@ -78,10 +87,12 @@ export function ReleaseDeployOverview({ const { impacts, group } = derived if (group === undefined) { return ( - - {shortReleaseLabel(commitSha)} served no traffic in this - window. Widen the time range to include its deploy. - + + + {shortReleaseLabel(commitSha)} served no traffic in + this window. Widen the time range to include its deploy. + + ) } @@ -140,7 +151,7 @@ function BeforeAfter({ return ( {before !== undefined ? ( - {format(before)} → + {format(before)} → ) : null} - against the version each replaced - + against the version each replaced } > - + Service Replaced Requests @@ -212,7 +221,7 @@ function DeployServiceRow({ timeSearch: TimeRangeSearch }) { return ( - + - - {impact.baseline ? shortReleaseLabel(impact.baseline.commitSha) : "-"} + + {impact.baseline ? shortReleaseLabel(impact.baseline.commitSha) : EMPTY_VALUE} {formatNumber(impact.spanCount)} @@ -264,7 +273,7 @@ function DeployServiceRow({ : "text-muted-foreground", )} > - {impact.share === undefined ? "-" : `${Math.round(impact.share * 100)}%`} + {impact.share === undefined ? EMPTY_VALUE : formatPercent(impact.share)} ) diff --git a/apps/web/src/components/releases/release-detail-panels.tsx b/apps/web/src/components/releases/release-detail-panels.tsx index 93b24c0725..edcedc7d95 100644 --- a/apps/web/src/components/releases/release-detail-panels.tsx +++ b/apps/web/src/components/releases/release-detail-panels.tsx @@ -1,5 +1,12 @@ import { Link } from "@tanstack/react-router" -import { countLabel, formatErrorRate, formatLatency, formatNumber } from "@maple/ui/lib/format" +import { + countLabel, + EMPTY_VALUE, + formatErrorRate, + formatLatency, + formatNumber, + formatPercent, +} from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { RelativeTime } from "@/components/common/relative-time" @@ -32,11 +39,11 @@ function changeTone(row: ComparisonRow): string { } function formatChange(row: ComparisonRow): string { - if (row.change === undefined) return "—" + if (row.change === undefined) return EMPTY_VALUE if (row.direction === "lower-is-better" && row.change >= 1) return `${(1 + row.change).toFixed(1)}×` if (!Number.isFinite(row.change)) return "from 0" - const pct = Math.round(row.change * 100) - return `${pct > 0 ? "+" : ""}${pct}%` + const sign = row.change > 0 ? "+" : row.change < 0 ? "-" : "" + return `${sign}${formatPercent(Math.abs(row.change))}` } const ratioChange = (value: number, baseline: number | undefined): number | undefined => @@ -114,11 +121,11 @@ export function ReleaseComparison({ impact }: { impact: ReleaseServiceImpact }) title="This version vs. the previous one" action={ baseline === undefined ? ( - no previous version + no previous version ) : ( replaced {shortReleaseLabel(baseline.commitSha)} @@ -133,7 +140,7 @@ export function ReleaseComparison({ impact }: { impact: ReleaseServiceImpact }) ) : (
    - + @@ -156,7 +163,7 @@ export function ReleaseComparison({ impact }: { impact: ReleaseServiceImpact }) {row.format(row.value)} - {row.baseline === undefined ? "—" : row.format(row.baseline)} + {row.baseline === undefined ? EMPTY_VALUE : row.format(row.baseline)} )} {baseline !== undefined && !comparable ? ( -
    +
    Changes are withheld below {MIN_COMPARE_SPANS} requests on either side.
    ) : null} @@ -200,7 +207,7 @@ export function ReleaseVersionsRail({ + {countLabel(sorted.length, "version")} } diff --git a/apps/web/src/components/releases/release-health.tsx b/apps/web/src/components/releases/release-health.tsx index dd95effa8e..cf0e3edc4e 100644 --- a/apps/web/src/components/releases/release-health.tsx +++ b/apps/web/src/components/releases/release-health.tsx @@ -3,6 +3,8 @@ import { Badge } from "@maple/ui/components/ui/badge" import { TONE_FILL, TONE_SOFT } from "@maple/ui/lib/tone" import type { ReleaseHealth } from "./release-model" +import { formatPercent } from "@maple/ui/lib/format" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" export const RELEASE_HEALTH_LABEL = { regressed: "errors up", @@ -43,15 +45,21 @@ interface ReleaseHealthPillProps { export function ReleaseHealthPill({ health, label, className }: ReleaseHealthPillProps) { return ( - - {label ?? RELEASE_HEALTH_LABEL[health]} - + + + } + > + {label ?? RELEASE_HEALTH_LABEL[health]} + + {RELEASE_HEALTH_DESCRIPTION[health]} + ) } @@ -70,9 +78,9 @@ export function releaseHealthFigure(impact: { ? `errors ${impact.errorRatio.toFixed(1)}×` : "errors from 0" case "watch": - return impact.p95Delta === undefined ? undefined : `p95 +${Math.round(impact.p95Delta * 100)}%` + return impact.p95Delta === undefined ? undefined : `p95 +${formatPercent(impact.p95Delta)}` case "rolling": - return impact.share === undefined ? undefined : `${Math.round(impact.share * 100)}% rolling out` + return impact.share === undefined ? undefined : `${formatPercent(impact.share)} rolling out` case "healthy": return undefined } diff --git a/apps/web/src/components/releases/release-issues-panel.tsx b/apps/web/src/components/releases/release-issues-panel.tsx index 86de125add..e233e15da8 100644 --- a/apps/web/src/components/releases/release-issues-panel.tsx +++ b/apps/web/src/components/releases/release-issues-panel.tsx @@ -69,12 +69,12 @@ export function IssueList({ {issues.length} diff --git a/apps/web/src/components/releases/releases-live-now.tsx b/apps/web/src/components/releases/releases-live-now.tsx index 5003352a4c..bf45855b7f 100644 --- a/apps/web/src/components/releases/releases-live-now.tsx +++ b/apps/web/src/components/releases/releases-live-now.tsx @@ -2,7 +2,7 @@ import { Panel } from "@maple/ui/components/ui/panel" import { Link } from "@tanstack/react-router" import { ServiceDot } from "@maple/ui/components/service-dot" import { cn } from "@maple/ui/lib/utils" -import { countLabel, pluralize } from "@maple/ui/lib/format" +import { countLabel, formatPercent, pluralize } from "@maple/ui/lib/format" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { ROLLOUT_COMPLETE_SHARE, shortReleaseLabel, type LiveVersion } from "./release-model" @@ -30,11 +30,11 @@ export function ReleasesLiveNow({ live, timeSearch, environments }: ReleasesLive
    Live now {behind > 0 ? ( - + {countLabel(behind, "service")} not on the latest release ) : ( - + Every service is on its latest release )} @@ -64,23 +64,23 @@ export function ReleasesLiveNow({ live, timeSearch, environments }: ReleasesLive > {version.serviceName} - + {shortReleaseLabel(version.commitSha)} {version.share < ROLLOUT_COMPLETE_SHARE ? ( - - {Math.round(version.share * 100)}% + + {formatPercent(version.share)} ) : null} {version.behind > 0 ? ( - + {version.behind} behind ) : null} ))} {hidden > 0 ? ( - +{hidden} + +{hidden} ) : null}
    diff --git a/apps/web/src/components/releases/releases-table.tsx b/apps/web/src/components/releases/releases-table.tsx index 7e731a7aa1..972f35130b 100644 --- a/apps/web/src/components/releases/releases-table.tsx +++ b/apps/web/src/components/releases/releases-table.tsx @@ -32,6 +32,7 @@ import { type ReleaseServiceImpact, } from "./release-model" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Panel } from "@maple/ui/components/ui/panel" /** The bulk VCS lookup takes one page of shas; rows past it fall back to the sha. */ const COMMIT_RESOLVE_LIMIT = 50 @@ -62,7 +63,7 @@ function ResolvedCommits({ return <>{children(commits)} } -interface DeltaProps { +interface BaselineShiftProps { value: number baseline: number | undefined format: (value: number) => string @@ -71,11 +72,11 @@ interface DeltaProps { } /** "0.30% → 1.24%": the version it replaced, then this one. */ -function Delta({ value, baseline, format, tone }: DeltaProps) { +function BaselineShift({ value, baseline, format, tone }: BaselineShiftProps) { return ( {baseline !== undefined ? ( - + {format(baseline)} → ) : null} @@ -132,7 +133,7 @@ function ServiceChips({ services }: { services: ReadonlyArray ))} {flagged.length > 2 ? ( - +{flagged.length - 2} + +{flagged.length - 2} ) : null} ) @@ -178,7 +179,7 @@ function ReleaseTitle({ commitSha, commit, health, figure, range }: ReleaseTitle @@ -186,7 +187,7 @@ function ReleaseTitle({ commitSha, commit, health, figure, range }: ReleaseTitle {health === "healthy" ? null : }
    -
    +
    {commit ? {shortReleaseLabel(commitSha)} : null} {author ? {author} : null} {!commit && !resolvable ? deployment reference : null} @@ -273,13 +274,10 @@ function ReleasesTableRows({ let lastDay: string | undefined return ( -
    +
    - + Release Services Deployed @@ -360,7 +358,7 @@ function ReleasesTableRows({ ) : null} - - - -
    -
    + ) } diff --git a/apps/web/src/components/releases/releases-timeline.tsx b/apps/web/src/components/releases/releases-timeline.tsx index c73e44742c..81b2e3fca4 100644 --- a/apps/web/src/components/releases/releases-timeline.tsx +++ b/apps/web/src/components/releases/releases-timeline.tsx @@ -4,7 +4,7 @@ import { ServiceDot } from "@maple/ui/components/service-dot" import { cn } from "@maple/ui/lib/utils" import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" -import { pluralize } from "@maple/ui/lib/format" +import { countLabel, pluralize } from "@maple/ui/lib/format" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { useTimezonePreference } from "@/hooks/use-timezone-preference" @@ -16,6 +16,7 @@ import { type ReleaseHealth, type ReleaseServiceImpact, } from "./release-model" +import { formatTimestampInTimezone } from "@/lib/timezone-format" /** Lanes beyond this fold into a trailing count; the table still lists every release. */ const MAX_LANES = 12 @@ -153,13 +154,7 @@ function axisLabels(startMs: number, endMs: number, timeZone: string): string[] return [0, 0.25, 0.5, 0.75, 1].map((ratio) => { const date = new Date(startMs + span * ratio) return showTime - ? date.toLocaleString(undefined, { - timeZone, - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }) + ? formatTimestampInTimezone(date, { timeZone, style: "range" }) : date.toLocaleDateString(undefined, { timeZone, month: "short", day: "numeric" }) }) } @@ -177,7 +172,7 @@ function markerTitle(marker: Marker, timeZone: string): string { .map((m) => shortReleaseLabel(m.commitSha)) .join(", ") const more = marker.members.length > 6 ? `, +${marker.members.length - 6} more` : "" - return `${marker.members.length} deploys · ${formatRelativeTimeOrDate(oldest.firstSeen, undefined, timeZone)} → ${formatRelativeTimeOrDate(newest.firstSeen, undefined, timeZone)}\n${listed}${more}` + return `${countLabel(marker.members.length, "deploy")} · ${formatRelativeTimeOrDate(oldest.firstSeen, undefined, timeZone)} → ${formatRelativeTimeOrDate(newest.firstSeen, undefined, timeZone)}\n${listed}${more}` } /** @@ -219,7 +214,7 @@ export function ReleasesTimeline({ +
    {RELEASE_HEALTH_ORDER.map((band) => ( - + @@ -278,7 +273,7 @@ export function ReleasesTimeline({ )} /> {count > 1 ? ( - + {count} ) : null} @@ -289,7 +284,7 @@ export function ReleasesTimeline({
    ))} {hidden > 0 ? ( -
    +
    +{hidden} more {pluralize(hidden, "service")} @@ -298,7 +293,7 @@ export function ReleasesTimeline({
    {labels.map((label, index) => ( diff --git a/apps/web/src/components/replays/active-user-filter.tsx b/apps/web/src/components/replays/active-user-filter.tsx index 9230ce078c..4fc565f41c 100644 --- a/apps/web/src/components/replays/active-user-filter.tsx +++ b/apps/web/src/components/replays/active-user-filter.tsx @@ -1,3 +1,6 @@ +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { countLabel } from "@maple/ui/lib/format" import { XmarkIcon } from "@/components/icons" import { gradientFor } from "./replay-format" @@ -31,7 +34,7 @@ export function ActiveUserFilter({ }: ActiveUserFilterProps) { const initial = (userId[0] ?? "?").toUpperCase() return ( -
    +
    @@ -40,22 +43,21 @@ export function ActiveUserFilter({
    {label} - + {userId} - +
    - {count.toLocaleString()} session{count === 1 ? "" : "s"} loaded + {countLabel(count, "session")} loaded
    - +
    ) } diff --git a/apps/web/src/components/replays/marker-legend.tsx b/apps/web/src/components/replays/marker-legend.tsx index b2acb205d3..62ce0932f9 100644 --- a/apps/web/src/components/replays/marker-legend.tsx +++ b/apps/web/src/components/replays/marker-legend.tsx @@ -14,7 +14,7 @@ export function MarkerLegend({ className }: { className?: string }) { return (
      + {/* Shared time region. The playhead overlays every track; scrubbing is driven from the scrub surface spanning the ruler + activity rows. */} @@ -119,7 +124,7 @@ export function ReplayEditorTimeline({
    -
    + ) } @@ -148,7 +153,7 @@ function TimeRuler({ totalMs }: { totalMs: number }) { ( -
    + Traces {count != null && count > 0 && ( @@ -292,7 +301,7 @@ const TracesTrack = React.memo(function TracesTrack({ {count} )} -
    + ) if (traceIds.length === 0) { @@ -320,10 +329,15 @@ const TracesTrack = React.memo(function TracesTrack({ )) - .onError(() => ( + .onError((error) => ( <> {header(null)} -

    Couldn’t load correlated traces.

    + )) .onSuccess((res) => { @@ -332,9 +346,9 @@ const TracesTrack = React.memo(function TracesTrack({ <> {header(summaries.length)} {summaries.length === 0 ? ( -

    - Linked traces aren’t available yet — they may still be ingesting. -

    + + Linked traces aren't available yet, they may still be ingesting. + ) : (
      {summaries.map((s) => ( @@ -370,65 +384,81 @@ function TraceRow({ summary, seek }: { summary: SessionTraceSummary; seek: SeekC "flex shrink-0 items-center gap-1.5 border-r border-border/60 pr-2 pl-2 text-xs", )} > - - - - - + + - + } + > + + + + + + + {summary.rootServiceName} + - - {summary.rootServiceName} - - - + + + Open trace in new tab · {summary.rootServiceName} · {summary.spanCount} spans + + - + + seek.seekDisplay(range.displayStartMs)} + className="relative h-11 flex-1 cursor-pointer" + /> + } + > + + + + {summary.rootSpanName}: fired at {formatClock(range.displayStartMs)} ·{" "} + {Math.round(summary.durationMs)}ms + {range.outOfRange ? " · outside recording" : ""} + + {expanded && ( @@ -453,7 +483,7 @@ function TraceBar({ return ( )) - .onError(() => ( -
      - Couldn’t load spans for this trace. -
      + .onError((error) => ( + )) .onSuccess((res) => { const spans: ReadonlyArray = res.spans if (spans.length === 0) { return ( -
      +
      No spans found for this trace.
      ) @@ -553,27 +586,35 @@ function SpanRowItem({ span, seek }: { span: SpanRow; seek: SeekContext }) { spanName={span.spanName} spanAttributes={span.spanAttributes} spanKind={span.spanKind} - className="text-[10px]" - textClassName="text-[10px] text-muted-foreground" + className="text-3xs" + textClassName="text-3xs text-muted-foreground" />
      - + + seek.seekDisplay(range.displayStartMs)} + className="relative h-5 flex-1 cursor-pointer" + /> + } + > + + + + {span.spanName}: fired at {formatClock(range.displayStartMs)} ·{" "} + {Math.round(span.durationMs)}ms + {range.outOfRange ? " · outside recording" : ""} + + ) } diff --git a/apps/web/src/components/replays/replay-player.tsx b/apps/web/src/components/replays/replay-player.tsx index 02e58a156d..1afb1b878f 100644 --- a/apps/web/src/components/replays/replay-player.tsx +++ b/apps/web/src/components/replays/replay-player.tsx @@ -2,9 +2,16 @@ import * as React from "react" import { createPortal } from "react-dom" import "@rrweb/replay/dist/style.css" import { Button } from "@maple/ui/components/ui/button" +import { FilterChip } from "@maple/ui/components/ui/filter-chip" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Spinner } from "@maple/ui/components/ui/spinner" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { Panel } from "@maple/ui/components/ui/panel" import { cn } from "@maple/ui/lib/utils" +import { TONE_SOFT } from "@maple/ui/lib/tone" import { displayError } from "@/lib/error-messages" import { DocsLink } from "@/components/common/docs-link" +import { SegmentedSelect } from "@/components/common/segmented-select" import { type DisplayMarker, type IdleBand, useReplayPlayer } from "./replay-player-context" import { GlobeIcon, @@ -19,7 +26,7 @@ import { formatClock, hostFromUrl, MARKER_STYLES } from "./replay-format" import { MarkerLegend } from "./marker-legend" import { useReplayKeyboardShortcuts } from "@/hooks/use-replay-keyboard-shortcuts" -const SPEEDS = [0.5, 1, 2, 4, 8] as const +const SPEED_OPTIONS = [0.5, 1, 2, 4, 8].map((s) => ({ value: String(s), label: `${s}×` })) /** Host + path for the faux browser address bar; blank URL reads as about:blank. */ function prettyUrl(url: string | undefined): string { @@ -71,7 +78,7 @@ export function ReplaySurface({
      +
      ) } return ( -
      +
      -
      + ) } @@ -235,13 +242,14 @@ function ReplayControls({ detached = false }: { detached?: boolean }) { )} >
      - +
      -
      - {SPEEDS.map((s) => ( - - ))} -
      + changeSpeed(Number(value))} + /> - + + toggleSkipInactive()} + > + Skip idle + + } + /> + + {skipInactive ? "Idle gaps skipped during playback" : "Skip idle gaps"} + + - +
      ) @@ -453,7 +451,7 @@ function HoverTimeBubble({ const left = Math.min(Math.max(hover.clientX, EDGE_MARGIN), window.innerWidth - EDGE_MARGIN) return createPortal(
      {formatClock(hover.ms)} @@ -489,7 +487,7 @@ function PlayerError({ error, onRetry }: { error: unknown; onRetry: () => void } role="alert" aria-live="polite" > -
      +
      @@ -511,11 +509,7 @@ function PlayerMessage({ children, spinner }: { children: React.ReactNode; spinn
      - {spinner ? ( - - ) : ( - - )} + {spinner ? : }

      {children}

      diff --git a/apps/web/src/components/replays/replays-filter-sidebar.tsx b/apps/web/src/components/replays/replays-filter-sidebar.tsx index 13fa6d9bb3..9114d13348 100644 --- a/apps/web/src/components/replays/replays-filter-sidebar.tsx +++ b/apps/web/src/components/replays/replays-filter-sidebar.tsx @@ -17,8 +17,9 @@ import { nextTagSelection, sessionTagsFromSearch, } from "@/components/replays/session-tags" +import { EmptyMessage } from "@maple/ui/components/ui/empty" import { SearchInput } from "@maple/ui/components/ui/search-input" -import { FILTER_SECTION_LABEL } from "@maple/ui/components/filters/filter-styles" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Separator } from "@maple/ui/components/ui/separator" import { cn } from "@maple/ui/lib/utils" import { @@ -324,9 +325,9 @@ export function ReplaysFilterSidebar({ facetsResult }: ReplaysFilterSidebarProps {/* The rule is the separator's job: everything above is picked from a list the warehouse supplied, everything below is typed from memory. */} -

      + Identity -

      + {!hasFacets && ( -

      + No sessions in the selected time range -

      + )} diff --git a/apps/web/src/components/replays/replays-toolbar.tsx b/apps/web/src/components/replays/replays-toolbar.tsx index 9209ba12b0..cb1ada1f2c 100644 --- a/apps/web/src/components/replays/replays-toolbar.tsx +++ b/apps/web/src/components/replays/replays-toolbar.tsx @@ -1,8 +1,10 @@ import { ToolbarSearch } from "@maple/ui/components/toolbar" import { refreshingClass } from "@maple/ui/lib/refreshing" -import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { FilterChip } from "@maple/ui/components/ui/filter-chip" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" -import { TONE_BORDER, TONE_FILL, TONE_SOFT } from "@maple/ui/lib/tone" +import type { Tone } from "@maple/ui/lib/tone" interface ReplaysToolbarProps { /** Current `q` search param (URL substring filter). */ @@ -57,26 +59,22 @@ export function ReplaysToolbar({ - {errorSessions.toLocaleString()} with errors + {formatNumber(errorSessions)} with errors {engagedSessions !== undefined && ( - {engagedSessions.toLocaleString()} + {formatNumber(engagedSessions)} )} engaged @@ -88,33 +86,26 @@ export function ReplaysToolbar({ function TriageChip({ pressed, onToggle, - title, - pressedClassName, - idleClassName, - dotClassName, + tooltip, + tone, children, }: { pressed: boolean onToggle: () => void - title: string - pressedClassName: string - idleClassName: string - dotClassName: string + tooltip: string + tone: Tone children: React.ReactNode }) { return ( - + + onToggle()} tone={tone} dot> + {children} + + } + /> + {tooltip} + ) } diff --git a/apps/web/src/components/replays/session-detail-parts.tsx b/apps/web/src/components/replays/session-detail-parts.tsx index d3b3732e8b..92b1826ffd 100644 --- a/apps/web/src/components/replays/session-detail-parts.tsx +++ b/apps/web/src/components/replays/session-detail-parts.tsx @@ -4,6 +4,7 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Badge } from "@maple/ui/components/ui/badge" import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { GlobeIcon, ClockIcon } from "@/components/icons" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { RelativeTime } from "@/components/common/relative-time" @@ -71,23 +72,29 @@ export function SessionIdentityBar({

      {label}

      - - - {hostFromUrl(urlInitial)} - + + + } + > + + {hostFromUrl(urlInitial)} + + {urlInitial} + {startedValid && ( )} - + {shortId(sessionId, "session", { length: 8 })}
      - + {formatSessionDuration(durationMs)} duration @@ -123,11 +130,11 @@ export function ReplayDetailSkeleton() {
      - - - + + +
      - +
      ) } diff --git a/apps/web/src/components/replays/session-events-panel.tsx b/apps/web/src/components/replays/session-events-panel.tsx index 184f469844..8c0a940f0e 100644 --- a/apps/web/src/components/replays/session-events-panel.tsx +++ b/apps/web/src/components/replays/session-events-panel.tsx @@ -1,10 +1,12 @@ import * as React from "react" +import { EmptyMessage } from "@maple/ui/components/ui/empty" import { shortId } from "@maple/ui/lib/ids" import * as Predicate from "effect/Predicate" import { Link } from "@tanstack/react-router" import { cn } from "@maple/ui/lib/utils" import { httpStatusTone } from "@maple/ui/lib/http" import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" +import { latencyLevel } from "@maple/ui/lib/latency-tone" import { Result, useAtomValue } from "@/lib/effect-atom" import { getSessionTranscriptResultAtom, @@ -51,6 +53,8 @@ import { type IconComponent, } from "@/components/icons" import { countryFlag, countryName } from "@/components/analytics/labels" +import { ErrorState } from "@/components/common/error-state" +import { StatFigure } from "@/components/common/stat-rail" import { browserIconFor, deviceIconFor } from "./session-icons" import { formatClock, formatSessionDuration, type ReplayPartitionWindow } from "./replay-format" import { useReplayPlayer } from "./replay-player-context" @@ -210,7 +214,7 @@ function RailTabItem({ > {children} {count != null && ( - {count} + {count} )} ) @@ -297,10 +301,8 @@ function EventsTab({ sessionId, window }: { sessionId: string; window?: ReplayPa return Result.builder(result) .onInitial(() => ) - .onError(() => ( -
      - Couldn't load session events. -
      + .onError((error) => ( + )) .onSuccess((data) => renderBody(data.data as ReadonlyArray)) .orElse(() => ) @@ -458,7 +460,7 @@ function EventFilterBar({ selected="bg-muted text-foreground" onClick={() => onChange("all")} > - All + All {KIND_FILTERS.map((id) => { const { Icon, tone, selected } = EVENT_KIND_VISUALS[id] @@ -520,7 +522,7 @@ function FilterChip({ )} > {children} - {count} + {count} } /> @@ -561,7 +563,7 @@ function EventKindLegend({ className }: { className?: string }) { return (
    • {label} @@ -594,7 +596,7 @@ function EventProps({ attributes }: { attributes?: string }) { if (entries.length === 0) return null return ( - + {entries.map(([key, value]) => ( {key}={value} @@ -644,7 +646,7 @@ function EventLine({ className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-muted/50" title="Seek replay to this moment" > - + {clockAt(ev.timestamp)} @@ -654,9 +656,9 @@ function EventLine({ {lead} - {trail && {trail}} + {trail && {trail}} - + {ev.type === "network" ? ( <> @@ -664,7 +666,7 @@ function EventLine({ = 1000 + isError || isSlowRequest(ev.netDurationMs) ? cn("font-semibold", TONE_TEXT.warn) : "text-muted-foreground", isError && TONE_TEXT.crit, @@ -693,21 +695,19 @@ function EventDetail({ ev }: { ev: EventRow }) { const fullUrl = ev.type === "network" ? ev.netUrl : ev.url return (
      - {fullUrl && ( - {fullUrl} - )} + {fullUrl && {fullUrl}} {ev.type === "click" && ev.targetText && ev.targetSelector && ( - + {ev.targetSelector} )} {ev.type === "console" && ( - + {ev.message} )} {ev.type === "error" && ev.errorStack && ( - + {ev.errorStack.split("\n").slice(0, 3).join("\n")} )} @@ -719,8 +719,7 @@ function EventDetail({ ev }: { ev: EventRow }) { // Carry the event timestamp so the span-hierarchy query narrows the // ClickHouse partition scan instead of reading the full retention. search={{ t: ev.timestamp }} - className="w-fit rounded-sm border border-input px-2 py-0.5 text-[10px] text-muted-foreground hover:text-foreground" - title="Open backend trace" + className="w-fit rounded-sm border border-input px-2 py-0.5 text-3xs text-muted-foreground hover:text-foreground" > Open trace @@ -736,10 +735,16 @@ function formatNetDuration(ms: number): string { return `${ms}ms` } +/** "Slow" on the shared p95 latency scale (>= 1s), so a request reads slow here as everywhere else. */ +function isSlowRequest(ms: number): boolean { + const level = latencyLevel(ms, "p95") + return level === "slow" || level === "critical" +} + /** Relative-duration micro-bar for the Network view — slow requests jump out * without reading every number. Log-free linear scale capped at 3s. */ function NetDurationBar({ durationMs, failed }: { durationMs: number; failed: boolean }) { - const slow = durationMs >= 1000 + const slow = isSlowRequest(durationMs) return (
      )) - .onError(() =>

      Couldn't load correlated traces.

      ) + .onError((error) => ( + + )) .onSuccess((res) => { const summaries: ReadonlyArray = res.data if (summaries.length === 0) { return ( -

      - Linked traces aren't available yet — they may still be ingesting. -

      + + Linked traces aren't available yet, they may still be ingesting. + ) } return @@ -822,14 +834,20 @@ function TraceListRow({ summary }: { summary: SessionTraceSummary }) { > {isError && }
      - + + seekTo(summary.startTime)} + className="w-10 shrink-0 text-left font-mono text-xs tabular-nums text-muted-foreground hover:text-foreground" + /> + } + > + {clockAt(summary.startTime)} + + Seek replay to this trace + {/* Same method-chip + route-path label the timeline and trace views use, so a trace reads identically everywhere. */} @@ -852,7 +870,7 @@ function TraceListRow({ summary }: { summary: SessionTraceSummary }) {
      - + {summary.rootServiceName} · {summary.spanCount} span{summary.spanCount === 1 ? "" : "s"} {isError ? " · error" : ""} @@ -862,7 +880,7 @@ function TraceListRow({ summary }: { summary: SessionTraceSummary }) { search={{ t: summary.startTime }} target="_blank" rel="noreferrer" - className="inline-flex shrink-0 items-center gap-1 text-[11px] text-info-foreground underline-offset-2 hover:underline" + className="inline-flex shrink-0 items-center gap-1 text-2xs text-info-foreground underline-offset-2 hover:underline" > open @@ -895,14 +913,20 @@ function SessionTab({ sessionId, session }: { sessionId: string; session: Sessio {/* The link is the point of this group: one visitor id spans this person's anonymous marketing sessions and their signed-in ones, so this is how you walk from a signup back to the campaign. */} - - {shortId(session.visitorId, "generic", { ellipsis: true })} - + + + } + > + {shortId(session.visitorId, "generic", { ellipsis: true })} + + All sessions from this visitor + {session.visitorIsNew ? "New" : "Returning"} @@ -1013,7 +1037,7 @@ function SessionTab({ sessionId, session }: { sessionId: string; session: Sessio )} {session.userAgent && ( - + {session.userAgent} @@ -1070,14 +1094,20 @@ function IdentityGroup({ session }: { session: SessionRailSession }) { )} {userId && ( - - {userId} - + + + } + > + {userId} + + All sessions from this user + {groupName ? ( - - {groupName} - + + + } + > + {groupName} + + All sessions from this group + ) : ( {groupId} @@ -1114,7 +1150,7 @@ function IdentityGroup({ session }: { session: SessionRailSession }) { )} {traits.length > 0 && ( -
      +
      {traits.map(([key, value]) => ( -
      - - {formatSessionDuration(total)} - - on the page -
      + {share && ( <> @@ -1191,7 +1222,7 @@ function SessionSummary({ session }: { session: SessionRailSession }) { total={100} className="mt-3 h-1 gap-px bg-muted" /> -
      +
      {active != null && ( - + {label} @@ -1286,7 +1317,7 @@ function EnvFact({ return ( {glyph ? ( - + {glyph} ) : ( diff --git a/apps/web/src/components/replays/session-pills.tsx b/apps/web/src/components/replays/session-pills.tsx index 8c4ca6a978..7a8e0831d2 100644 --- a/apps/web/src/components/replays/session-pills.tsx +++ b/apps/web/src/components/replays/session-pills.tsx @@ -1,7 +1,10 @@ import { Badge } from "@maple/ui/components/ui/badge" import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { countLabel } from "@maple/ui/lib/format" +import { TONE_BORDER, TONE_SOFT } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" -/** Green "Live" pill with the pulsing ring; `compact` is the dense list-row size. */ +/** Green "Live" pill with a static dot; `compact` is the dense list-row size. */ export function LivePill({ compact }: { compact?: boolean }) { return ( + - {count} error{count === 1 ? "" : "s"} + {countLabel(count, "error")} ) } diff --git a/apps/web/src/components/replays/sessions-list.tsx b/apps/web/src/components/replays/sessions-list.tsx index ecf372204f..a834892f1e 100644 --- a/apps/web/src/components/replays/sessions-list.tsx +++ b/apps/web/src/components/replays/sessions-list.tsx @@ -1,4 +1,3 @@ -import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" import { DisclosureChevron } from "@/components/common/disclosure-chevron" import { useState } from "react" import { ReachEndSentinel } from "@/components/common/reach-end-sentinel" @@ -7,12 +6,13 @@ import { useVirtualizer } from "@tanstack/react-virtual" import { Badge, badgeVariants } from "@maple/ui/components/ui/badge" import { ListFooter, LoadMoreButton, LoadingMoreRow } from "@maple/ui/components/ui/list-footer" import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { countLabel, EMPTY_VALUE, pluralize } from "@maple/ui/lib/format" +import { RelativeTime } from "@/components/common/relative-time" import { cn } from "@maple/ui/lib/utils" import { SignalEmptyState } from "@/components/common/signal-empty-state" import { useLiveClock } from "@/hooks/use-live-clock" import { usePageScrollMargin } from "@/hooks/use-page-scroll-margin" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { formatTimestampInTimezone } from "@/lib/timezone-format" import type { SessionTag } from "@maple/domain/query-engine" import { browserIconFor, deviceIconFor } from "./session-icons" import { @@ -57,11 +57,6 @@ export interface SessionRow { readonly tags: ReadonlyArray } -function absoluteTs(startTime: string, timeZone: string): string { - const parsed = toEpochMs(startTime) - return Number.isNaN(parsed) ? startTime : formatTimestampInTimezone(parsed, { timeZone, withYear: true }) -} - // A person is easier to recognize by name than by an opaque id, so the label // walks down to the most human value available. Sessions recorded before // identify() (or by users who never call it) carry `""` in every identity column @@ -131,7 +126,6 @@ export function SessionsList({ nowMs, }: SessionsListProps) { const navigate = useNavigate() - const { effectiveTimezone } = useTimezonePreference() // Only sessions still reading `"active"` can cross the live boundary while // the list sits open; a page of ended ones needs no timer at all. const tickedNowMs = useLiveClock({ @@ -217,7 +211,6 @@ export function SessionsList({ session={item.session} lowSignal={item.lowSignal} nowMs={effectiveNowMs} - timeZone={effectiveTimezone} durationP95={durationP95} onOpen={() => navigate({ @@ -258,7 +251,7 @@ export function SessionsList({ function ColumnHeader() { return ( -
      +
      User Org Tags @@ -274,7 +267,6 @@ interface SessionListRowProps { session: SessionRow lowSignal: boolean nowMs: number - timeZone: string durationP95?: number onOpen: () => void onFilterTag?: (tag: SessionTag) => void @@ -292,7 +284,6 @@ function SessionListRow({ session, lowSignal, nowMs, - timeZone, durationP95, onOpen, onFilterTag, @@ -306,7 +297,6 @@ function SessionListRow({ const entry = hostFromUrl(session.urlInitial) // The email goes under a name; when the email is the label, the entry page does. const secondary = session.userEmail && session.userEmail !== label ? session.userEmail : entry - const started = formatRelativeTimeOrDate(session.startTime, undefined, timeZone) const BrowserIcon = browserIconFor(session.browserName) const DeviceIcon = deviceIconFor(session.deviceType) @@ -347,12 +337,12 @@ function SessionListRow({ {label} {isActive && } - - {started} - + />
      ) : ( - — + {EMPTY_VALUE} )}
      @@ -399,7 +389,7 @@ function SessionListRow({ > @@ -417,8 +407,7 @@ function SessionListRow({ )} - {session.pageViews || 1} page{(session.pageViews || 1) === 1 ? "" : "s"} ·{" "} - {session.clickCount} click{session.clickCount === 1 ? "" : "s"} + {countLabel(session.pageViews || 1, "page")} · {countLabel(session.clickCount, "click")}
      @@ -442,12 +431,12 @@ function SessionListRow({
      - - {started} - + />
      ) @@ -457,14 +446,20 @@ function OrgButton({ name, onFilter }: { name: string; onFilter?: (groupName: st const className = "min-w-0 truncate text-xs font-medium text-foreground" if (!onFilter) return {name} return ( - + + onFilter(name)} + className={cn(className, "rounded hover:underline")} + /> + } + > + {name} + + Only sessions from {name} + ) } @@ -487,15 +482,20 @@ function SessionTags({ ) } return ( - + + onFilter(tag)} + className={cn(className, "hover:ring-1 hover:ring-border")} + /> + } + > + {SESSION_TAG_LABELS[tag]} + + {SESSION_TAG_DESCRIPTIONS[tag]}. Click to filter. + ) })} @@ -508,7 +508,7 @@ function SessionBadges({ session }: { session: SessionRow }) { {session.errorCount > 0 && } {session.traceCount > 0 && ( - {session.traceCount} trace{session.traceCount === 1 ? "" : "s"} + {countLabel(session.traceCount, "trace")} )} {/* Metadata-only session — no rrweb chunks were ever written, so the @@ -545,14 +545,14 @@ function QuietRunRow({ className="flex w-full items-center gap-3 border-b border-border bg-muted/30 px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring @2xl:gap-4" > - {count} low-signal session{count === 1 ? "" : "s"} + {countLabel(count, "low-signal session")} {tiers.map(({ tag, count: n }) => ( - {n} {SESSION_TAG_LABELS[tag].toLowerCase()} - {n === 1 ? "" : "s"} + {n}{" "} + {pluralize(n, SESSION_TAG_LABELS[tag].toLowerCase())} ))} diff --git a/apps/web/src/components/route-error.tsx b/apps/web/src/components/route-error.tsx index 5d6da1749c..90785a7732 100644 --- a/apps/web/src/components/route-error.tsx +++ b/apps/web/src/components/route-error.tsx @@ -70,7 +70,7 @@ function RouteError({ error, info, reset }: ErrorComponentProps) { {isStaleChunk ? : null} - + {title} @@ -102,7 +102,7 @@ function RouteError({ error, info, reset }: ErrorComponentProps) { Stack trace -
      +					
       						{stack}
       					
      diff --git a/apps/web/src/components/service-map/service-map-view.tsx b/apps/web/src/components/service-map/service-map-view.tsx index f26df9afe6..d14ff9bd55 100644 --- a/apps/web/src/components/service-map/service-map-view.tsx +++ b/apps/web/src/components/service-map/service-map-view.tsx @@ -4,9 +4,12 @@ import { formatLatency, formatNumber, formatPercent, + EMPTY_VALUE, + formatRate, pluralize, } from "@maple/ui/lib/format" import { Separator } from "@maple/ui/components/ui/separator" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { SampledValue } from "@/components/services/sampled-value" import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" import { ErrorRateValue } from "@maple/ui/components/error-rate-value" @@ -40,7 +43,8 @@ import { } from "@maple/ui/components/ui/empty" import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" -import { Button } from "@maple/ui/components/ui/button" +import { Panel } from "@maple/ui/components/ui/panel" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { InlineCode } from "@maple/ui/components/ui/inline-code" @@ -100,7 +104,6 @@ import { type PlanetScaleNodeMetrics, type ServiceMapColorMode, } from "@maple/ui/components/service-map/service-map-utils" -import { formatRate } from "@maple/ui/components/service-map/service-map-node" import type { Tone } from "@maple/ui/lib/tone" import type { HyperdriveConfigInput, @@ -149,9 +152,9 @@ function MetricTile({ }) { return (
      - {label} + {label} {children} - {caption !== undefined && {caption}} + {caption !== undefined && {caption}}
      ) } @@ -183,7 +186,7 @@ function DetailPanelHeader({ /** Badge for short uppercase tags (a branch's "prod", an origin scheme). */ function TagBadge({ children }: { children: React.ReactNode }) { return ( - + {children} ) @@ -235,7 +238,7 @@ function EdgeRow({ /> {service}
      -
      +
      {unit} @@ -301,24 +304,23 @@ function ServiceDetailPanel({ accentColor={accentColor} actions={ <> - + View service - + } > @@ -326,7 +328,7 @@ function ServiceDetailPanel({
      {serviceId} {overview?.serviceNamespace ? ( - + {overview.serviceNamespace} ) : null} @@ -343,7 +345,7 @@ function ServiceDetailPanel({ Infrastructure {serviceWorkloads.length > 0 && ( - + {serviceWorkloads.length} )} @@ -509,7 +511,7 @@ function ServiceWorkloadRow({ workload }: { workload: ServiceWorkload }) { ? workload.workloadKind : null return ( -
      +
      @@ -519,7 +521,7 @@ function ServiceWorkloadRow({ workload }: { workload: ServiceWorkload }) {

      {workload.workloadName}

      -

      +

      {workload.namespace || "default"} {workload.clusterName ? ` · ${workload.clusterName}` : ""}

      @@ -532,31 +534,25 @@ function ServiceWorkloadRow({ workload }: { workload: ServiceWorkload }) {
      -
      -
      - CPU - - {workload.avgCpuLimitUtilization == null - ? "—" - : formatPercent(workload.avgCpuLimitUtilization)} - -
      -
      - Memory - - {workload.avgMemoryLimitUtilization == null - ? "—" - : formatPercent(workload.avgMemoryLimitUtilization)} - -
      -
      + + + {workload.avgCpuLimitUtilization == null + ? EMPTY_VALUE + : formatPercent(workload.avgCpuLimitUtilization)} + + + {workload.avgMemoryLimitUtilization == null + ? EMPTY_VALUE + : formatPercent(workload.avgMemoryLimitUtilization)} + +
      {knownKind && ( View workload @@ -573,12 +569,12 @@ function ServiceWorkloadRow({ workload }: { workload: ServiceWorkload }) { namespaces: workload.namespace ? [workload.namespace] : undefined, } } - className="inline-flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors" + className="inline-flex items-center gap-1 text-3xs text-primary hover:text-primary/80 transition-colors" > View pods
      -
      + ) } @@ -589,12 +585,12 @@ function ServiceInfraEmptyState() {

      No Kubernetes workloads found

      -

      +

      This service has no spans tagged with k8s.deployment.name in the selected window. Install the maple-k8s-infra Helm chart and label your namespace to enable infrastructure context:

      -
      +			
       				kubectl label namespace <ns> maple.io/instrument=true
       			
      @@ -867,7 +863,7 @@ function PlanetScaleSection({
      PlanetScale - + {planetscale.kind === "postgresql" ? "Postgres" : "MySQL"} · {planetscale.branchCount}{" "} {pluralize(planetscale.branchCount, "branch", "branches")} @@ -882,12 +878,12 @@ function PlanetScaleSection({ number tinted red here is tinted red on /infra/planetscale too. */} - {stats.cpuMaxPercent.toFixed(0)}% + {formatPercent(stats.cpuMaxPercent / 100)} - {stats.memMaxPercent.toFixed(0)}% + {formatPercent(stats.memMaxPercent / 100)} @@ -910,7 +906,7 @@ function PlanetScaleSection({
      ) : (

      - No PlanetScale metrics in this window yet — the scraper delivers them within a minute of + No PlanetScale metrics in this window yet; the scraper delivers them within a minute of connecting.

      )} @@ -937,33 +933,17 @@ function PlanetScaleSection({ className="flex items-center justify-between gap-2 rounded-md border border-border bg-card px-2.5 py-2 text-xs" >
      - + {row.branch} {info?.production ? prod : null}
      -
      +
      {formatRate(row.connectionsAvg)} conns - 80 - ? "text-severity-error" - : row.cpuMaxPercent > 60 - ? "text-severity-warn" - : undefined, - )} - > - {row.cpuMaxPercent.toFixed(0)}% cpu + + {formatPercent(row.cpuMaxPercent / 100)} cpu - 10 - ? "text-severity-error" - : row.replicaLagMaxSeconds > 1 - ? "text-severity-warn" - : undefined, - )} - > + {formatReplicationLag(row.replicaLagMaxSeconds)} lag
      @@ -976,12 +956,12 @@ function PlanetScaleSection({ className="flex items-center justify-between gap-2 rounded-md border border-border/60 bg-card px-2.5 py-2 text-xs opacity-70" >
      - + {branch.name} {branch.production ? prod : null}
      - + {branch.ready ? "no metrics" : "not ready"}
      @@ -1013,7 +993,7 @@ function HyperdriveSection({ configs }: { configs: ReadonlyArray Hyperdrive Configs - + {countLabel(configs.length, "config")}
      @@ -1031,10 +1011,10 @@ function HyperdriveSection({ configs }: { configs: ReadonlyArray
      -
      +
      {config.matched ? ( {config.matched.name} - + {config.matched.kind === "postgresql" ? "Postgres" : "MySQL"} on PlanetScale @@ -1053,19 +1033,17 @@ function HyperdriveSection({ configs }: { configs: ReadonlyArray {config.originDatabase} - - PlanetScale (not in inventory) - + PlanetScale (not in inventory) ) : ( {config.originDatabase} {config.originHost ? ( - + {config.originHost} ) : ( - + private origin )} @@ -1147,9 +1125,9 @@ function DatabaseDetailPanel({ + - + } > @@ -1233,7 +1211,7 @@ function DatabaseDetailPanel({
      Query Activity {summaryWaiting && summaryResponse && ( - Refreshing + Refreshing )}
      {Result.builder(summaryResult) @@ -1263,15 +1241,15 @@ function DatabaseDetailPanel({ className="rounded-md border border-border bg-card px-2.5 py-2" >
      -

      +

      {formatQueryLabel(query.queryLabel)}

      -
      +
      = 0.01) - color = dark ? HEALTH_COLOR[health(errorRate)] : errorRate >= 0.02 ? "#a95a4a" : "#9d762b" + const level = errorRateLevel(errorRate) + if (level !== "neutral") + color = dark ? HEALTH_COLOR[health(errorRate)] : level === "crit" ? "#a95a4a" : "#9d762b" return { color, opacity: dimmed ? 0.08 : active ? 0.95 : dark ? 0.48 : 0.72 } } diff --git a/apps/web/src/components/service-map/three/factory-routing.ts b/apps/web/src/components/service-map/three/factory-routing.ts index 7bb1ccd5cf..6442885d53 100644 --- a/apps/web/src/components/service-map/three/factory-routing.ts +++ b/apps/web/src/components/service-map/three/factory-routing.ts @@ -1,4 +1,5 @@ import * as THREE from "three" +import { errorRateLevel } from "@maple/ui/lib/error-rate" import type { Edge3D, Topology3D } from "./types" import type { SpatialLayout } from "./spatial-layout" @@ -129,7 +130,7 @@ export function decorateRoutes(routes: FactoryRoute[], edges: ReadonlyArray= peak * 0.15 || - edge.errorRate >= 0.015), + errorRateLevel(edge.errorRate) !== "neutral"), }, ] }) diff --git a/apps/web/src/components/service-map/three/spatial-layout.ts b/apps/web/src/components/service-map/three/spatial-layout.ts index cfa3c7e8fc..728d78a298 100644 --- a/apps/web/src/components/service-map/three/spatial-layout.ts +++ b/apps/web/src/components/service-map/three/spatial-layout.ts @@ -1,5 +1,9 @@ import { errorRateLevel } from "@maple/ui/lib/error-rate" -import { formatLatency as formatSharedLatency } from "@maple/ui/lib/format" +import { + EMPTY_VALUE, + formatLatency as formatSharedLatency, + formatRate as formatSharedRate, +} from "@maple/ui/lib/format" import type { Edge3D, Node3D } from "./types" import { computeTiers } from "./graph" import type { Vec3 } from "./types" @@ -138,11 +142,12 @@ export const nodeHeight = (node: Node3D) => 0.5 + Math.log10(Math.max(1, node.th const HEALTH_BY_LEVEL = { crit: "degraded", warn: "elevated", neutral: "healthy" } as const export const health = (rate: number) => HEALTH_BY_LEVEL[errorRateLevel(rate)] export const HEALTH_COLOR = { healthy: "#79ad9b", elevated: "#d3a65c", degraded: "#dc7b6d" } as const -const rateFormatter = new Intl.NumberFormat("en", { maximumFractionDigits: 1, notation: "compact" }) +/** The shared rate format, plus the 3D labels' "/s" suffix, "~" estimate mark and "<0.1" floor. */ export const formatRate = (rate: number, estimated = false) => - `${estimated ? "~" : ""}${rate > 0 && rate < 0.1 ? "<0.1" : rateFormatter.format(rate)}/s` + `${estimated ? "~" : ""}${rate > 0 && rate < 0.1 ? "<0.1" : formatSharedRate(rate)}/s` /** The shared latency format, tolerating the optional latencies of 3D edges. */ -export const formatLatency = (ms: number | undefined) => (ms === undefined ? "—" : formatSharedLatency(ms)) +export const formatLatency = (ms: number | undefined) => + ms === undefined ? EMPTY_VALUE : formatSharedLatency(ms) export function connectedIds( topology: SpatialTopology, diff --git a/apps/web/src/components/service-map/three/viewport.tsx b/apps/web/src/components/service-map/three/viewport.tsx index f7ced97c2b..e3deb7a00a 100644 --- a/apps/web/src/components/service-map/three/viewport.tsx +++ b/apps/web/src/components/service-map/three/viewport.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from "react" import { useReducedMotion } from "motion/react" import { useTheme } from "@maple/ui/hooks/use-theme" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { ArrowRotateAnticlockwiseIcon, @@ -107,9 +108,7 @@ export function ServiceMap3DViewport({ Cascade - - {copy.encoding} - + {copy.encoding}
      { if (element) labels.current.set("overlay:legend", element) else labels.current.delete("overlay:legend") }} - className="pointer-events-none absolute bottom-14 left-4 flex flex-wrap gap-3 rounded-md sm:bottom-4 bg-background/95 px-3 py-2 text-[10px] text-foreground/85" + className="pointer-events-none absolute bottom-14 left-4 flex flex-wrap gap-3 rounded-md sm:bottom-4 bg-background/95 px-3 py-2 text-3xs text-foreground/85" > {(["healthy", "elevated", "degraded"] as const).map((status) => ( @@ -243,31 +242,21 @@ export function ServiceMap3DViewport({ if (element) labels.current.set("overlay:controls", element) else labels.current.delete("overlay:controls") }} - className="absolute right-4 bottom-4 flex gap-1 rounded-lg border bg-background p-1" + className="absolute right-4 bottom-4 flex gap-1 rounded-md border bg-background p-1" aria-label="Camera controls" > - - - + -
      +
      Drag to orbit · scroll to zoom · right-drag to pan {selectedId diff --git a/apps/web/src/components/services/dependency-table.tsx b/apps/web/src/components/services/dependency-table.tsx index 5e714420ff..a8ed7d6382 100644 --- a/apps/web/src/components/services/dependency-table.tsx +++ b/apps/web/src/components/services/dependency-table.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react" +import { Panel } from "@maple/ui/components/ui/panel" import { useNavigate } from "@tanstack/react-router" import { Table, TableBody, TableCell, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { formatRate } from "@maple/ui/lib/format" -import { cn } from "@maple/ui/lib/utils" import { BarCell, HeadLabel, @@ -12,7 +12,6 @@ import { MobileStat, MobileStatLine, SortColumnHead, - TABLE_CARD_CLASS, } from "./service-table-cells" import { SampledValue } from "./sampled-value" import { useTableSort } from "@/hooks/use-table-sort" @@ -84,10 +83,10 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre return ( <> {/* Desktop: dense sortable table with inline distribution bars. */} -
      + - + Target
      - + {row.kind === "service" && ( )} {row.name} {row.subtitle ? ( - + {row.subtitle} ) : null} @@ -154,7 +153,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre - + @@ -190,7 +189,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre )}
      -
      + {/* Mobile: tap-to-trace list with a compact sort control. The 5-column table clips below md, so each row collapses to name + a mono metric line. */} @@ -207,7 +206,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre sortDir={sortDir} onSort={handleSort} /> -
      + {sorted.length === 0 ? ( No downstream dependencies in this window. @@ -226,7 +225,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre {row.name} {row.subtitle ? ( - + {row.subtitle} ) : null} @@ -251,7 +250,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre ) }) )} -
      +
      ) diff --git a/apps/web/src/components/services/issue-line.tsx b/apps/web/src/components/services/issue-line.tsx index 71b970a6d7..87fa27d74b 100644 --- a/apps/web/src/components/services/issue-line.tsx +++ b/apps/web/src/components/services/issue-line.tsx @@ -5,6 +5,7 @@ import { formatNumber } from "@maple/ui/lib/format" import { SeverityBadge } from "@/components/errors/severity-badge" import { RelativeTime } from "@/components/common/relative-time" +import { ListRow } from "@maple/ui/components/ui/list-row" import { TruncatedText } from "@maple/ui/components/ui/truncated-text" interface IssueLineProps { @@ -19,31 +20,38 @@ interface IssueLineProps { export function IssueLine({ issue, occurrences, occurrencesTitle, showService }: IssueLineProps) { const title = issue.errorLabel || issue.exceptionType || issue.exceptionMessage || "Unknown error" return ( - - - {showService ? ( - - - - ) : null} - - - {formatNumber(occurrences ?? issue.occurrenceCount)}× - - - + } + leading={ + <> + + {showService ? ( + + + + ) : null} + + } + title={} + trailing={ + <> + + {formatNumber(occurrences ?? issue.occurrenceCount)}× + + + + } + /> ) } diff --git a/apps/web/src/components/services/service-api-tab.tsx b/apps/web/src/components/services/service-api-tab.tsx index fafe3c909f..4f993f0a40 100644 --- a/apps/web/src/components/services/service-api-tab.tsx +++ b/apps/web/src/components/services/service-api-tab.tsx @@ -1,11 +1,12 @@ import { useMemo, useState } from "react" +import { Panel } from "@maple/ui/components/ui/panel" import { useNavigate } from "@tanstack/react-router" import { cn } from "@maple/ui/lib/utils" import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { Button } from "@maple/ui/components/ui/button" import { Table, TableBody, TableCell, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { LatencyValue } from "@maple/ui/components/latency-value" -import { countLabel, formatErrorRate, formatLatency, formatRate } from "@maple/ui/lib/format" +import { countLabel, formatErrorRate, formatLatency, formatNumber, formatRate } from "@maple/ui/lib/format" import { refreshingClass } from "@maple/ui/lib/refreshing" import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" import { ErrorRateValue } from "@maple/ui/components/error-rate-value" @@ -22,7 +23,6 @@ import { MobileStat, MobileStatLine, SortColumnHead, - TABLE_CARD_CLASS, } from "./service-table-cells" import { SampledValue } from "./sampled-value" import { useSortState } from "@/hooks/use-table-sort" @@ -192,7 +192,7 @@ export function ServiceApiTab({ aria-busy={isWaiting || undefined} >
      - + {/* Desktop: grouped, sortable table with inline distribution bars. */} -
      + - +
      -
      + {/* Mobile: tap-to-trace list with a compact sort control. */}
      @@ -294,7 +294,7 @@ export function ServiceApiTab({ sortDir={sortDir} onSort={handleSort} /> -
      + {groups.map((group) => ( ))} -
      +
      ) @@ -379,9 +379,9 @@ function GroupRows({ group, seconds, maxima, expanded, onToggle, onSelect }: Gro
      {copy.explainer} - + {sample.join(" · ")} - {remaining > 0 ? ` · ${remaining.toLocaleString()} more` : ""} + {remaining > 0 ? ` · ${formatNumber(remaining)} more` : ""}
      @@ -511,7 +510,7 @@ function EndpointRow({ 0 ? "pl-6" : "pl-3")}>
      - + {head.length > 0 && {head}} {tail} @@ -519,7 +518,7 @@ function EndpointRow({ endpoint.spanCount} value={formatRate(callsPerSecond(endpoint.estimatedSpanCount, seconds))} /> @@ -531,16 +530,16 @@ function EndpointRow({ max={0.05} tone="errors" > - + - + - + - + ) @@ -550,7 +549,7 @@ function MethodLabel({ method }: { method: string }) { return ( @@ -588,7 +587,7 @@ function MobileGroup({ > {title} - + {group.endpoints.length} ·{" "} {formatRate(callsPerSecond(group.totals.estimatedSpanCount, seconds))} req/s @@ -597,7 +596,7 @@ function MobileGroup({ variant="outline" size="sm" onClick={onToggle} - className="h-6 shrink-0 px-2 text-[11px]" + className="h-6 shrink-0 px-2 text-2xs" > {expanded ? "Hide" : "Show"} @@ -643,7 +642,7 @@ function MobileGroup({ * rather than "no data", because the tab is empty for a reason the user can fix. */ function ApiEmptyState({ serviceName }: { serviceName: string }) { return ( -
      + No HTTP endpoints in this window @@ -654,7 +653,7 @@ function ApiEmptyState({ serviceName }: { serviceName: string }) { url.path. -
      + ) } @@ -667,7 +666,7 @@ function ApiLoadingState() { ))}
      -
      +
      {[COLUMN.rate, COLUMN.err, COLUMN.p50, COLUMN.p95, COLUMN.p99].map((w, i) => ( @@ -702,7 +701,7 @@ function ApiLoadingState() {
      )} /> -
      +
      ) } diff --git a/apps/web/src/components/services/service-dependencies-tab.tsx b/apps/web/src/components/services/service-dependencies-tab.tsx index 9e1a6f1077..71a77d89ca 100644 --- a/apps/web/src/components/services/service-dependencies-tab.tsx +++ b/apps/web/src/components/services/service-dependencies-tab.tsx @@ -261,7 +261,7 @@ export function ServiceDependenciesTab({ aria-busy={isWaiting || undefined} > {summary ? ( -
      +
      {dedupedRows.length}{" "} diff --git a/apps/web/src/components/services/service-dependency-strip.tsx b/apps/web/src/components/services/service-dependency-strip.tsx index 50b8d81dee..2f0bae0bfb 100644 --- a/apps/web/src/components/services/service-dependency-strip.tsx +++ b/apps/web/src/components/services/service-dependency-strip.tsx @@ -12,6 +12,7 @@ import { DatabaseIcon, GlobeIcon, NetworkNodesIcon, PaperPlaneIcon } from "@/com import type { DependencyKind } from "./dependency-type-badge" import { ViewAllButton } from "./view-all-button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" const STRIP_LIMIT = 8 @@ -149,10 +150,9 @@ function DependencyChip({ target, onClick }: { target: StripTarget; onClick: () type="button" onClick={onClick} className="inline-flex max-w-[220px] items-center gap-1.5 rounded-md border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - title={`${target.name} — see Dependencies tab`} > - {target.name} + ) } diff --git a/apps/web/src/components/services/service-errors-panel.tsx b/apps/web/src/components/services/service-errors-panel.tsx index cf4bc2e623..87e78f6deb 100644 --- a/apps/web/src/components/services/service-errors-panel.tsx +++ b/apps/web/src/components/services/service-errors-panel.tsx @@ -24,7 +24,7 @@ function PanelFrame({ children, detailLimited }: { children: React.ReactNode; de action={
      {detailLimited && ( - Latest 90 days + Latest 90 days )} {/* `/errors/issues` now 302s to `/errors` and rewrites the time range to 7d on the way through. Link to the hub directly. */} diff --git a/apps/web/src/components/services/service-operations-tab.tsx b/apps/web/src/components/services/service-operations-tab.tsx index 7f7ad91f90..e7bba56a06 100644 --- a/apps/web/src/components/services/service-operations-tab.tsx +++ b/apps/web/src/components/services/service-operations-tab.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react" +import { Panel } from "@maple/ui/components/ui/panel" import { useNavigate } from "@tanstack/react-router" import { cn } from "@maple/ui/lib/utils" import { formatRate } from "@maple/ui/lib/format" @@ -138,10 +139,10 @@ export function ServiceOperationsTab({

      )} {/* Desktop: dense sortable table with inline distribution bars. */} -
      + - + Operation {op.spanName} @@ -203,7 +204,7 @@ export function ServiceOperationsTab({ tone="calls" > op.spanCount} value={formatRate( callsPerSecond(op.estimatedSpanCount, seconds), @@ -217,20 +218,20 @@ export function ServiceOperationsTab({ max={0.05} tone="errors" > - + @@ -245,7 +246,7 @@ export function ServiceOperationsTab({ )}
      -
      + {/* Mobile: tap-to-trace list with a compact sort control. */}
      @@ -261,7 +262,7 @@ export function ServiceOperationsTab({ sortDir={sortDir} onSort={handleSort} /> -
      + {sorted.length === 0 ? ( No operations recorded in this window. ) : ( @@ -292,7 +293,7 @@ export function ServiceOperationsTab({ ) }) )} -
      +
      ) diff --git a/apps/web/src/components/services/service-recent-deploys.tsx b/apps/web/src/components/services/service-recent-deploys.tsx index f7db56c3c6..2c0329071e 100644 --- a/apps/web/src/components/services/service-recent-deploys.tsx +++ b/apps/web/src/components/services/service-recent-deploys.tsx @@ -116,7 +116,7 @@ function RowFrame({
      {line1}
      {chip}
      -
      {line2}
      +
      {line2}
      ) @@ -310,7 +310,7 @@ export function ServiceRecentDeploys({ releases, isLoading = false }: ServiceRec ))} {expanded && hiddenCount > 0 ? ( -
      +
      …and {hiddenCount} more {pluralize(hiddenCount, "version")} in this window
      ) : null} diff --git a/apps/web/src/components/services/service-table-cells.tsx b/apps/web/src/components/services/service-table-cells.tsx index b985672a4c..772f5ed67c 100644 --- a/apps/web/src/components/services/service-table-cells.tsx +++ b/apps/web/src/components/services/service-table-cells.tsx @@ -5,13 +5,14 @@ import type { ReactNode } from "react" import { cn } from "@maple/ui/lib/utils" import { TableCell, TableHead } from "@maple/ui/components/ui/table" +import { BackdropBar } from "@maple/ui/components/ui/meter" import { Eyebrow, eyebrowVariants } from "@maple/ui/components/ui/eyebrow" import { SortableHeader } from "@/components/common/sortable-header" import type { SortDir } from "@/hooks/use-table-sort" import { ChevronDownIcon, ChevronUpIcon, ChevronExpandYIcon } from "@/components/icons" /** Shared chrome for the desktop table and the mobile list of the service detail tabs. */ -export const TABLE_CARD_CLASS = "overflow-hidden rounded-lg border bg-card" +export const TABLE_CARD_CLASS = "overflow-hidden rounded-md border bg-card" interface BarCellProps { value: number @@ -22,22 +23,19 @@ interface BarCellProps { /** Numeric cell with a column-tinted distribution bar. */ export function BarCell({ value, max, tone, children }: BarCellProps) { - const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0 - const hasBar = pct > 0 return ( - {hasBar ? ( -
      - ) : null} + {children} ) @@ -99,7 +97,7 @@ export function MobileSortBar({ onSort, }: MobileSortBarProps) { return ( -
      +
      Sort {options.map(([key, label]) => { const active = sortKey === key diff --git a/apps/web/src/components/services/service-top-operations-panel.tsx b/apps/web/src/components/services/service-top-operations-panel.tsx index acfb9e730d..1748d36dab 100644 --- a/apps/web/src/components/services/service-top-operations-panel.tsx +++ b/apps/web/src/components/services/service-top-operations-panel.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react" import { refreshingClass } from "@maple/ui/lib/refreshing" import { Sparkline } from "@maple/ui/components/ui/gradient-chart" +import { BackdropBar } from "@maple/ui/components/ui/meter" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { Result } from "@/lib/effect-atom" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getServiceOperationsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -70,24 +72,24 @@ export function ServiceTopOperationsPanel({ >
        {operations.map((op) => { - const barPct = maxCalls > 0 ? Math.min((op.estimatedSpanCount / maxCalls) * 100, 100) : 0 return (
      {delta !== undefined && ( -
      {delta.label}
      +
      {delta.label}
      )} @@ -534,7 +536,7 @@ const ServiceRow = React.memo(function ServiceRow({ /> {service.hasSampling && ( - + ~{formatThroughput(service.tracedThroughput, "/s")} traced )} @@ -543,8 +545,8 @@ const ServiceRow = React.memo(function ServiceRow({ {service.hasSampling && (

      - Estimated from {((1 / service.samplingWeight) * 100).toFixed(0)}% sampled - traces (x{service.samplingWeight.toFixed(0)} extrapolation) + Estimated from {formatPercent(1 / service.samplingWeight)} sampled traces (x + {service.samplingWeight.toFixed(0)} extrapolation)

      )} @@ -961,20 +963,14 @@ export function ServicesTable({ filters }: ServicesTableProps) { {subtitleFor(service)}
      )} -
      - - - P99{" "} - + + - - - - Thru{" "} - + + - -
      + +
      (null) return ( -
      - + + {label} -

      + - {spent.toLocaleString()} of {value.toLocaleString()} used today + {formatNumber(spent)} of {formatNumber(value)} used today {" "} · {help} -

      -
      + + ) } @@ -100,11 +100,7 @@ export function AiTriageSettingsSection({ isAdmin, hasEntitlement }: AiTriageSet payload: request, reactivityKeys: SETTINGS_REACTIVITY_KEYS, }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: successMessage, type: "success" }) - } else { - toastManager.add({ title: "Failed to update AI triage settings.", type: "error" }) - } + toastExit(result, { success: successMessage, error: "Failed to update AI triage settings" }) }, ) @@ -117,103 +113,97 @@ export function AiTriageSettingsSection({ isAdmin, hasEntitlement }: AiTriageSet .orElse(() => null) return ( - - - + +
      +

      AI auto-triage {settings?.enabled ? Enabled : null} - - +

      +

      When a new error or anomaly incident opens, an AI agent automatically investigates it with - read-only tools and attaches a triage summary. Runs use Maple's managed AI — no setup + read-only tools and attaches a triage summary. Runs use Maple's managed AI, no setup required. - - - - {Result.builder(settingsResult) - .onInitial(() => ) - .onError((error) => ( - refreshSettings()} +

      +
      + {Result.builder(settingsResult) + .onInitial(() => ) + .onError((error) => ( + refreshSettings()} + /> + )) + .onSuccess((current) => ( + <> + + save( + new AiTriageSettingsUpdateRequest({ enabled: checked }), + checked ? "AI auto-triage enabled" : "AI auto-triage disabled", + ) + } + /> + } /> - )) - .onSuccess((current) => ( - <> - - save( - new AiTriageSettingsUpdateRequest({ enabled: checked }), - checked - ? "AI auto-triage enabled" - : "AI auto-triage disabled", - ) - } - /> - } - /> - - save( - new AiTriageSettingsUpdateRequest({ maxRunsPerDay: parsed }), - "Daily run cap updated", - ) - } - /> + + save( + new AiTriageSettingsUpdateRequest({ maxRunsPerDay: parsed }), + "Daily run cap updated", + ) + } + /> - - save( - new AiTriageSettingsUpdateRequest({ maxPassesPerDay: parsed }), - "Daily model-pass cap updated", - ) - } - /> + + save( + new AiTriageSettingsUpdateRequest({ maxPassesPerDay: parsed }), + "Daily model-pass cap updated", + ) + } + /> -

      - Both ceilings apply to automatic triage only. Investigations you start or - retry yourself are never blocked by them. -

      +

      + Both ceilings apply to automatic triage only. Investigations you start or retry + yourself are never blocked by them. +

      -
      - -
      - - )) - .render()} - -
      +
      + refreshSettings()} + /> +
      + + )) + .render()} + ) } diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index 02249eab0c..9d00a17fdd 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -1,31 +1,21 @@ -import { countLabel } from "@maple/ui/lib/format" +import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { useAtomSet } from "@/lib/effect-atom" import { useState } from "react" import { Link } from "@tanstack/react-router" import { Exit } from "effect" import type { V2ApiKey } from "@maple/domain/http/v2" -import { toastManager } from "@maple/ui/components/ui/toast" import { cn } from "@maple/ui/lib/utils" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@maple/ui/components/ui/card" import { CopyButton } from "@maple/ui/components/ui/copy-button" +import { CopyableField } from "@maple/ui/components/ui/copyable-field" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { Panel } from "@maple/ui/components/ui/panel" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { Empty, EmptyContent, @@ -35,47 +25,28 @@ import { EmptyTitle, } from "@maple/ui/components/ui/empty" import { SearchInput } from "@maple/ui/components/ui/search-input" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { SegmentedSelect } from "@/components/common/segmented-select" import { DocsLink, EmptyActions } from "@/components/common/docs-link" import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { ColumnHead, DataTable } from "@/components/common/data-table" import { RelativeTime } from "@/components/common/relative-time" -import { - ArrowPathIcon, - CodeIcon, - DotsVerticalIcon, - KeyIcon, - PlusIcon, - SquareTerminalIcon, - TrashIcon, -} from "@/components/icons" +import { ArrowPathIcon, CodeIcon, KeyIcon, PlusIcon, SquareTerminalIcon, TrashIcon } from "@/components/icons" import { apiBaseUrl } from "@/lib/services/common/api-base-url" import { useApiKeyMutationSync, useApiKeysList } from "@/hooks/use-api-keys" import { SyncUnavailable } from "@/components/common/sync-unavailable" import { retryOrgCollections } from "@/lib/collections/org-collections" import { useLiveClock } from "@/hooks/use-live-clock" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" -import { displayError } from "@/lib/error-messages" +import { toastExit } from "@/lib/error-toast" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatDateInTimezone } from "@/lib/timezone-format" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { CreateApiKeyDialog } from "./create-api-key-dialog" import { RollApiKeyDialog } from "./roll-api-key-dialog" type ApiKey = V2ApiKey -function formatDate(timestamp: string | null): string { - if (!timestamp) return "Never" - try { - return new Date(timestamp).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }) - } catch { - return "Unknown" - } -} - /** * A key has exactly one status, and the list is grouped by it. "Expiring" is not a separate bucket — * the key still works, so it belongs with the active ones — but it carries a badge and sorts to the @@ -149,16 +120,11 @@ export function ApiKeysSection() { if (!revokingKey) return false prepareForMutation() const result = await revokeMutation({ params: { id: revokingKey.id } }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "API key revoked", type: "success" }) - void reconcileTxid(result.value.txid) - // ConfirmDialog closes on `true`. `revokingKey` stays set so the copy doesn't swap mid-animation. - return true - } - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) - // `false` keeps the dialog open so the user can retry. - return false + // ConfirmDialog closes on `true` (`revokingKey` stays set so the copy doesn't swap mid-animation); + // `false` keeps it open so the user can retry. + const ok = toastExit(result, { success: "API key revoked", error: "Couldn't revoke API key" }) + if (Exit.isSuccess(result)) void reconcileTxid(result.value.txid) + return ok } // One pass, one clock. An expired key used to count as "Active" and sit in the active list behind @@ -196,50 +162,59 @@ export function ApiKeysSection() { const showSearch = buckets[activeView].length > 5 || needle.length > 0 return ( -
      -
      -
      - {keys.length > 0 && ( - <> - tab === "active" || buckets[tab].length > 0) - .map((tab) => ({ - value: tab, - label: `${VIEW_LABELS[tab]} · ${buckets[tab].length}`, - }))} - /> - {buckets.active.length > 0 && ( - - {standardCount} standard - · - {mcpCount} mcp - - )} - - )} -
      - {showSearch && ( - - )} + + setCreateOpen(true)} size="sm" disabled={!isAdmin}> - + Create key -
      + } + > + {keys.length > 0 || showSearch ? ( +
      + {keys.length > 0 && ( + <> + tab === "active" || buckets[tab].length > 0) + .map((tab) => ({ + value: tab, + label: `${VIEW_LABELS[tab]} · ${buckets[tab].length}`, + }))} + /> + {buckets.active.length > 0 && ( + + + {standardCount} standard + + · + {mcpCount} mcp + + )} + + )} +
      + {showSearch && ( + + )} +
      + ) : null} -
      + {isLoading ? ( ) : isError ? ( @@ -263,7 +238,7 @@ export function ApiKeysSection() { {isAdmin ? ( ) : ( @@ -297,7 +272,7 @@ export function ApiKeysSection() { {needle.length === 0 && activeView === "active" && isAdmin && ( @@ -330,8 +305,8 @@ export function ApiKeysSection() { ))} )} -
      -
      + + {!isAdmin ? (

      @@ -342,7 +317,7 @@ export function ApiKeysSection() { > MCP {" "} - page — you can create one of those yourself. + page; you can create one of those yourself.

      ) : null} @@ -373,7 +348,7 @@ export function ApiKeysSection() { confirmLabel="Revoke key" onConfirm={handleRevoke} /> -
      + ) } @@ -404,7 +379,7 @@ const SCOPE_FAMILY_ROWS = [ { id: "investigations", label: "Investigations", - description: "AI investigation war-rooms — list, open, and update status", + description: "AI investigation war-rooms: list, open, and update status", }, { id: "anomalies", @@ -437,89 +412,66 @@ const curlExample = `curl ${apiBaseUrl}/v2/alerts/rules \\ */ function ApiReference() { return ( -
      - - - API Reference - - The Maple v2 API is a resource-oriented REST interface — snake_case JSON, prefixed - object IDs, cursor-paginated lists, and scoped API keys. - - - - - - -
      - - Base URL - -
      - {apiBaseUrl}/v2 - -
      -
      -
      - - Quick start - -
      + <> + } + > + + Open API reference + + } + > +
      + +
      + Quick start +
      {curlExample}
      -
      +
      - - +
      + - - - Scopes - + Restricted keys grant read or write{" "} access per resource family (write implies{" "} read). A key without scopes has full access. - - - -
      - {SCOPE_FAMILY_ROWS.map((family) => ( -
      -
      -
      {family.label}
      -
      - {family.description} -
      -
      -
      - - {family.id}:read - - - {family.id}:write - -
      + + } + padded={false} + > +
      + {SCOPE_FAMILY_ROWS.map((family) => ( +
      +
      +
      {family.label}
      + + {family.description} +
      - ))} -
      - - -
      +
      + + {family.id}:read + + + {family.id}:write + +
      +
      + ))} +
      + + ) } @@ -569,6 +521,8 @@ function ApiKeyRow({ ? "bg-info/10 text-info" : "bg-success/10 text-success" + const { effectiveTimezone } = useTimezonePreference() + const formatDate = (timestamp: string) => formatDateInTimezone(timestamp, { timeZone: effectiveTimezone }) const createdMeta = [ apiKey.description, `Created ${formatDate(apiKey.created_at)}${apiKey.created_by_email ? ` by ${apiKey.created_by_email}` : ""}`, @@ -615,15 +569,13 @@ function ApiKeyRow({ )}
      - - {createdMeta} - + {createdMeta}
      {apiKey.key_prefix} @@ -632,14 +584,18 @@ function ApiKeyRow({
      - - {apiKey.last_used_at ? : "—"} + + {apiKey.last_used_at ? ( + + ) : ( + EMPTY_VALUE + )} @@ -648,26 +604,18 @@ function ApiKeyRow({
      {onRevoke && ( - - } - aria-label={`Actions for ${apiKey.name}`} - > - - - - {onRoll && ( - - - Roll key - - )} - - - Revoke key + + {onRoll && ( + + + Roll key - - + )} + + + Revoke key + + )}
      @@ -676,21 +624,22 @@ function ApiKeyRow({ function ScopesCell({ apiKey }: { apiKey: ApiKey }) { if (apiKey.kind === "mcp") { - return MCP tools + return MCP tools } if (apiKey.scopes === null) { - return Full access + return Full access } const compact = apiKey.scopes.map((scope) => scope.replace(/:write$/, ":w").replace(/:read$/, ":r")) const shown = compact.slice(0, 2).join(" · ") const extra = compact.length - 2 return ( - {shown} {extra > 0 ? ` · +${extra}` : ""} - + ) } diff --git a/apps/web/src/components/settings/attribute-key-autocomplete.tsx b/apps/web/src/components/settings/attribute-key-autocomplete.tsx index a55c755adb..beb923bbbf 100644 --- a/apps/web/src/components/settings/attribute-key-autocomplete.tsx +++ b/apps/web/src/components/settings/attribute-key-autocomplete.tsx @@ -49,10 +49,10 @@ export function AttributeKeyAutocomplete({ {Result.isInitial(result) && Loading attributes…} {Result.isFailure(result) && ( - Couldn't load attribute suggestions — you can still type a key + Couldn't load attribute suggestions; you can still type a key )} - No matching attributes — press Enter to use as typed + No matching attributes. Press Enter to use as typed {(key: string) => ( diff --git a/apps/web/src/components/settings/attribute-mappings-section.tsx b/apps/web/src/components/settings/attribute-mappings-section.tsx index 9b9e820eb1..f340a97f89 100644 --- a/apps/web/src/components/settings/attribute-mappings-section.tsx +++ b/apps/web/src/components/settings/attribute-mappings-section.tsx @@ -1,6 +1,5 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Field, FieldLabel } from "@maple/ui/components/ui/field" -import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import type { IngestAttributeMappingId, @@ -9,20 +8,15 @@ import type { } from "@maple/domain/http" import type { V2AttributeMapping } from "@maple/domain/http/v2" import { useState } from "react" -import { Exit } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@maple/ui/components/ui/dialog" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Panel } from "@maple/ui/components/ui/panel" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { Input } from "@maple/ui/components/ui/input" @@ -52,7 +46,9 @@ import { import { AttributeKeyAutocomplete } from "./attribute-key-autocomplete" import { DocsLink, EmptyActions } from "@/components/common/docs-link" import { ErrorState } from "@/components/common/error-state" -import { useAsyncAction } from "@/hooks/use-mutation-action" +import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { SettingsSection } from "./settings-section" const SOURCE_CONTEXT_LABELS: Record = { span: "Span attribute", @@ -78,9 +74,12 @@ const SOURCE_CONTEXT_ICON: Record = { resource: CubeIcon, } satisfies Record +const toSourceContext = (value: string | null): IngestMappingSourceContext => + value === "resource" ? "resource" : "span" +const toOperation = (value: string | null): IngestMappingOperation => (value === "move" ? "move" : "copy") + export function AttributeMappingsSection() { const [dialogOpen, setDialogOpen] = useState(false) - const [togglingId, setTogglingId] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null) const [editing, setEditing] = useState(null) @@ -129,79 +128,53 @@ export function AttributeMappingsSection() { setDialogOpen(true) } - const [handleSave, isSaving] = useAsyncAction(async () => { - if (!formName.trim() || !formSourceKey.trim() || !formTargetKey.trim()) { - toastManager.add({ title: "Name, source key, and target key are required", type: "error" }) - return - } + const formValid = + formName.trim().length > 0 && formSourceKey.trim().length > 0 && formTargetKey.trim().length > 0 - if (editing) { - const result = await updateMutation({ - params: { id: editing.id }, - payload: { - name: formName.trim(), - source_context: formSourceContext, - source_key: formSourceKey.trim(), - target_key: formTargetKey.trim(), - operation: formOperation, - }, - }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Attribute mapping updated", type: "success" }) - setDialogOpen(false) - refreshMappings() - refreshRecommendations() - } else { - toastManager.add({ title: "Failed to update attribute mapping", type: "error" }) - } - } else { - const result = await createMutation({ - payload: { - name: formName.trim(), - source_context: formSourceContext, - source_key: formSourceKey.trim(), - target_key: formTargetKey.trim(), - operation: formOperation, - }, - }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Attribute mapping created", type: "success" }) - setDialogOpen(false) - refreshMappings() - refreshRecommendations() - } else { - toastManager.add({ title: "Failed to create attribute mapping", type: "error" }) - } + const [handleSave, isSaving] = useAsyncAction(async () => { + if (!formValid) return + const payload = { + name: formName.trim(), + source_context: formSourceContext, + source_key: formSourceKey.trim(), + target_key: formTargetKey.trim(), + operation: formOperation, } + const ok = editing + ? toastExit(await updateMutation({ params: { id: editing.id }, payload }), { + success: "Attribute mapping updated", + error: "Failed to update attribute mapping", + }) + : toastExit(await createMutation({ payload }), { + success: "Attribute mapping created", + error: "Failed to create attribute mapping", + }) + if (!ok) return + setDialogOpen(false) + refreshMappings() + refreshRecommendations() }) async function handleDelete(mappingId: IngestAttributeMappingId) { - setDeleteConfirm(null) const result = await deleteMutation({ params: { id: mappingId } }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Attribute mapping deleted", type: "success" }) + const ok = toastExit(result, { + success: "Attribute mapping deleted", + error: "Failed to delete attribute mapping", + }) + if (ok) { refreshMappings() refreshRecommendations() - } else { - toastManager.add({ title: "Failed to delete attribute mapping", type: "error" }) } + return ok } - async function handleToggleEnabled(mapping: V2AttributeMapping) { - setTogglingId(mapping.id) + const toggle = useKeyedAsyncAction(async (_id: IngestAttributeMappingId, mapping: V2AttributeMapping) => { const result = await updateMutation({ params: { id: mapping.id }, - payload: { - enabled: !mapping.enabled, - }, + payload: { enabled: !mapping.enabled }, }) - if (Exit.isSuccess(result)) { - refreshMappings() - } else { - toastManager.add({ title: "Failed to update attribute mapping", type: "error" }) - } - setTogglingId(null) - } + if (toastExit(result, { error: "Failed to update attribute mapping" })) refreshMappings() + }) const mappingCount = Result.isSuccess(listResult) ? mappings.length : null @@ -210,29 +183,22 @@ export function AttributeMappingsSection() { return ( <> - - - } className="text-sm font-medium"> - Attribute mappings - {mappingCount !== null && mappingCount > 0 && ( - - {" "} - · {mappingCount} - - )} - - - Rename or promote span attribute keys at ingest. Applied only to spans received after - a rule is saved. - - - - - -
      + 0 + ? `Attribute mappings · ${mappingCount}` + : "Attribute mappings" + } + description="Rename or promote span attribute keys at ingest. Applied only to spans received after a rule is saved." + padded={false} + actions={ + + } + > +
      {Result.isInitial(listResult) ? ( @@ -303,9 +269,9 @@ export function AttributeMappingsSection() { !mapping.enabled && "opacity-55", )} > - + {mapping.name} - +
      {mapping.source_key} @@ -331,33 +297,36 @@ export function AttributeMappingsSection() {
      - - +
      - handleToggleEnabled(mapping)} - disabled={togglingId === mapping.id} - title={`Added ${formatRelativeTime(mapping.created_at)}`} - /> + + }> + + void toggle.run(mapping.id, mapping) + } + disabled={toggle.isPending(mapping.id)} + /> + + + Added {formatRelativeTime(mapping.created_at)} + +
      ) @@ -365,184 +334,154 @@ export function AttributeMappingsSection() {
      )}
      -
      + {/* Add / Edit Dialog */} - - - -
      - -
      - - {editing ? "Edit Attribute Mapping" : "Add Attribute Mapping"} - - - The value at the source key is written to the target span attribute. An existing - target key is never overwritten. - -
      -
      - - Name - setFormName(e.target.value)} - /> - - - Source context - setFormName(e.target.value)} + /> + + + Source context + - - - Source key - - - - Target span attribute key - - - - Operation - + + + Source key + + + + Target span attribute key + + + + Operation + - {formSourceContext === "resource" && formOperation === "move" && ( -

      - Move behaves as Copy for resource attributes — a resource attribute is - shared across every span in a batch and is never deleted. -

      - )} -
      + ) + }} + + + + + + + + Copy (keep source key) + + + + + + + + Move{" "} + (remove source key) + + + + + + {formSourceContext === "resource" && formOperation === "move" && ( + + Move behaves as Copy for resource attributes: a resource attribute is shared + across every span in a batch and is never deleted. + + )} + - {showPreview && ( -
      - - Preview - -
      - {formSourceKey.trim()} - - - {formTargetKey.trim()} - - - - {OPERATION_LABELS[formOperation]} - -
      -
      - )} -
      - - - - -
      -
      + {showPreview && ( + + + Preview + +
      + {formSourceKey.trim()} + + + {formTargetKey.trim()} + + + + {OPERATION_LABELS[formOperation]} + +
      +
      + )} + {/* Delete Confirmation */} { if (!open) setDeleteConfirm(null) }} - title="Delete attribute mapping" + title="Delete attribute mapping?" description={ <> Are you sure you want to delete{" "} @@ -559,9 +498,7 @@ export function AttributeMappingsSection() { } confirmLabel="Delete" - onConfirm={() => { - if (deleteConfirm) void handleDelete(deleteConfirm.id) - }} + onConfirm={() => (deleteConfirm ? handleDelete(deleteConfirm.id) : undefined)} /> ) diff --git a/apps/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx index 8cb1e831ed..8db24432a6 100644 --- a/apps/web/src/components/settings/audit-log-section.tsx +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -20,14 +20,21 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@m import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { ListFooter } from "@maple/ui/components/ui/list-footer" +import { Panel } from "@maple/ui/components/ui/panel" +import { RefreshButton } from "@maple/ui/components/ui/refresh-button" import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { SegmentedSelect } from "@/components/common/segmented-select" import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { refreshingClass } from "@maple/ui/lib/refreshing" import { trySync } from "@maple/ui/lib/try-sync" import { cn } from "@maple/ui/lib/utils" import { RelativeTime } from "@/components/common/relative-time" -import { ArrowPathIcon, ChevronRightIcon, HistoryIcon } from "@/components/icons" +import { ChevronRightIcon, HistoryIcon } from "@/components/icons" +import { SettingsSection } from "@/components/settings/settings-section" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" import { ErrorState } from "@/components/common/error-state" type ActorFilter = AuditActorType | "all" @@ -110,18 +117,6 @@ function actorDisplayName(entry: V2AuditLogEntry): string | null { return null } -function formatDateTimeFull(value: string): string { - return new Date(value).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - timeZoneName: "short", - }) -} - /** `` / `` are the audit pipeline's placeholders, not values. */ function isPlaceholder(value: unknown): value is string { return typeof value === "string" && /^<[a-z]+>$/.test(value) @@ -158,7 +153,7 @@ function metadataBlock(value: unknown): string | null { /** * Append the next page, dropping any entry already shown. The pinned `until` * ceiling makes overlap rare, but a filter re-fetch or a refresh mid-scroll can - * still repeat one — and a duplicated React key corrupts the list either way. + * still repeat one, and a duplicated React key corrupts the list either way. */ function dedupeById( existing: ReadonlyArray, @@ -248,12 +243,11 @@ export function AuditLogSection() { const filtered = actorFilter !== "all" || outcomeFilter !== "all" return ( -
      -

      - Changes, refused attempts, and every read of telemetry or session replays — from the - dashboard, API, and MCP. Select an entry for its full record. -

      - +
      - + label="Refresh audit log" + pending={waiting} + onRefresh={handleRefresh} + />
      -
      {view === null && Result.isFailure(pageResult) ? ( )} -
      + {view !== null && view.hasMore && view.nextCursor !== null && ( )} -
      +
      ) } @@ -391,7 +379,7 @@ function ActorCell({ entry }: { entry: V2AuditLogEntry }) { minting user's id too, and wearing it would credit a human for something they may not have done. */} {entry.actor_type === "user" ? ( - + {entry.actor_avatar_url !== null && } {initialsOf(entry.actor_name ?? entry.actor_id ?? "")} @@ -401,12 +389,12 @@ function ActorCell({ entry }: { entry: V2AuditLogEntry }) { )} {name !== null && ( - {name} - + )}
      ) @@ -417,10 +405,10 @@ function ActionLabel({ action }: { action: string }) { const dot = action.indexOf(".") if (dot === -1) return {action} return ( - + {action.slice(0, dot + 1)} {action.slice(dot + 1)} - + ) } @@ -462,12 +450,12 @@ function AuditLogRow({ entry }: { entry: V2AuditLogEntry }) { )}
      {denied && entry.denial_reason !== null && ( - + {entry.denial_reason} )} {entry.changes !== null && entry.changes.fields.length > 0 && ( -

      +

      {entry.changes.fields.join(", ")}

      )} @@ -495,7 +483,7 @@ 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 — + return {EMPTY_VALUE} } return (
      @@ -505,7 +493,7 @@ function ResourceCell({ entry }: { entry: V2AuditLogEntry }) { )} {id !== null && ( - + {id} )} @@ -517,7 +505,7 @@ function ResourceCell({ entry }: { entry: V2AuditLogEntry }) { function Identifier({ value, label }: { value: string; label: string }) { return ( - + {value} @@ -530,7 +518,7 @@ function ChangeValue({ value }: { value: unknown }) { return {value.slice(1, -1)} } if (value === undefined || value === null || value === "") { - return — + return {EMPTY_VALUE} } return {formatScalar(value)} } @@ -573,11 +561,11 @@ function MetadataList({ metadata }: { metadata: Record }) { return ( {block !== null ? ( -
      +							
       								{block}
       							
      ) : ( - + )} @@ -592,6 +580,9 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { const badge = ACTOR_BADGES[entry.actor_type] const hasChanges = entry.changes !== null && entry.changes.fields.length > 0 const hasMetadata = entry.metadata !== null && Object.keys(entry.metadata).length > 0 + const { effectiveTimezone } = useTimezonePreference() + const formatDateTimeFull = (value: string) => + formatTimestampInTimezone(value, { timeZone: effectiveTimezone, withYear: true }) return (
      @@ -626,7 +617,7 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { - + {entry.action} {entry.outcome === "denied" ? ( @@ -645,7 +636,7 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { {entry.resource_type === null && entry.resource_id === null ? ( - — + {EMPTY_VALUE} ) : ( {entry.resource_type !== null && ( diff --git a/apps/web/src/components/settings/automation-section.tsx b/apps/web/src/components/settings/automation-section.tsx index 7658de4217..9f792f29dd 100644 --- a/apps/web/src/components/settings/automation-section.tsx +++ b/apps/web/src/components/settings/automation-section.tsx @@ -1,5 +1,4 @@ import { useState } from "react" -import type { ReactNode } from "react" import { Exit } from "effect" import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { @@ -7,21 +6,25 @@ import { EscalationPolicyEvaluationRequest, type IssueSeverity, } from "@maple/domain/http" -import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { ListRow } from "@maple/ui/components/ui/list-row" import { NativeSelect, NativeSelectOption } from "@maple/ui/components/ui/native-select" import { SeverityBadge } from "@/components/errors/severity-badge" +import { ErrorState } from "@/components/common/error-state" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { SeveritySelect } from "@/components/errors/severity-select" import { AiTriageSettingsSection } from "./ai-triage-settings-section" import { EscalationPolicySection } from "./escalation-policy-section" -import { SectionHeader } from "@/components/layout/section-header" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" import { useAlertDestinationsList } from "@/hooks/use-alerts-list" import { RelativeTime } from "@/components/common/relative-time" import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" const CONFIDENCES: ReadonlyArray = ["high", "medium", "low"] @@ -33,22 +36,20 @@ export function AutomationSection({ hasEntitlement: boolean }) { return ( -
      -
      - + + -
      -
      - -

      - Confidence gates apply only to AI decisions. A manual severity change is explicit human - intent and bypasses the confidence threshold. -

      + + -
      + -
      + ) } @@ -78,92 +79,79 @@ function PolicySimulator() { ...(source === "ai" ? { confidence } : undefined), }), }) - if (Exit.isSuccess(result)) { + if (toastExit(result, { error: "Policy evaluation failed" }) && Exit.isSuccess(result)) { setDecision(result.value) - } else { - toastManager.add({ title: "Policy evaluation failed", type: "error" }) } }) return ( -
      - -
      -
      - - setSeverity(next ?? "high")} - className="h-8 w-full" - /> - - - setSource(event.target.value === "manual" ? "manual" : "ai")} - className="w-full" - > - AI diagnosis - Manual change - - - - - setConfidence( - CONFIDENCES.find((value) => value === event.target.value) ?? "high", - ) - } - disabled={source === "manual"} - className="w-full" - > - {CONFIDENCES.map((value) => ( - - {value} - - ))} - - -
      -
      - - {decision ? ( - <> - - {decision.outcome} - - - {decision.outcome === "route" - ? decision.destinationIds - .map( - (id) => - destinations.find((destination) => destination.id === id) - ?.name ?? id, - ) - .join(", ") - : decision.skipReason?.replaceAll("_", " ")} - - - ) : ( + +
      + + Severity + setSeverity(next ?? "high")} + className="h-8 w-full" + /> + + + Decision source + setSource(event.target.value === "manual" ? "manual" : "ai")} + className="w-full" + > + AI diagnosis + Manual change + + + + AI confidence + + setConfidence(CONFIDENCES.find((value) => value === event.target.value) ?? "high") + } + disabled={source === "manual"} + className="w-full" + > + {CONFIDENCES.map((value) => ( + + {value} + + ))} + + +
      +
      + + {decision ? ( + <> + + {decision.outcome} + - Preview the exact worker decision without sending a notification. + {decision.outcome === "route" + ? decision.destinationIds + .map( + (id) => + destinations.find((destination) => destination.id === id) + ?.name ?? id, + ) + .join(", ") + : decision.skipReason?.replaceAll("_", " ")} - )} -
      + + ) : ( + + Preview the exact worker decision without sending a notification. + + )}
      -
      - ) -} - -function SimulatorField({ label, children }: { label: string; children: ReactNode }) { - return ( - + ) } @@ -175,52 +163,58 @@ function RecentDeliveries() { }), ) return ( -
      - + {Result.builder(result) .onSuccess((response) => response.attempts.length === 0 ? ( - - No escalation attempts have been recorded. - + No escalation attempts have been recorded. ) : ( -
      +
      {response.attempts.map((attempt) => ( -
      -
      - - - {attempt.status} + divided + leading={ + + + {attempt.status} + + } + title={ + + {attempt.deliveries.length > 0 + ? attempt.deliveries + .map( + (delivery) => + `${delivery.destinationName ?? delivery.destinationId}: ${delivery.status}`, + ) + .join(", ") + : (attempt.skipReason?.replaceAll("_", " ") ?? + "Awaiting delivery")} -
      -

      - {attempt.deliveries.length > 0 - ? attempt.deliveries - .map( - (delivery) => - `${delivery.destinationName ?? delivery.destinationId}: ${delivery.status}`, - ) - .join(", ") - : (attempt.skipReason?.replaceAll("_", " ") ?? - "Awaiting delivery")} -

      - -
      + } + trailing={ + + } + /> ))}
      ), ) + .onError((error) => ( + + )) .orElse(() => ( - - Recent escalation activity could not be loaded. - + ))} -
      + ) } diff --git a/apps/web/src/components/settings/billing-controls-card.tsx b/apps/web/src/components/settings/billing-controls-card.tsx index 40c43a472c..2d4116f9c0 100644 --- a/apps/web/src/components/settings/billing-controls-card.tsx +++ b/apps/web/src/components/settings/billing-controls-card.tsx @@ -2,7 +2,9 @@ import { useState } from "react" import type { BillingCustomer } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" +import { Panel } from "@maple/ui/components/ui/panel" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { maximumInvoiceCents, spendLimitFor } from "@/lib/billing/controls" import { formatCurrency } from "@maple/domain/format" @@ -16,20 +18,19 @@ import { import { BillingControlsDialog } from "./billing-controls-dialog" import { formatFeatureUsage } from "./format-feature-usage" - export function BillingControlsCardSkeleton() { return ( -
      -
      + +
      {SPEND_FEATURES.map((featureId) => ( -
      +
      ))} -
      +
      ) } @@ -47,8 +48,8 @@ export function BillingControlsCard({ return ( <> -
      -
      + +

      Each cap limits paid overage for one feature. Included usage is unaffected; the cap is enforced while usage keeps being recorded. @@ -59,52 +60,65 @@ export function BillingControlsCard({

      -
      - {SPEND_FEATURES.map((featureId, index) => { - const feature = model?.features.find((entry) => entry.featureId === featureId) - const limit = spendLimitFor(customer, featureId)?.overageLimit - const stopAt = - limit === undefined || feature?.included == null ? null : feature.included + limit - - return ( -
      0 ? "border-t border-border/30" : ""}`} - > -
      - - {FEATURE_LABELS[featureId]} -
      -
      -

      - {limit === undefined - ? "Paid overage uncapped" - : `${formatFeatureUsage(featureId, limit)} paid overage`} -

      -

      - {stopAt === null - ? "No enforced stop this cycle" - : `Stops at ${formatFeatureUsage(featureId, stopAt)} total usage`} -

      -
      - -
      - ) - })} -
      -
      + + + + Feature + Paid overage cap + + Actions + + + + + {SPEND_FEATURES.map((featureId) => { + const feature = model?.features.find((entry) => entry.featureId === featureId) + const limit = spendLimitFor(customer, featureId)?.overageLimit + const stopAt = + limit === undefined || feature?.included == null + ? null + : feature.included + limit + return ( + + +
      + + {FEATURE_LABELS[featureId]} +
      +
      + +

      + {limit === undefined + ? "Paid overage uncapped" + : `${formatFeatureUsage(featureId, limit)} paid overage`} +

      +

      + {stopAt === null + ? "No enforced stop this cycle" + : `Stops at ${formatFeatureUsage(featureId, stopAt)} total usage`} +

      +
      + + + +
      + ) + })} +
      +
      + {editing !== null && ( + {feature.included !== null && ( {formatFeatureUsage(featureId, feature.included)} @@ -104,11 +93,12 @@ export function BillingControlsDialog({ onSuccess: () => onOpenChange(false), }) const [limit, setLimit] = useState(existingLimit === undefined ? "" : String(existingLimit)) + const [invalid, setInvalid] = useState(false) async function handleSave() { const overageLimit = limit.trim() === "" ? null : Number(limit) if (overageLimit !== null && (!Number.isFinite(overageLimit) || overageLimit < 0)) { - toastManager.add({ title: "Paid overage cap must be zero or greater.", type: "error" }) + setInvalid(true) return } await save({ @@ -118,54 +108,49 @@ export function BillingControlsDialog({ } return ( - - - - {FEATURE_LABELS[featureId]} billing controls - - Limit what {FEATURE_LABELS[featureId].toLowerCase()} can add to this cycle's invoice - beyond the included allowance. - - - - - - Paid overage cap - - setLimit(event.target.value)} - placeholder="No cap" - // Spinners belong on a stepper, not on a cap you type once. - controlClassName="font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - - {featureUnit(featureId)} / cycle - - - - Usage is rejected once the included allowance plus this cap is consumed. Leave - empty for uncapped overage. - - - - {feature !== null && ( - - )} - + void handleSave()} + submitLabel="Save controls" + pending={saving} + > + + Paid overage cap + + { + setLimit(event.target.value) + setInvalid(false) + }} + placeholder="No cap" + // Spinners belong on a stepper, not on a cap you type once. + controlClassName="font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + + {featureUnit(featureId)} / cycle + + + {invalid ? ( + Paid overage cap must be zero or greater. + ) : ( + + Usage is rejected once the included allowance plus this cap is consumed. Leave empty + for uncapped overage. + + )} + - - }>Cancel - - - - + {feature !== null && } + ) } diff --git a/apps/web/src/components/settings/billing-details-dialog.tsx b/apps/web/src/components/settings/billing-details-dialog.tsx index 2be7e1dd61..2a36707a03 100644 --- a/apps/web/src/components/settings/billing-details-dialog.tsx +++ b/apps/web/src/components/settings/billing-details-dialog.tsx @@ -1,8 +1,8 @@ import { useState } from "react" import { BillingAddress, type BillingProfile, UpdateBillingProfileRequest } from "@maple/domain/http" -import { Button } from "@maple/ui/components/ui/button" import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Combobox, ComboboxContent, @@ -11,14 +11,6 @@ import { ComboboxItem, ComboboxList, } from "@maple/ui/components/ui/combobox" -import { - Dialog, - DialogClose, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "@maple/ui/components/ui/dialog" import { Input } from "@maple/ui/components/ui/input" import { useMutationAction } from "@/hooks/use-mutation-action" @@ -30,7 +22,7 @@ const COUNTRIES = sortedCountryCodes() /** * Company name + billing address, written straight to the Stripe customer. - * Every field is optional — Stripe prints whatever is set — so an empty field + * Every field is optional (Stripe prints whatever is set), so an empty field * clears the line rather than being "invalid". */ export function BillingDetailsDialog({ @@ -79,115 +71,108 @@ export function BillingDetailsDialog({ } return ( - - - - Billing details - + void handleSave()} + submitLabel="Save details" + pending={saving} + > + + Company name + setName(event.target.value)} + placeholder="Legal entity as it should appear on invoices" + maxLength={150} + autoComplete="organization" + /> + -
      - - Company name - setName(event.target.value)} - placeholder="Legal entity as it should appear on invoices" - maxLength={150} - autoComplete="organization" - /> - + + Address + setLine1(event.target.value)} + placeholder="Street and number" + autoComplete="address-line1" + /> + setLine2(event.target.value)} + placeholder="Suite, floor, c/o (optional)" + autoComplete="address-line2" + /> + - - Address - setLine1(event.target.value)} - placeholder="Street and number" - autoComplete="address-line1" - /> - setLine2(event.target.value)} - placeholder="Suite, floor, c/o (optional)" - autoComplete="address-line2" - /> - +
      + + Postal code + setPostalCode(event.target.value)} + autoComplete="postal-code" + /> + + + City + setCity(event.target.value)} + autoComplete="address-level2" + /> + +
      -
      - - Postal code - setPostalCode(event.target.value)} - autoComplete="postal-code" - /> - - - City - setCity(event.target.value)} - autoComplete="address-level2" - /> - -
      - -
      - - State / region - setState(event.target.value)} - placeholder="Optional" - autoComplete="address-level1" - /> - - - Country - - items={COUNTRIES} - itemToStringLabel={(code: string | null) => (code ? countryName(code) : "")} - value={country} - onValueChange={(next) => setCountry(typeof next === "string" ? next : null)} - > - - - No country found. - - {(code: string) => ( - - {countryName(code)} - - )} - - - - -
      - -

      - Stored on your Stripe customer and printed on every invoice from the next one on. Add - your VAT or tax ID separately below the details. -

      -
      +
      + + State / region + setState(event.target.value)} + placeholder="Optional" + autoComplete="address-level1" + /> + + + Country + + items={COUNTRIES} + itemToStringLabel={(code: string | null) => (code ? countryName(code) : "")} + value={country} + onValueChange={(next) => setCountry(typeof next === "string" ? next : null)} + > + + + No country found. + + {(code: string) => ( + + {countryName(code)} + + )} + + + + +
      - - }>Cancel - - -
      -
      +

      + Stored on your Stripe customer and printed on every invoice from the next one on. Add your VAT + or tax ID separately below the details. +

      + ) } diff --git a/apps/web/src/components/settings/billing-details-section.tsx b/apps/web/src/components/settings/billing-details-section.tsx index ed49436f67..2e937e9475 100644 --- a/apps/web/src/components/settings/billing-details-section.tsx +++ b/apps/web/src/components/settings/billing-details-section.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { Cause, Schema } from "effect" import { toastExit } from "@/lib/error-toast" +import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action" import { AddBillingTaxIdRequest, @@ -17,11 +18,14 @@ import { } from "@maple/domain/billing-tax-ids" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { Field, FieldError } from "@maple/ui/components/ui/field" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Panel } from "@maple/ui/components/ui/panel" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Input } from "@maple/ui/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { toastManager } from "@maple/ui/components/ui/toast" import { XmarkIcon } from "@/components/icons" import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom" @@ -59,8 +63,8 @@ const TYPE_OPTIONS = [ export function BillingDetailsSkeleton() { return ( -
      -
      + +
      @@ -71,7 +75,7 @@ export function BillingDetailsSkeleton() {
      -
      + ) } @@ -97,16 +101,15 @@ function TaxIdRow({ )} {canEdit && ( - + )}
    • ) @@ -116,21 +119,19 @@ function AddTaxIdRow({ profile }: { readonly profile: BillingProfile }) { const add = useAtomSet(addBillingTaxIdMutation, { mode: "promiseExit" }) const [type, setType] = useState(defaultTaxIdTypeFor(profile.address?.country) ?? "eu_vat") const [value, setValue] = useState("") - const [adding, setAdding] = useState(false) + const [missingValue, setMissingValue] = useState(false) - async function handleAdd() { + const [handleAdd, adding] = useAsyncAction(async () => { const trimmed = value.trim() if (!isTaxIdType(type)) return if (trimmed.length === 0) { - toastManager.add({ title: "Enter the tax ID first.", type: "error" }) + setMissingValue(true) return } - setAdding(true) const exit = await add({ payload: new AddBillingTaxIdRequest({ type, value: trimmed }), reactivityKeys: [BILLING_PROFILE_KEY], }) - setAdding(false) if ( toastExit(exit, { success: "Tax ID added. Stripe is verifying it.", @@ -139,36 +140,42 @@ function AddTaxIdRow({ profile }: { readonly profile: BillingProfile }) { ) { setValue("") } - } + }) return ( -
      - - setValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") void handleAdd() - }} - placeholder={taxIdExampleFor(type, profile.address?.country)} - className="w-56 font-mono" - /> - -
      + +
      + + { + setValue(event.target.value) + setMissingValue(false) + }} + onKeyDown={(event) => { + if (event.key === "Enter") void handleAdd() + }} + placeholder={taxIdExampleFor(type, profile.address?.country)} + className="w-56 font-mono" + /> + +
      + {missingValue ? Enter the tax ID first. : null} +
      ) } @@ -180,7 +187,17 @@ export function BillingDetailsSection({ canEdit }: { readonly canEdit: boolean } const profileResult = useAtomValue(billingProfileAtom) const remove = useAtomSet(removeBillingTaxIdMutation, { mode: "promiseExit" }) const [editing, setEditing] = useState(false) - const [removingId, setRemovingId] = useState(null) + const [confirmRemove, setConfirmRemove] = useState(null) + const removal = useKeyedAsyncAction(async (_id: string, taxId: BillingTaxId) => { + const exit = await remove({ + params: { taxIdId: taxId.id }, + reactivityKeys: [BILLING_PROFILE_KEY], + }) + return toastExit(exit, { + success: `Removed ${taxIdLabel(taxId.type)} ${taxId.value}.`, + error: "The tax ID could not be removed.", + }) + }) if (Result.isInitial(profileResult)) return @@ -188,36 +205,23 @@ export function BillingDetailsSection({ canEdit }: { readonly canEdit: boolean } const notConfigured = Result.isFailure(profileResult) && isNotConfigured(Cause.squash(profileResult.cause)) return ( -
      +

      {notConfigured ? "Billing details aren't set up for this deployment. Company name, address and tax IDs are managed in the billing portal instead." - : "Billing details aren't available right now. Try again in a moment — your invoices still carry the details already on file."} + : "Billing details aren't available right now. Try again in a moment; your invoices still carry the details already on file."}

      -
      + ) } const profile = profileResult.value const addressLines = formatAddressLines(profile.address) - async function handleRemove(taxId: BillingTaxId) { - setRemovingId(taxId.id) - const exit = await remove({ - params: { taxIdId: taxId.id }, - reactivityKeys: [BILLING_PROFILE_KEY], - }) - setRemovingId(null) - toastExit(exit, { - success: `Removed ${taxIdLabel(taxId.type)} ${taxId.value}.`, - error: "The tax ID could not be removed.", - }) - } - return ( <> -
      -
      + +
      Bill to {profile.name ? ( @@ -253,8 +257,8 @@ export function BillingDetailsSection({ canEdit }: { readonly canEdit: boolean } key={taxId.id} taxId={taxId} canEdit={canEdit} - removing={removingId === taxId.id} - onRemove={() => void handleRemove(taxId)} + removing={removal.isPending(taxId.id)} + onRemove={() => setConfirmRemove(taxId)} /> ))}
    @@ -271,15 +275,28 @@ export function BillingDetailsSection({ canEdit }: { readonly canEdit: boolean } {canEdit && ( -
    +
    -

    +

    EU, UK and Australian numbers are checked against the official registry; the ID is printed on invoices either way.

    )} -
    + + + (open ? undefined : setConfirmRemove(null))} + title="Remove tax ID?" + description={ + confirmRemove + ? `${taxIdLabel(confirmRemove.type)} ${confirmRemove.value} will no longer print on invoices.` + : undefined + } + confirmLabel="Remove tax ID" + onConfirm={() => (confirmRemove ? removal.run(confirmRemove.id, confirmRemove) : undefined)} + /> {editing && } diff --git a/apps/web/src/components/settings/billing-kpis.tsx b/apps/web/src/components/settings/billing-kpis.tsx index 367bb075d3..8a3ed21b91 100644 --- a/apps/web/src/components/settings/billing-kpis.tsx +++ b/apps/web/src/components/settings/billing-kpis.tsx @@ -1,6 +1,7 @@ import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { formatCurrency } from "@maple/domain/format" +import { formatPercent } from "@maple/ui/lib/format" import { FEATURE_COLORS, type SpendModel } from "@/lib/billing/spend" import { formatFeatureUsage } from "./format-feature-usage" @@ -9,7 +10,7 @@ import { formatFeatureUsage } from "./format-feature-usage" * reader touches a chart: spend so far, where it lands, the ceiling it's headed * for, and which signal is driving it. * - * Deliberately four cards and not a chart summary — this row is what a customer + * Deliberately four cards and not a chart summary: this row is what a customer * screenshots into a finance thread. */ @@ -40,8 +41,9 @@ export function BillingKpis({ value={dollars(model.spendCents, model.currency)} subline={wrap( <> - Day {model.dayOfCycle} of {model.cycleDays} · {dollars(model.baseCents, model.currency)}{" "} - base + {dollars(model.overageCents, model.currency)} overage + Day {model.dayOfCycle} of {model.cycleDays} ·{" "} + {dollars(model.baseCents, model.currency)} base +{" "} + {dollars(model.overageCents, model.currency)} overage {model.partial && " · some legacy items aren't itemized"} , )} @@ -98,7 +100,7 @@ export function BillingKpis({ subline={wrap( <> {model.overageCents > 0 - ? `${Math.round((driver.overageCents / model.overageCents) * 100)}% of overage · ` + ? `${formatPercent(driver.overageCents / model.overageCents)} of overage · ` : ""} {formatFeatureUsage(driver.featureId, driver.overageUnits)} over included , diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index eeb12cb300..8a63308c4d 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -1,10 +1,9 @@ -import { useMemo, type ReactNode } from "react" -import { format } from "date-fns" +import { useMemo } from "react" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { cn } from "@maple/ui/lib/utils" import { Result, useAtomValue } from "@/lib/effect-atom" @@ -15,6 +14,9 @@ import { billingUsageAtom, } from "@/lib/services/atoms/billing-atoms" import { useBillingActions } from "@/hooks/use-billing-actions" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatDateInTimezone } from "@/lib/timezone-format" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { getLegacyPlanInfo, getTrialStatus, type TrialStatus } from "@/lib/billing/plan-gating" import { buildSpendModel } from "@/lib/billing/spend" import { estimateCycleCost } from "@/lib/billing/cost-estimate" @@ -38,42 +40,6 @@ import { BillingDetailsSection } from "./billing-details-section" * billed, and the chart is only their sum over time. */ -function DataPoint({ - label, - value, - accent, - className, - trailing, -}: { - label: string - value: string - accent?: boolean - className?: string - /** Rendered inline with the value, on its baseline (e.g. a status badge). */ - trailing?: ReactNode -}) { - return ( -
    - {label} - - - {value} - - {trailing} - -
    - ) -} - -function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) { - return ( -
    - {title} - {subtitle && {subtitle}} -
    - ) -} - function SubscriptionStrip({ trial, isLegacy, @@ -87,6 +53,7 @@ function SubscriptionStrip({ isLoading: boolean onManageBilling: () => void }) { + const { effectiveTimezone } = useTimezonePreference() if (isLoading) { return (
    @@ -117,22 +84,25 @@ function SubscriptionStrip({ return (
    -
    - - Legacy - - ) : undefined - } - /> - - -
    + + + {planName} + {isLegacy ? ( + + Legacy + + ) : null} + + + {statusValue} + + + {billingPeriodLabel} + + @@ -145,8 +115,9 @@ function SubscriptionStrip({ )} {isTrialing && trialEndsAt && (

    - Card charges when trial ends on {format(trialEndsAt, "MMM d")}. Cancel anytime before to - avoid charges. + Card charges when trial ends on{" "} + {formatDateInTimezone(trialEndsAt, { timeZone: effectiveTimezone, withYear: false })}. + Cancel anytime before to avoid charges.

    )}
    @@ -159,6 +130,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { const usageResult = useAtomValue(billingUsageAtom) const dailySpendResult = useAtomValue(billingDailySpendAtom) const { openCustomerPortal } = useBillingActions() + const { effectiveTimezone } = useTimezonePreference() const customer = Result.isSuccess(customerResult) ? customerResult.value : undefined const plans = Result.isSuccess(plansResult) ? plansResult.value.plans : undefined @@ -174,16 +146,16 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { const { isLegacy } = getLegacyPlanInfo(customer, plans) const billingPeriodLabel = useMemo(() => { + const timeZone = effectiveTimezone + const range = (start: Date, end: Date) => + `${formatDateInTimezone(start, { timeZone, withYear: false })} – ${formatDateInTimezone(end, { timeZone })}` const activeSub = customer?.subscriptions?.find((s) => s.status === "active") if (activeSub?.currentPeriodStart && activeSub?.currentPeriodEnd) { - const start = new Date(activeSub.currentPeriodStart) - const end = new Date(activeSub.currentPeriodEnd) - return `${format(start, "MMM d")} – ${format(end, "MMM d, yyyy")}` + return range(new Date(activeSub.currentPeriodStart), new Date(activeSub.currentPeriodEnd)) } const now = new Date() - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) - return `${format(startOfMonth, "MMM d")} – ${format(now, "MMM d, yyyy")}` - }, [customer]) + return range(new Date(now.getFullYear(), now.getMonth(), 1), now) + }, [customer, effectiveTimezone]) const costEstimate = useMemo( () => estimateCycleCost({ customer, plans, usage: usageTotal }), @@ -205,7 +177,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { ) return ( -
    + openCustomerPortal({ returnUrl: window.location.href })} /> -
    + {isLoading || model === null ? ( + + ) : ( + + )} + + {isLoading || model === null ? ( - + ) : ( - + )} -
    + -
    - -
    - {isLoading || model === null ? ( - - ) : ( - - )} -
    -
    + {isLoading || model === null || Result.isInitial(dailySpendResult) ? ( + + ) : ( + + )} -
    - {isLoading || model === null || Result.isInitial(dailySpendResult) ? ( - + + {customer === undefined ? ( + ) : ( - + )} -
    + -
    - -
    - {customer === undefined ? ( - + + + + + {(isCostLoading || costEstimate !== null) && ( + + {isCostLoading || !costEstimate ? ( + ) : ( - + )} -
    -
    - -
    - -
    - -
    -
    - -
    - {(isCostLoading || costEstimate !== null) && ( -
    - -
    - {isCostLoading || !costEstimate ? ( - - ) : ( - - )} -
    -
    - )} - -
    - -
    - openCustomerPortal({ returnUrl: window.location.href })} - /> -
    -
    -
    + + )} -
    - -
    - openCustomerPortal({ returnUrl: window.location.href })} - /> -
    -
    -
    + + openCustomerPortal({ returnUrl: window.location.href })} + /> + + + + openCustomerPortal({ returnUrl: window.location.href })} + /> + + ) } diff --git a/apps/web/src/components/settings/checkout-return-panel.tsx b/apps/web/src/components/settings/checkout-return-panel.tsx index d7015d13e0..5fbf6a64a0 100644 --- a/apps/web/src/components/settings/checkout-return-panel.tsx +++ b/apps/web/src/components/settings/checkout-return-panel.tsx @@ -1,36 +1,34 @@ +import { Panel } from "@maple/ui/components/ui/panel" import { Spinner } from "@maple/ui/components/ui/spinner" /** * What the buyer sees between Stripe sending them back and Autumn confirming - * the subscription — in place of the plan offer, so the "Start trial" button + * the subscription, in place of the plan offer, so the "Start trial" button * they just used cannot be shown to them a second time. */ export function CheckoutConfirmingPanel() { return ( -

    Confirming your subscription…

    - Stripe is letting us know your checkout finished. This usually takes a few seconds — there's + Stripe is letting us know your checkout finished. This usually takes a few seconds; there's nothing else to click.

    -
    + ) } /** Shown above the offer once the wait gave up, so a slow sync doesn't read as "it failed". */ export function CheckoutTimedOutNotice() { return ( -

    + We haven't heard back from Stripe yet. If you completed checkout, your plan will appear here - shortly — there's no need to subscribe again. If you left checkout, pick a plan below. -

    + shortly; there's no need to subscribe again. If you left checkout, pick a plan below. + ) } diff --git a/apps/web/src/components/settings/cost-breakdown.tsx b/apps/web/src/components/settings/cost-breakdown.tsx index 4b934f7041..117cd2ca81 100644 --- a/apps/web/src/components/settings/cost-breakdown.tsx +++ b/apps/web/src/components/settings/cost-breakdown.tsx @@ -35,7 +35,7 @@ export function CostBreakdown({ estimate }: { estimate: CycleCostEstimate }) {

    {line.label}

    {line.detail && ( -

    +

    {line.detail}

    )} @@ -55,10 +55,10 @@ export function CostBreakdown({ estimate }: { estimate: CycleCostEstimate }) {
    -

    +

    So far this cycle · excludes taxes & credits {estimate.partial && - " · some items are on legacy pricing we can't itemize — see your invoice for the exact amount"} + " · some items are on legacy pricing we can't itemize; see your invoice for the exact amount"}

    ) diff --git a/apps/web/src/components/settings/create-api-key-dialog.tsx b/apps/web/src/components/settings/create-api-key-dialog.tsx index b11826565d..f4002e026d 100644 --- a/apps/web/src/components/settings/create-api-key-dialog.tsx +++ b/apps/web/src/components/settings/create-api-key-dialog.tsx @@ -1,11 +1,11 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { useAtomSet } from "@/lib/effect-atom" import { useId, useState } from "react" import { Exit } from "effect" import type { ApiKeyKind } from "@maple/domain/http" import type { V2ApiKeyWithSecret, V2Scope } from "@maple/domain/http/v2" -import { toastManager } from "@maple/ui/components/ui/toast" +import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" @@ -24,7 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { SegmentedSelect } from "@/components/common/segmented-select" import { useApiKeyMutationSync } from "@/hooks/use-api-keys" import { useAsyncAction } from "@/hooks/use-mutation-action" -import { displayError } from "@/lib/error-messages" +import { toastExit } from "@/lib/error-toast" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { trackProduct } from "@/lib/analytics" import { buildApiKeyCreatePayload } from "./api-key-create-payload" @@ -143,15 +143,14 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea ...(restrictedScopes !== undefined ? { scopes: restrictedScopes } : undefined), }), }) - if (Exit.isSuccess(result)) { - setCreatedKey(result.value) - trackProduct("api_key_created", { kind, access: isMcp ? "full" : accessMode }) - onCreated?.(result.value.secret) - void reconcileTxid(result.value.txid) - } else { - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + if (!Exit.isSuccess(result)) { + toastExit(result, { error: isMcp ? "Couldn't create MCP key" : "Couldn't create API key" }) + return } + setCreatedKey(result.value) + trackProduct("api_key_created", { kind, access: isMcp ? "full" : accessMode }) + onCreated?.(result.value.secret) + void reconcileTxid(result.value.txid) }) } @@ -184,7 +183,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea
    {createdKey.name} - + {createdKey.key_prefix}… {createdKey.scopes !== null && @@ -247,7 +246,10 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea label: o.label, }))} value={expiration} - onValueChange={(value) => setExpiration(value as ExpirationValue)} + onValueChange={(value) => { + const option = EXPIRATION_OPTIONS.find((o) => o.value === value) + if (option) setExpiration(option.value) + }} > @@ -316,9 +318,9 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea /> )} {visibleFamilies.length === 0 && ( -

    + No resource family matches "{scopeFilter.trim()}". -

    + )} {visibleFamilies.map((family) => { return ( @@ -344,15 +346,15 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea
    ) })} -

    - Write includes read. Scopes are fixed at creation — roll the + + Write includes read. Scopes are fixed at creation; roll the key to change access. -

    + ) : ( -

    + Full access to the organization's API. -

    + )} )} 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 34303fee41..fd17a8579c 100644 --- a/apps/web/src/components/settings/data-platform-usage-section.tsx +++ b/apps/web/src/components/settings/data-platform-usage-section.tsx @@ -1,13 +1,15 @@ -import { formatNumber, formatStorageBytes } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatNumber, formatStorageBytes } from "@maple/ui/lib/format" import type { ReactNode } from "react" import { Result } from "@/lib/effect-atom" -import { ChartLineIcon, DatabaseIcon, FileIcon, GridSquareCirclePlusIcon } from "@/components/icons" +import { DatabaseIcon, type IconComponent } from "@/components/icons" +import { CONCEPT_ICON } from "@/components/icons/concept" import { cn } from "@maple/ui/lib/utils" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { getServiceUsageResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { sumTotals } from "@/components/dashboard/service-usage-cards" +import { SettingsSection } from "@/components/settings/settings-section" // "Total in the DB": sum every retained usage row for the org. Fixed bounds // (not a rolling window) keep the atom key stable and capture all stored data. @@ -18,22 +20,22 @@ type StatKey = "logs" | "traces" | "metrics" | "dataSize" const STATS: ReadonlyArray<{ key: StatKey label: string - icon: typeof FileIcon + icon: IconComponent tone: string format: (n: number) => string }> = [ - { key: "logs", label: "Logs", icon: FileIcon, tone: "text-chart-2", format: formatNumber }, + { key: "logs", label: "Logs", icon: CONCEPT_ICON.log, tone: "text-chart-2", format: formatNumber }, { key: "traces", label: "Traces", - icon: GridSquareCirclePlusIcon, + icon: CONCEPT_ICON.trace, tone: "text-chart-5", format: formatNumber, }, { key: "metrics", label: "Metrics", - icon: ChartLineIcon, + icon: CONCEPT_ICON.metric, tone: "text-chart-3", format: formatNumber, }, @@ -63,14 +65,11 @@ export function DataPlatformUsageSection() { const result = useRefreshableAtomValue(getServiceUsageResultAtom({ data: ALL_TIME })) return ( -
    -
    -

    Stored data

    -

    - Everything currently held in the warehouse for this organization. -

    -
    - + {Result.builder(result) .onSuccess((response) => { @@ -79,13 +78,16 @@ export function DataPlatformUsageSection() { }) .onError(() => STATS.map((stat) => - statItem(stat, —), + statItem( + stat, + {EMPTY_VALUE}, + ), ), ) .orElse(() => STATS.map((stat) => statItem(stat, )), )} -
    + ) } diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index 0ee66c2ae2..5d3e1c6e54 100644 --- a/apps/web/src/components/settings/escalation-policy-section.tsx +++ b/apps/web/src/components/settings/escalation-policy-section.tsx @@ -1,7 +1,5 @@ import { useEffect, useState } from "react" import { Link } from "@tanstack/react-router" -import { Exit } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { Schema } from "effect" @@ -19,7 +17,7 @@ import { AlertDestinationId } from "@maple/domain/primitives" const decodeDestinationIds = Schema.decodeUnknownSync(Schema.Array(AlertDestinationId)) import { Button } from "@maple/ui/components/ui/button" -import { Card } from "@maple/ui/components/ui/card" +import { Panel } from "@maple/ui/components/ui/panel" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { SettingRow } from "@maple/ui/components/ui/setting-row" @@ -31,8 +29,10 @@ import { SeverityBadge, SEVERITY_ORDER } from "@/components/errors/severity-badg import { DocsLink } from "@/components/common/docs-link" import { ErrorState } from "@/components/common/error-state" import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" const CONFIDENCE_ANY = "any" as const +const CONFIDENCE_CHOICES = [CONFIDENCE_ANY, "low", "medium", "high"] as const interface SeverityRuleDraft { destinationIds: string[] @@ -89,33 +89,27 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { const [save, isSaving] = useAsyncAction(async () => { const ruleList = SEVERITY_ORDER.filter((severity) => rules[severity].destinationIds.length > 0).map( - (severity) => - new IssueEscalationPolicyRule({ + (severity) => { + const minConfidence = rules[severity].minConfidence + return new IssueEscalationPolicyRule({ severity, destinationIds: decodeDestinationIds(rules[severity].destinationIds), - ...(!(rules[severity].minConfidence === CONFIDENCE_ANY) - ? { - minConfidence: rules[severity].minConfidence as EscalationConfidence, - } - : undefined), - }), + ...(minConfidence !== CONFIDENCE_ANY ? { minConfidence } : undefined), + }) + }, ) const result = await upsertMutation({ payload: new IssueEscalationPolicyUpsertRequest({ enabled, rules: ruleList }), reactivityKeys: ["issueEscalationPolicy"], }) - if (Exit.isSuccess(result)) { - toastManager.add({ title: "Escalation policy saved", type: "success" }) - } else { - toastManager.add({ title: "Failed to save escalation policy", type: "error" }) - } + toastExit(result, { success: "Escalation policy saved", error: "Failed to save escalation policy" }) }) // Never render the editable form off a failed (or pending) policy load — // saving a default draft would silently overwrite the real policy. if (!initialized) { return ( -
    +
    {Result.builder(policyResult) .onError((error) => ( )) .orElse(() => ( - + ))}
    ) } return ( -
    - +
    +
    - + Min. AI confidence + + + Date + Plan + Status + Amount + + Invoice + + + + + {invoices.map((invoice, index) => ( + + ))} + +
    + ) } diff --git a/apps/web/src/components/settings/mcp-section.tsx b/apps/web/src/components/settings/mcp-section.tsx index 35a85f5791..1591c5b3ab 100644 --- a/apps/web/src/components/settings/mcp-section.tsx +++ b/apps/web/src/components/settings/mcp-section.tsx @@ -1,7 +1,6 @@ import { useState } from "react" import { Link } from "@tanstack/react-router" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" import { Button } from "@maple/ui/components/ui/button" @@ -11,6 +10,7 @@ import { mcpUrl } from "@/lib/services/common/mcp-url" import { McpToolsList } from "@/components/mcp/mcp-tools-list" import { CreateApiKeyDialog } from "@/components/settings/create-api-key-dialog" import { CodeBlock } from "@/components/quick-start/code-block" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" const mcpEndpoint = `${mcpUrl}/mcp` @@ -49,15 +49,12 @@ export function McpSection() { const [configTab, setConfigTab] = useState("claude-code") return ( -
    - - - Server Endpoint - - Use this URL to connect MCP-compatible clients to Maple. - - - + + +

    @@ -69,87 +66,82 @@ export function McpSection() {

    )}
    - - +
    +
    - - - Quick Setup - - Add the server URL. Your client will open Maple to approve access to a workspace. - - - - - - Claude Code - Cursor - Windsurf - Other - - -
    -
    -

    Run in your terminal

    - -
    -
    + + + + Claude Code + Cursor + Windsurf + Other + + +
    +
    +

    Run in your terminal

    + +
    +
    +

    + Or add to {CONFIG_FILE_HINTS["claude-code"]} +

    + +
    +
    +
    + {(["cursor", "windsurf", "other"] as const).map((client) => ( + +
    + {CONFIG_FILE_HINTS[client] && (

    - Or add to {CONFIG_FILE_HINTS["claude-code"]} + Add to {CONFIG_FILE_HINTS[client]}

    - -
    + )} +
    - {(["cursor", "windsurf", "other"] as const).map((client) => ( - -
    - {CONFIG_FILE_HINTS[client] && ( -

    - Add to {CONFIG_FILE_HINTS[client]} -

    - )} - -
    -
    - ))} - - {!createdSecret && ( -

    - Client does not support OAuth?{" "} - {" "} - or manage existing keys in{" "} - - API Keys - - . -

    - )} - - + ))} + + {!createdSecret && ( +

    + Client does not support OAuth?{" "} + {" "} + or manage existing keys in{" "} + + API Keys + + . +

    + )} + @@ -159,6 +151,6 @@ export function McpSection() { onCreated={(secret) => setCreatedSecret(secret)} kind="mcp" /> -
    +
    ) } diff --git a/apps/web/src/components/settings/members-section.tsx b/apps/web/src/components/settings/members-section.tsx index 8d277c36e5..5b9b09a2e6 100644 --- a/apps/web/src/components/settings/members-section.tsx +++ b/apps/web/src/components/settings/members-section.tsx @@ -1,43 +1,26 @@ // BOUNDARY: This module intentionally carries opaque values; callers decode them before domain use. import { useOrganization, useAuth } from "@clerk/clerk-react" import { useState } from "react" -import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { Button } from "@maple/ui/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@maple/ui/components/ui/card" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Avatar, AvatarFallback, AvatarImage } from "@maple/ui/components/ui/avatar" import { Badge } from "@maple/ui/components/ui/badge" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@maple/ui/components/ui/dialog" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" import { Input } from "@maple/ui/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" -import { toastAccountError } from "@/components/account/account-errors" +import { settleClerk } from "@/components/account/account-errors" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { PlusIcon, DotsVerticalIcon, TrashIcon, ShieldIcon, UserIcon, EnvelopeIcon } from "@/components/icons" +import { PlusIcon, TrashIcon, ShieldIcon, UserIcon, EnvelopeIcon } from "@/components/icons" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatDateInTimezone } from "@/lib/timezone-format" import { useAsyncAction } from "@/hooks/use-mutation-action" function getInitials(firstName?: string | null, lastName?: string | null) { @@ -46,16 +29,6 @@ function getInitials(firstName?: string | null, lastName?: string | null) { return (first + last).toUpperCase() || "?" } -const dateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", -}) - -function formatDate(date: Date) { - return dateFormatter.format(date) -} - function roleBadge(role: string) { if (role === "org:admin") { return Admin @@ -83,24 +56,30 @@ export function MembersSection() { } | null>(null) const [withRemoveLoading, removeLoading] = useAsyncAction((task: () => Promise) => task()) + const [invitationToRevoke, setInvitationToRevoke] = useState<{ + email: string + revoke: () => Promise + } | null>(null) + const [withRevokeLoading, revokeLoading] = useAsyncAction((task: () => Promise) => task()) + const { effectiveTimezone } = useTimezonePreference() + const isAdmin = orgRole === "org:admin" function handleInvite() { if (!organization || !inviteEmail.trim()) return return withInviteLoading(async () => { - try { - await organization.inviteMember({ + const ok = await settleClerk( + organization.inviteMember({ emailAddress: inviteEmail.trim(), - role: inviteRole as "org:admin" | "org:member", - }) - toastManager.add({ title: `Invitation sent to ${inviteEmail}`, type: "success" }) - setInviteEmail("") - setInviteRole("org:member") - setInviteOpen(false) - invitations?.revalidate?.() - } catch (err: unknown) { - toastAccountError(err, "Failed to send invitation") - } + role: inviteRole === "org:admin" ? "org:admin" : "org:member", + }), + { success: `Invitation sent to ${inviteEmail}`, error: "Failed to send invitation" }, + ) + if (!ok) return + setInviteEmail("") + setInviteRole("org:member") + setInviteOpen(false) + invitations?.revalidate?.() }) } @@ -109,42 +88,39 @@ export function MembersSection() { update: (params: { role: string }) => Promise, ) { const newRole = currentRole === "org:admin" ? "org:member" : "org:admin" - try { - await update({ role: newRole }) - toastManager.add({ - title: `Role updated to ${newRole === "org:admin" ? "Admin" : "Member"}`, - type: "success", - }) - memberships?.revalidate?.() - } catch (err: unknown) { - toastAccountError(err, "Failed to update role") - } + const ok = await settleClerk(update({ role: newRole }), { + success: `Role updated to ${newRole === "org:admin" ? "Admin" : "Member"}`, + error: "Failed to update role", + }) + if (ok) memberships?.revalidate?.() } function handleRemoveMember() { if (!memberToRemove) return return withRemoveLoading(async () => { - try { - await memberToRemove.destroy() - toastManager.add({ title: `${memberToRemove.name} has been removed`, type: "success" }) - memberships?.revalidate?.() - setRemoveDialogOpen(false) - setMemberToRemove(null) - } catch (err: unknown) { - // Keep the confirmation open with its member selected so the admin can retry. - toastAccountError(err, "Failed to remove member") - } + // On failure the confirmation stays open with its member selected so the admin can retry. + const ok = await settleClerk(memberToRemove.destroy(), { + success: `${memberToRemove.name} has been removed`, + error: "Failed to remove member", + }) + if (!ok) return + memberships?.revalidate?.() + setRemoveDialogOpen(false) + setMemberToRemove(null) }) } - async function handleRevokeInvitation(revoke: () => Promise, email: string) { - try { - await revoke() - toastManager.add({ title: `Invitation to ${email} revoked`, type: "success" }) + function handleRevokeInvitation() { + if (!invitationToRevoke) return + return withRevokeLoading(async () => { + const ok = await settleClerk(invitationToRevoke.revoke(), { + success: `Invitation to ${invitationToRevoke.email} revoked`, + error: "Failed to revoke invitation", + }) + if (!ok) return invitations?.revalidate?.() - } catch (err: unknown) { - toastAccountError(err, "Failed to revoke invitation") - } + setInvitationToRevoke(null) + }) } if (!isLoaded) { @@ -188,256 +164,225 @@ export function MembersSection() { const memberList = memberships?.data ?? [] const invitationList = invitations?.data ?? [] - return ( -
    - - - Team Members - {isAdmin && ( - - - - )} - - - {memberList.length === 0 ? ( - - - - - - No members - This organization has no members yet. - - {isAdmin && ( - - )} - - ) : ( - - - - User - Role - Joined - {isAdmin && } - - - - {memberList.map((member) => { - const userData = member.publicUserData - const isCurrentUser = userData?.userId === userId - const memberName = - [userData?.firstName, userData?.lastName].filter(Boolean).join(" ") || - userData?.identifier || - "Unknown" + const inviteButton = isAdmin ? ( + + ) : null - return ( - - -
    - - - - {getInitials( - userData?.firstName, - userData?.lastName, - )} - - -
    -
    - {memberName} - {isCurrentUser && ( - - (you) - - )} -
    -
    - {userData?.identifier} -
    -
    -
    -
    - {roleBadge(member.role)} - - {formatDate(member.createdAt)} - - {isAdmin && ( - - {!isCurrentUser && ( - - - } - > - - - - - handleRoleChange( - member.role, - (params) => member.update(params), - ) - } - > - - {member.role === "org:admin" - ? "Change to Member" - : "Change to Admin"} - - { - setMemberToRemove({ - id: member.id, - name: memberName, - destroy: () => member.destroy(), - }) - setRemoveDialogOpen(true) - }} - > - - Remove member - - - - )} - - )} -
    - ) - })} -
    -
    - )} -
    -
    + return ( + + 0 ? inviteButton : undefined} + padded={memberList.length === 0} + > + {memberList.length === 0 ? ( + + + + + + No members + This organization has no members yet. + + {inviteButton} + + ) : ( + + + + User + Role + Joined + {isAdmin && } + + + + {memberList.map((member) => { + const userData = member.publicUserData + const isCurrentUser = userData?.userId === userId + const memberName = + [userData?.firstName, userData?.lastName].filter(Boolean).join(" ") || + userData?.identifier || + "Unknown" - {invitationList.length > 0 && ( - - - Pending Invitations - - Invitations that have been sent but not yet accepted. - - - -
    - - - Email - Role - Status - {isAdmin && } - - - - {invitationList.map((invitation) => ( - + return ( +
    + - + {getInitials(userData?.firstName, userData?.lastName)} - {invitation.emailAddress} +
    +
    + {memberName} + {isCurrentUser && ( + + (you) + + )} +
    +
    + {userData?.identifier} +
    +
    - {roleBadge(invitation.role)} - - Pending + {roleBadge(member.role)} + + {formatDateInTimezone(member.createdAt, { + timeZone: effectiveTimezone, + })} {isAdmin && ( - + {!isCurrentUser && ( + + + handleRoleChange(member.role, (params) => + member.update(params), + ) + } + > + + {member.role === "org:admin" + ? "Change to Member" + : "Change to Admin"} + + { + setMemberToRemove({ + id: member.id, + name: memberName, + destroy: () => member.destroy(), + }) + setRemoveDialogOpen(true) + }} + > + + Remove member + + + )} )}
    - ))} -
    -
    - - + ) + })} + +
    + )} + + + {invitationList.length > 0 && ( + + + + + Email + Role + Status + {isAdmin && } + + + + {invitationList.map((invitation) => ( + + +
    + + + + + + {invitation.emailAddress} +
    +
    + {roleBadge(invitation.role)} + + Pending + + {isAdmin && ( + + + + )} +
    + ))} +
    +
    +
    )} - - - - Invite member - Send an invitation to join {organization.name}. - -
    - - - Email address - - setInviteEmail(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - /> - - - - Role - - - -
    - - - - -
    -
    + void handleInvite()} + submitLabel="Send invitation" + pending={inviteLoading} + submitDisabled={!inviteEmail.trim()} + > + + Email address + setInviteEmail(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> + + + Role + + + + + (open ? undefined : setInvitationToRevoke(null))} + title="Revoke invitation?" + description={`The invitation to ${invitationToRevoke?.email ?? ""} will stop working. You can invite them again later.`} + confirmLabel="Revoke invitation" + pending={revokeLoading} + onConfirm={() => void handleRevokeInvitation()} + /> void handleRemoveMember()} /> -
    + ) } diff --git a/apps/web/src/components/settings/notifications-section.tsx b/apps/web/src/components/settings/notifications-section.tsx index 0170878bd1..0fe9269152 100644 --- a/apps/web/src/components/settings/notifications-section.tsx +++ b/apps/web/src/components/settings/notifications-section.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from "react" import { Exit } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { MapleInternalAtomClient, retainedInternalQuery } from "@/lib/services/common/internal-atom-client" @@ -11,12 +10,15 @@ import { formatWarehouseDateTime } from "@maple/query-engine" import { Button } from "@maple/ui/components/ui/button" import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Panel } from "@maple/ui/components/ui/panel" import { MultiSelectCombobox } from "@maple/ui/components/multi-select-combobox" import { ChartBarTrendUpIcon, EnvelopeIcon } from "@/components/icons" import { getServicesFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { snapRangeForCache } from "@/lib/time-utils" import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" /** Two arrays are the same scope regardless of the order they were picked in. */ const sameScope = (a: readonly string[], b: readonly string[]) => @@ -100,65 +102,60 @@ export function NotificationsSection() { const scopeDirty = !sameScope(environments, savedScope.environments) || !sameScope(namespaces, savedScope.namespaces) - const [save, isSaving] = useAsyncAction(async (next: DigestSave): Promise => { - if (!email) return false - const result = await upsertMutation({ - payload: new UpsertDigestSubscriptionRequest({ - email, - enabled: next.enabled, - environments: next.environments, - namespaces: next.namespaces, - // The saved value when this save is not about it, so the ops digest's - // controls never flip the web analytics email. - webAnalyticsEnabled: next.webAnalyticsEnabled ?? webAnalyticsEnabled, - }), - }) - if (Exit.isSuccess(result)) { + const [save, isSaving] = useAsyncAction( + async (next: DigestSave, messages: { success: string; error: string }) => { + if (!email) return false + const result = await upsertMutation({ + payload: new UpsertDigestSubscriptionRequest({ + email, + enabled: next.enabled, + environments: next.environments, + namespaces: next.namespaces, + // The saved value when this save is not about it, so the ops digest's + // controls never flip the web analytics email. + webAnalyticsEnabled: next.webAnalyticsEnabled ?? webAnalyticsEnabled, + }), + }) + if (!toastExit(result, messages)) return false // Drop the local edits; the refreshed subscription now carries them. setEnabledEdit(null) setWebAnalyticsEdit(null) setScopeEdit(null) refreshSubscription() - } - return Exit.isSuccess(result) - }) + return true + }, + ) async function handleToggle(checked: boolean) { setEnabledEdit(checked) - const ok = await save({ enabled: checked, environments, namespaces }) - if (ok) { - toastManager.add({ - title: checked ? "Weekly digest enabled" : "Weekly digest disabled", - type: "success", - }) - } else { - toastManager.add({ title: "Failed to update notification preferences", type: "error" }) - setEnabledEdit(!checked) - } + const ok = await save( + { enabled: checked, environments, namespaces }, + { + success: checked ? "Weekly digest enabled" : "Weekly digest disabled", + error: "Failed to update notification preferences", + }, + ) + if (!ok) setEnabledEdit(!checked) } async function handleWebAnalyticsToggle(checked: boolean) { setWebAnalyticsEdit(checked) // `enabled` rides along: the upsert treats a missing value as "on". - const ok = await save({ enabled, environments, namespaces, webAnalyticsEnabled: checked }) - if (ok) { - toastManager.add({ - title: checked ? "Web analytics email enabled" : "Web analytics email disabled", - type: "success", - }) - } else { - toastManager.add({ title: "Failed to update notification preferences", type: "error" }) - setWebAnalyticsEdit(!checked) - } + const ok = await save( + { enabled, environments, namespaces, webAnalyticsEnabled: checked }, + { + success: checked ? "Web analytics email enabled" : "Web analytics email disabled", + error: "Failed to update notification preferences", + }, + ) + if (!ok) setWebAnalyticsEdit(!checked) } - async function handleSaveScope() { - const ok = await save({ enabled, environments, namespaces }) - if (ok) { - toastManager.add({ title: "Digest scope updated", type: "success" }) - } else { - toastManager.add({ title: "Failed to update digest scope", type: "error" }) - } + function handleSaveScope() { + return save( + { enabled, environments, namespaces }, + { success: "Digest scope updated", error: "Failed to update digest scope" }, + ) } /** @@ -182,109 +179,118 @@ export function NotificationsSection() { const [handlePreview, isPreviewing] = useAsyncAction(async () => { const result = await previewMutation({}) - if (Exit.isSuccess(result)) openPreview(result.value.html) - else toastManager.add({ title: "Failed to generate digest preview", type: "error" }) + if (toastExit(result, { error: "Failed to generate digest preview" }) && Exit.isSuccess(result)) + openPreview(result.value.html) }) const [handlePreviewWebAnalytics, isPreviewingWebAnalytics] = useAsyncAction(async () => { const result = await previewWebAnalyticsMutation({}) - if (Exit.isSuccess(result)) openPreview(result.value.html) - else toastManager.add({ title: "Failed to generate web analytics preview", type: "error" }) + if ( + toastExit(result, { error: "Failed to generate web analytics preview" }) && + Exit.isSuccess(result) + ) + openPreview(result.value.html) }) - if (!settled || !user) { - return ( -
    - -
    - ) - } + if (!settled || !user) return return ( -
    - } - label="Email" - description="Weekly digest via email" - control={ - - } - /> - {enabled && ( -
    - - Namespaces - - - - Environments - - - Leave both empty to receive a digest covering the whole organization. - - -
    - - {scopeDirty && ( - + + +
    + } + label="Email" + description="Weekly digest via email" + control={ + + } + > + {enabled && ( + + + Namespaces + + + + Environments + + + Leave both empty to receive a digest covering the whole organization. + + +
    + + {scopeDirty && ( + + )} +
    +
    )} -
    + + } + label="Web analytics" + description="Weekly overview of visitors, top pages and AI traffic. Only sent once the browser SDK is reporting visits." + control={ + <> + + + + } + />
    - )} - } - label="Web analytics" - description="Weekly overview of visitors, top pages and AI traffic. Only sent once the browser SDK is reporting visits." - control={ - <> - - - - } - /> -
    + + ) } 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 51d5f3363a..4eb6fc58a8 100644 --- a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx +++ b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx @@ -1,28 +1,24 @@ -import { countLabel } from "@maple/ui/lib/format" +import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { useEffect, useMemo, useRef, useState } from "react" import { Exit } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field" -import { Spinner } from "@maple/ui/components/ui/spinner" +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 { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@maple/ui/components/ui/card" 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 { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { AlertWarningIcon, ChevronDownIcon, @@ -34,26 +30,12 @@ import { import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" import { OrgClickHouseSettingsUpsertRequest } from "@maple/domain/http" import { DataPlatformUsageSection } from "@/components/settings/data-platform-usage-section" -import { getExitErrorMessage } from "@/lib/error-toast" +import { toastExit } from "@/lib/error-toast" +import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" import { ErrorState } from "@/components/common/error-state" -const syncDateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", -}) - -function formatSyncDate(value: string | null): string { - if (!value) return "Never" - try { - return syncDateFormatter.format(new Date(value)) - } catch { - return value - } -} - interface OrgClickHouseSettingsSectionProps { isAdmin: boolean hasEntitlement: boolean @@ -66,6 +48,7 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli const [chDatabase, setChDatabase] = useState("default") const [isStarting, setIsStarting] = useState(false) const [disableOpen, setDisableOpen] = useState(false) + const { effectiveTimezone } = useTimezonePreference() const [expandedDrifts, setExpandedDrifts] = useState>(new Set()) const settingsQueryAtom = retainedQuery("orgClickHouseSettings", "get", {}) @@ -100,14 +83,11 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli }), }) - if (Exit.isSuccess(result)) { - setChPassword("") - refreshSettings() - refreshDiff() - toastManager.add({ title: "ClickHouse connection saved", type: "success" }) + if (!toastExit(result, { success: "ClickHouse connection saved", error: "Failed to save settings" })) return - } - toastManager.add({ title: getExitErrorMessage(result, "Failed to save settings"), type: "error" }) + setChPassword("") + refreshSettings() + refreshDiff() }) const [handleRefreshDiff, isRefreshingDiff] = useAsyncAction(async () => { @@ -121,18 +101,17 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli const result = await deleteMutation({}) setDisableOpen(false) - if (Exit.isSuccess(result)) { - setChUrl("") - setChPassword("") - refreshSettings() - refreshDiff() - toastManager.add({ title: "BYO ClickHouse disabled", type: "success" }) + if ( + !toastExit(result, { + success: "BYO ClickHouse disabled", + error: "Failed to disable BYO ClickHouse", + }) + ) return - } - toastManager.add({ - title: getExitErrorMessage(result, "Failed to disable BYO ClickHouse"), - type: "error", - }) + setChUrl("") + setChPassword("") + refreshSettings() + refreshDiff() }) const settings = Result.builder(settingsResult) @@ -169,7 +148,11 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli refreshDiff() toastManager.add({ title: "Schema applied", type: "success" }) } else if (status === "failed") { - toastManager.add({ title: applyStatus?.errorMessage ?? "Schema apply failed", type: "error" }) + toastManager.add({ + title: "Schema apply failed", + description: applyStatus?.errorMessage ?? undefined, + type: "error", + }) } }, [applyStatus?.status, applyStatus?.errorMessage, refreshSettings, refreshDiff]) @@ -221,10 +204,7 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli return } setIsStarting(false) - toastManager.add({ - title: getExitErrorMessage(result, "Failed to start schema apply"), - type: "error", - }) + toastExit(result, { error: "Failed to start schema apply" }) } function toggleDriftRow(name: string) { @@ -240,25 +220,16 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli return ( <> -
    + - - - Bring your own ClickHouse - - Route this organization's read queries through your own ClickHouse server. - Save the connection first, then review the schema diff and apply the bundled - snapshot to your cluster. - - - {Result.isInitial(settingsResult) ? ( - - ) : ( - statusBadge - )} - - - + : statusBadge + } + > +
    {Result.isFailure(settingsResult) ? ( )} - - +
    +
    {configured ? ( - - -
    -
    - Schema - - Compare your cluster against Maple's bundled schema snapshot. - Apply creates missing tables and views, and adds missing columns to - existing tables. Type mismatches are skipped: resolve those manually. - -
    -
    -
    - -
    -
    -

    Last applied

    -

    {formatSyncDate(settings?.lastSyncAt ?? null)}

    -
    -
    -

    Applied version

    -

    + +

    + + + + {settings?.lastSyncAt + ? formatTimestampInTimezone(settings.lastSyncAt, { + timeZone: effectiveTimezone, + withYear: true, + }) + : "Never"} + + {settings?.schemaVersion ? ( ) : ( - "—" + EMPTY_VALUE )} -

    -
    -
    -

    Expected version

    -

    + + {diff?.expectedSchemaVersion ? ( ) : ( - "—" + EMPTY_VALUE )} -

    -
    -
    -

    Drift

    -

    + + {diffSummary ? `${diffSummary.up_to_date} ok · ${diffSummary.missing} missing · ${diffSummary.drifted} drift` - : "—"} -

    -
    -
    + : EMPTY_VALUE} + + + {Result.isInitial(diffResult) ? ( - ) : !Result.isSuccess(diffResult) ? ( - - - Failed to introspect ClickHouse:{" "} - {getExitErrorMessage( - // SAFETY: this branch has already excluded loading and success, leaving the failure Exit variant. - diffResult as unknown as Exit.Exit, - "check that credentials are valid", - )} - - + ) : Result.isFailure(diffResult) ? ( + ) : diff && diff.entries.length > 0 ? ( -
    + {diff.entries.map((entry) => { const isExpanded = expandedDrifts.has(entry.name) const isDrifted = entry.status === "drifted" @@ -458,9 +412,9 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli {entry.status === "up_to_date" ? "Up to date" : entry.status === "missing" - ? "Missing — will be created" + ? "Missing, will be created" : entry.status === "wrong_kind" - ? `Wrong kind: expected ${entry.kind === "materialized_view" ? "MV" : "table"}, found ${entry.actualKind === "materialized_view" ? "MV" : "table"} — resolve manually` + ? `Wrong kind: expected ${entry.kind === "materialized_view" ? "MV" : "table"}, found ${entry.actualKind === "materialized_view" ? "MV" : "table"}; resolve manually` : `Drift: ${countLabel(entry.columnDrifts.length, "mismatch", "mismatches")}`} {isDrifted ? ( @@ -490,60 +444,52 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli
    ) })} -
    + ) : ( -

    No tables in the schema.

    + No tables in the schema. )}
    - + />
    {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."} + {". Runs in the background, safe to leave this page."}

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

    {applyStatus.errorMessage}

    +

    {applyStatus.errorMessage}

    )} -
    -
    +
    + ) : null} -
    + Promise) => task()) - const [withSavingLogo, isSavingLogo] = useAsyncAction((task: () => Promise) => task()) + const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task()) + const [withSavingLogo, isSavingLogo] = useAsyncAction((task: () => Promise) => task()) const [deleteOpen, setDeleteOpen] = useState(false) const [confirmText, setConfirmText] = useState("") const [withDeleting, isDeleting] = useAsyncAction((task: () => Promise) => task()) @@ -71,38 +72,32 @@ export function OrganizationSection() { function handleRename() { if (!organization || !nameDirty) return - return withSavingName(async () => { - try { - await organization.update({ name: trimmedName }) - toastManager.add({ title: "Organization renamed", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to rename organization") - } - }) + return withSavingName(() => + settleClerk(organization.update({ name: trimmedName }), { + success: "Organization renamed", + error: "Failed to rename organization", + }), + ) } function handleLogoSelect(file: File) { if (!organization || !isAdmin || isSavingLogo) return - return withSavingLogo(async () => { - try { - await organization.setLogo({ file }) - toastManager.add({ title: "Organization logo updated", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to update logo") - } - }) + return withSavingLogo(() => + settleClerk(organization.setLogo({ file }), { + success: "Organization logo updated", + error: "Failed to update logo", + }), + ) } function handleRemoveLogo() { if (!organization || !isAdmin || isSavingLogo) return - return withSavingLogo(async () => { - try { - await organization.setLogo({ file: null }) - toastManager.add({ title: "Organization logo removed", type: "success" }) - } catch (err) { - toastAccountError(err, "Failed to remove logo") - } - }) + return withSavingLogo(() => + settleClerk(organization.setLogo({ file: null }), { + success: "Organization logo removed", + error: "Failed to remove logo", + }), + ) } function handleDelete() { @@ -114,11 +109,8 @@ export function OrganizationSection() { (m) => m.organization.id !== organization.id, ) const next = remaining[0]?.organization.id ?? null - try { - if (setActive) await setActive({ organization: next }) - } catch { - // fall through to navigation; Clerk session will refresh on next load - } + // A failed switch falls through to navigation; Clerk's session refreshes on next load. + if (setActive) await setActive({ organization: next }).catch(() => undefined) toastManager.add({ title: "Organization deleted", type: "success" }) setDeleteOpen(false) setConfirmText("") @@ -133,89 +125,80 @@ export function OrganizationSection() { } return ( -
    - - - General - - {isAdmin - ? "Update your organization's logo and name. Changes are visible to all members." - : "Only org admins can change these settings."} - - - -
    - - Logo - - } - onFile={(file) => void handleLogoSelect(file)} - onRemove={organization.hasImage ? () => void handleRemoveLogo() : undefined} - uploading={isSavingLogo} - disabled={!isAdmin} - targetLabel="Change organization logo" - changeLabel="Change logo" - /> - - - Name - setName(e.target.value)} - disabled={!isAdmin || isSavingName} - placeholder="Organization name" - /> - -
    - -
    - -
    -
    -
    - - - - Danger Zone - - Permanently delete this organization, its dashboards, alerts, API keys, and all - associated data. Telemetry already sent to Maple will age out per its retention - policy. This cannot be undone. - - - -
    -
    - {isAdmin - ? `Delete "${organization.name}" and remove every member's access.` - : "Only org admins can delete the organization."} -
    + + +
    + + Logo + + } + onFile={(file) => void handleLogoSelect(file)} + onRemove={organization.hasImage ? () => void handleRemoveLogo() : undefined} + uploading={isSavingLogo} + disabled={!isAdmin} + targetLabel="Change organization logo" + changeLabel="Change logo" + /> + + + Name + setName(e.target.value)} + disabled={!isAdmin || isSavingName} + placeholder="Organization name" + /> + +
    - - + +
    +
    + + + +

    + {isAdmin + ? `Delete "${organization.name}" and remove every member's access.` + : "Only org admins can delete the organization."} +

    + +
    +
    -
    + ) } diff --git a/apps/web/src/components/settings/plan-offer.tsx b/apps/web/src/components/settings/plan-offer.tsx index ac6d2094bf..a4b376cbde 100644 --- a/apps/web/src/components/settings/plan-offer.tsx +++ b/apps/web/src/components/settings/plan-offer.tsx @@ -5,13 +5,15 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { CatalogPlan } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { Panel } from "@maple/ui/components/ui/panel" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { billingCustomerAtom, billingPlansAtom } from "@/lib/services/atoms/billing-atoms" import { useBillingActions } from "@/hooks/use-billing-actions" -import { displayError } from "@/lib/error-messages" +import { showErrorToast } from "@/lib/error-toast" +import { formatCount } from "@/lib/billing/usage" import { getTrialStatus } from "@/lib/billing/plan-gating" import { buildCheckoutSuccessUrl } from "@/lib/billing/checkout-return" import { useCheckoutReturn } from "@/hooks/use-checkout-return" @@ -24,7 +26,7 @@ import { ErrorState } from "@/components/common/error-state" * The plans section: one offer, not a tier ladder. * * `apps/api/autumn.config.ts` sells exactly one self-serve plan, so a card-per- - * tier grid manufactured a choice that doesn't exist — the same conclusion + * tier grid manufactured a choice that doesn't exist: the same conclusion * `apps/landing/src/components/PricingTable.astro` reached for marketing. This is * the in-app version of that layout: a plate for the offer, and Enterprise as a * one-line rail beneath it rather than a peer card that competes with it. @@ -38,26 +40,26 @@ const ENTERPRISE_FROM_PRICE = "From $2,000" const includedRun = (plan: CatalogPlan): string => plan.items - .filter((item) => item.featureId && item.included != null) - .map((item) => { - const unit = featureUnit(item.featureId as string) - const amount = Number(item.included).toLocaleString() - return unit === "GB" ? `${amount} GB ${item.featureId}` : `${amount} ${unit}` + .flatMap((item) => { + if (!item.featureId || item.included == null) return [] + const unit = featureUnit(item.featureId) + const amount = formatCount(Number(item.included)) + return [unit === "GB" ? `${amount} GB ${item.featureId}` : `${amount} ${unit}`] }) .join(" · ") export function PlanOfferSkeleton() { return (
    - - + +
    ) } /** * The plan a custom-priced customer is actually on, rendered as their current - * plan. Their plan is not for sale, so it can never come from the catalog — the + * plan. Their plan is not for sale, so it can never come from the catalog; the * numbers here are their own, from the expanded subscription plus balances. */ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onManageBilling: () => void }) { @@ -73,7 +75,7 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana .map((feature) => feature.unlimited ? `unlimited ${feature.label.toLowerCase()}` - : `${Number(feature.included).toLocaleString()} ${featureUnit(feature.featureId) === "GB" ? "GB" : ""} ${feature.label.toLowerCase()}`.replace( + : `${formatCount(Number(feature.included))} ${featureUnit(feature.featureId) === "GB" ? "GB" : ""} ${feature.label.toLowerCase()}`.replace( /\s+/g, " ", ), @@ -81,7 +83,10 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana .join(" · ") return ( -
    +
    {model.planName ?? "Your plan"} @@ -93,7 +98,7 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana {price == null ? "Custom" : `$${price}`} - + {price == null ? "negotiated pricing" : billsUsage @@ -101,16 +106,16 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana : `/${interval}`}
    -

    +

    {billsUsage - ? "A custom plan — your allotments and rates are your own, not the published ones." - : "A custom plan with hard caps — usage past your allotments is rejected, never billed."} + ? "A custom plan: your allotments and rates are your own, not the published ones." + : "A custom plan with hard caps: usage past your allotments is rejected, never billed."}

    Included every cycle -

    +

    {included.length > 0 ? included : "Allotments are set on your contract."}

    @@ -119,9 +124,9 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana -

    Invoices and payment method

    +

    Invoices and payment method

    -
    + ) } @@ -160,24 +165,26 @@ export function PlanOffer({ // would quote them a price they don't pay. const showCustomPlate = model?.isCustomPlan === true && model.planName !== null - async function handleSubscribe(planId: string) { + function handleSubscribe(planId: string) { setAttaching(planId) - try { - const result = await attach({ planId, successUrl: buildCheckoutSuccessUrl(window.location.href) }) - if (result.paymentUrl) { - // Keep the button disabled through the redirect — see the note in - // pricing-cards.tsx. Clearing it here invites the double-click that - // Autumn answers with a 409. - window.location.href = result.paymentUrl - return - } - toastManager.add({ title: "Plan updated successfully.", type: "success" }) - refreshCustomer() - setAttaching(null) - } catch (error) { - toastManager.add({ title: displayError(error).message, type: "error" }) - setAttaching(null) - } + return attach({ planId, successUrl: buildCheckoutSuccessUrl(window.location.href) }).then( + (result) => { + if (result.paymentUrl) { + // Keep the button disabled through the redirect (see the note in + // pricing-cards.tsx). Clearing it here invites the double-click that + // Autumn answers with a 409. + window.location.href = result.paymentUrl + return + } + toastManager.add({ title: "Plan updated successfully.", type: "success" }) + refreshCustomer() + setAttaching(null) + }, + (error: unknown) => { + showErrorToast(error, { title: "Couldn't update your plan" }) + setAttaching(null) + }, + ) } return ( @@ -191,11 +198,12 @@ export function PlanOffer({ const retention = platformFeatures.find((feature) => feature.label === "Data retention") return ( -
    @@ -219,18 +227,18 @@ export function PlanOffer({ ${plan.price?.amount ?? 0} - + /{plan.price?.interval ?? "month"} + usage
    -

    +

    Everything in Maple. Pay per GB past what's included.

    Included every cycle -

    +

    {includedRun(plan)} {retention && ` · ${retention.value.toLowerCase()} retention`}

    @@ -239,7 +247,7 @@ export function PlanOffer({ {addOns.map((addOn) => { const addOnActive = addOn.customerEligibility?.status === "active" return ( -

    +

    {addOnActive ? "Add-on active · " : "Add-on available · "} {addOn.name} {addOn.price?.amount != null && @@ -267,21 +275,24 @@ export function PlanOffer({ : "Subscribe"} )} -

    +

    {isActive ? "Invoices and payment method" : "$0 due today · cancel anytime"}

    -
    + ) })} {/* Enterprise is a rail, not a peer: it keeps the "From $2,000" anchor that makes the self-serve price read as small, without spending a full card of attention on a call-us flow. */} -
    +
    Enterprise -

    +

    Higher volume, custom retention, priority support.

    @@ -298,7 +309,7 @@ export function PlanOffer({ Talk to the founder →
    -
    +
    ) } diff --git a/apps/web/src/components/settings/pricing-cards.tsx b/apps/web/src/components/settings/pricing-cards.tsx index dddf86e112..3eeca72c2f 100644 --- a/apps/web/src/components/settings/pricing-cards.tsx +++ b/apps/web/src/components/settings/pricing-cards.tsx @@ -6,7 +6,10 @@ import type { CatalogPlan, CatalogPlanItem } from "@maple/domain/http" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { billingCustomerAtom, billingPlansAtom } from "@/lib/services/atoms/billing-atoms" import { useBillingActions } from "@/hooks/use-billing-actions" -import { displayError } from "@/lib/error-messages" +import { showErrorToast } from "@/lib/error-toast" +import { formatCount } from "@/lib/billing/usage" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatDateInTimezone } from "@/lib/timezone-format" import { getTrialStatus } from "@/lib/billing/plan-gating" import { buildCheckoutSuccessUrl } from "@/lib/billing/checkout-return" import { useCheckoutReturn } from "@/hooks/use-checkout-return" @@ -29,16 +32,9 @@ import { import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Separator } from "@maple/ui/components/ui/separator" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@maple/ui/components/ui/dialog" import { FileIcon, PulseIcon, @@ -119,7 +115,7 @@ function formatIncludedUsage(item: PlanItem): string { if (item.unlimited) return "Unlimited" if (item.included != null) { const unit = (item.featureId ? COUNT_UNITS[item.featureId] : undefined) ?? "GB" - return `${Number(item.included).toLocaleString()} ${unit}` + return `${formatCount(Number(item.included))} ${unit}` } return "" } @@ -220,9 +216,10 @@ export function PricingCards() { const [confirmDialog, setConfirmDialog] = useState(null) const [isAttaching, setIsAttaching] = useState(false) const checkoutReturn = useCheckoutReturn() + const { effectiveTimezone } = useTimezonePreference() // Back from Stripe with the plan not yet synced: never re-offer the plan the - // buyer just bought — that is how a trial user ends up clicking "Start trial" + // buyer just bought; that is how a trial user ends up clicking "Start trial" // twice. if (checkoutReturn === "confirming") return @@ -271,73 +268,77 @@ export function PricingCards() { // For upgrades/downgrades, show a preview first if (scenario === "upgrade" || scenario === "downgrade") { setLoadingPlanId(planId) - try { - const preview = await previewAttach({ planId }) - setConfirmDialog({ - planId, - planName: plan?.name ?? planId, - lines: preview.lineItems.map((l) => ({ - description: l.description ?? "", - amount: l.total ?? 0, - })), - total: preview.total ?? 0, - currency: preview.currency ?? "usd", - nextCycle: preview.nextCycle - ? { starts_at: preview.nextCycle.startsAt ?? 0, total: preview.nextCycle.total ?? 0 } - : undefined, - }) - } catch (err) { - toastManager.add({ title: displayError(err).message, type: "error" }) - } finally { - setLoadingPlanId(null) - } + await previewAttach({ planId }).then( + (preview) => + setConfirmDialog({ + planId, + planName: plan?.name ?? planId, + lines: preview.lineItems.map((l) => ({ + description: l.description ?? "", + amount: l.total ?? 0, + })), + total: preview.total ?? 0, + currency: preview.currency ?? "usd", + nextCycle: preview.nextCycle + ? { + starts_at: preview.nextCycle.startsAt ?? 0, + total: preview.nextCycle.total ?? 0, + } + : undefined, + }), + (error: unknown) => showErrorToast(error, { title: "Couldn't preview the plan change" }), + ) + setLoadingPlanId(null) return } // For new subscriptions, attach directly (redirects to checkout if needed) setLoadingPlanId(planId) - try { - const result = await attach({ planId, successUrl: buildCheckoutSuccessUrl(window.location.href) }) - - if (result.paymentUrl) { - // Deliberately NOT clearing `loadingPlanId`: assigning `location.href` - // starts a navigation without stopping JS, so the old DOM stays on - // screen until Stripe answers. Re-enabling the button here left an - // inviting "Subscribe" on an apparently frozen page — and every extra - // click became a 409 we reported back as a failed purchase. - window.location.href = result.paymentUrl - return - } - - toastManager.add({ title: "Plan updated successfully.", type: "success" }) - refreshCustomer() - setLoadingPlanId(null) - } catch (err) { - toastManager.add({ title: displayError(err).message, type: "error" }) - setLoadingPlanId(null) - } + await attach({ planId, successUrl: buildCheckoutSuccessUrl(window.location.href) }).then( + (result) => { + if (result.paymentUrl) { + // Deliberately NOT clearing `loadingPlanId`: assigning `location.href` + // starts a navigation without stopping JS, so the old DOM stays on + // screen until Stripe answers. Re-enabling the button here left an + // inviting "Subscribe" on an apparently frozen page, and every extra + // click became a 409 we reported back as a failed purchase. + window.location.href = result.paymentUrl + return + } + + toastManager.add({ title: "Plan updated successfully.", type: "success" }) + refreshCustomer() + setLoadingPlanId(null) + }, + (error: unknown) => { + showErrorToast(error, { title: "Couldn't update your plan" }) + setLoadingPlanId(null) + }, + ) } async function handleConfirmAttach() { if (!confirmDialog) return setIsAttaching(true) - try { - const result = await attach({ - planId: confirmDialog.planId, - successUrl: buildCheckoutSuccessUrl(window.location.href), - }) - if (result.paymentUrl) { - window.location.href = result.paymentUrl - return - } - toastManager.add({ title: "Plan updated successfully.", type: "success" }) - refreshCustomer() - setConfirmDialog(null) - setIsAttaching(false) - } catch (err) { - toastManager.add({ title: displayError(err).message, type: "error" }) - setIsAttaching(false) - } + await attach({ + planId: confirmDialog.planId, + successUrl: buildCheckoutSuccessUrl(window.location.href), + }).then( + (result) => { + if (result.paymentUrl) { + window.location.href = result.paymentUrl + return + } + toastManager.add({ title: "Plan updated successfully.", type: "success" }) + refreshCustomer() + setConfirmDialog(null) + setIsAttaching(false) + }, + (error: unknown) => { + showErrorToast(error, { title: "Couldn't update your plan" }) + setIsAttaching(false) + }, + ) } function handleEnterpriseContact() { @@ -360,62 +361,52 @@ export function PricingCards() { loadingPlanId={loadingPlanId} /> - { if (!open) setConfirmDialog(null) }} + tone="default" + icon={null} + title="Confirm plan change" + description={ + <> + You're switching to{" "} + {confirmDialog?.planName}. + + } + confirmLabel="Confirm" + pending={isAttaching} + onConfirm={() => void handleConfirmAttach()} > - - - Confirm plan change - - You're switching to{" "} - {confirmDialog?.planName}. - - - - {confirmDialog && ( -
    - - {confirmDialog.lines.map((line, i) => ( - - {formatCurrency(line.amount, confirmDialog.currency)} - - ))} - - -
    - Due today - - {formatCurrency(confirmDialog.total, confirmDialog.currency)} - -
    - {confirmDialog.nextCycle && ( -

    - Then{" "} - {formatCurrency(confirmDialog.nextCycle.total, confirmDialog.currency)}{" "} - starting{" "} - {new Date(confirmDialog.nextCycle.starts_at).toLocaleDateString()} -

    - )} + {confirmDialog && ( +
    + + {confirmDialog.lines.map((line, i) => ( + + {formatCurrency(line.amount, confirmDialog.currency)} + + ))} + + +
    + Due today + + {formatCurrency(confirmDialog.total, confirmDialog.currency)} +
    - )} - - - - - - -
    + {confirmDialog.nextCycle && ( +

    + Then {formatCurrency(confirmDialog.nextCycle.total, confirmDialog.currency)}{" "} + starting{" "} + {formatDateInTimezone(confirmDialog.nextCycle.starts_at, { + timeZone: effectiveTimezone, + })} +

    + )} +
    + )} +
    ) } @@ -521,7 +512,7 @@ export function PlanCards({ {feature.value} {feature.detail && ( -

    +

    {feature.detail}

    )} @@ -578,7 +569,7 @@ export function PlanCards({ : btn.label} {trialAvailable && !btn.disabled && ( -

    +

    $0 due today · Card required · Cancel anytime

    )} diff --git a/apps/web/src/components/settings/recommended-mappings-section.tsx b/apps/web/src/components/settings/recommended-mappings-section.tsx index 614e804e04..94a93dd4c0 100644 --- a/apps/web/src/components/settings/recommended-mappings-section.tsx +++ b/apps/web/src/components/settings/recommended-mappings-section.tsx @@ -3,8 +3,6 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import type { V2Recommendation } from "@maple/domain/http/v2" import { useState } from "react" import { Link } from "@tanstack/react-router" -import { Exit } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Badge } from "@maple/ui/components/ui/badge" @@ -19,7 +17,11 @@ import { } from "@/lib/services/atoms/ingestion-atoms" import { formatNumber } from "@maple/ui/lib/format" import { DocsLink } from "@/components/common/docs-link" -import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { SettingsSection } from "@/components/settings/settings-section" +import { useKeyedAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" import { SegmentedSelect } from "@/components/common/segmented-select" import { formatRelativeTime } from "@maple/ui/lib/time-format" @@ -45,13 +47,13 @@ const MODE = { label: "Auto-apply", icon: BoltIcon, className: "border-primary/30 text-primary", - title: "Maple can apply this for you — Apply creates the ingest mapping.", + title: "Maple can apply this for you: Apply creates the ingest mapping.", }, manual: { label: "Manual fix", icon: CodeIcon, className: "text-muted-foreground", - title: "Fix this in your SDK — an ingest mapping can't resolve it.", + title: "Fix this in your SDK; an ingest mapping can't resolve it.", }, } as const @@ -61,7 +63,7 @@ function recSentence(issue: V2Recommendation) { <> Standardize on{" "} {issue.canonical_key} - — spans also emit + ; spans also emit {issue.source_key} ) @@ -86,15 +88,13 @@ function recSentence(issue: V2Recommendation) { function recPlainText(issue: V2Recommendation): string { if (issue.kind === "double-emission") - return `Standardize on ${issue.canonical_key} — spans also emit ${issue.source_key}` + return `Standardize on ${issue.canonical_key}; spans also emit ${issue.source_key}` if (issue.kind === "naming") return `Rename non-conforming key ${issue.source_key}` return `Rename ${issue.source_key} → ${issue.canonical_key}` } export function RecommendedMappingsSection() { const [tab, setTab] = useState<"open" | "closed">("open") - const [applyingId, setApplyingId] = useState(null) - const [busyId, setBusyId] = useState(null) const listResult = useAtomValue(recommendationIssuesListAtom) const refreshIssues = useAtomRefresh(recommendationIssuesListAtom) @@ -124,16 +124,9 @@ export function RecommendedMappingsSection() { const openIssues = issues.filter((i) => i.status === "open") const closedIssues = issues.filter((i) => i.status !== "open") - // Opportunistic — only surface when there's something open or dismissed to act on. - const hasRelevant = issues.some((i) => i.status === "open" || i.status === "dismissed") - if (!Result.isSuccess(listResult) || !hasRelevant) { - return null - } - - async function handleApply(issue: V2Recommendation) { + const apply = useKeyedAsyncAction(async (_id: string, issue: V2Recommendation) => { if (issue.kind !== "rename" || !issue.canonical_key) return const canonicalKey = issue.canonical_key - setApplyingId(issue.id) const result = await createMutation({ payload: { name: `Rename ${issue.source_key} → ${canonicalKey}`, @@ -143,160 +136,164 @@ export function RecommendedMappingsSection() { operation: "copy", }, }) - if (Exit.isSuccess(result)) { - toastManager.add({ - title: `Mapping created — ${issue.source_key} → ${canonicalKey}`, - type: "success", + if ( + toastExit(result, { + success: `Mapping created: ${issue.source_key} → ${canonicalKey}`, + error: "Failed to create mapping", }) + ) { refreshIssues() refreshMappings() - } else { - toastManager.add({ title: "Failed to create mapping", type: "error" }) } - setApplyingId(null) - } + }) - async function handleDismiss(issue: V2Recommendation) { - setBusyId(issue.id) - const result = await dismissMutation({ params: { id: issue.id } }) - if (Exit.isSuccess(result)) { - refreshIssues() - } else { - toastManager.add({ title: "Failed to dismiss recommendation", type: "error" }) - } - setBusyId(null) - } + // Dismiss and reopen share one per-row pending flag: a row only ever shows one of them. + const triage = useKeyedAsyncAction(async (id: V2Recommendation["id"], action: "dismiss" | "reopen") => { + const result = + action === "dismiss" + ? await dismissMutation({ params: { id } }) + : await reopenMutation({ params: { id } }) + const error = + action === "dismiss" ? "Failed to dismiss recommendation" : "Failed to reopen recommendation" + if (toastExit(result, { error })) refreshIssues() + }) - async function handleReopen(issue: V2Recommendation) { - setBusyId(issue.id) - const result = await reopenMutation({ params: { id: issue.id } }) - if (Exit.isSuccess(result)) { - refreshIssues() - } else { - toastManager.add({ title: "Failed to reopen recommendation", type: "error" }) - } - setBusyId(null) + // Opportunistic: only surface when there's something open or dismissed to act on. + const hasRelevant = issues.some((i) => i.status === "open" || i.status === "dismissed") + if (!Result.isSuccess(listResult) || !hasRelevant) { + return null } const rows = tab === "open" ? openIssues : closedIssues return ( - - - } className="text-sm font-medium"> - Recommendations - - - Deprecated or non-conforming OpenTelemetry attribute keys detected on your spans. - - - - - - + + } + > {rows.length === 0 ? ( -
    +

    {tab === "open" ? "No open recommendations. Your span attributes look healthy." : "Applied and dismissed recommendations show up here."}

    {tab === "open" && } -
    + ) : ( - rows.map((issue) => { - const kindTag = KIND_TAG[issue.kind] - const mode = issue.kind === "rename" ? MODE.auto : MODE.manual - const status = STATUS_BADGE[issue.status] - const isApplying = applyingId === issue.id - const isBusy = busyId === issue.id +
    + {rows.map((issue) => { + const kindTag = KIND_TAG[issue.kind] + const mode = issue.kind === "rename" ? MODE.auto : MODE.manual + const status = STATUS_BADGE[issue.status] + const isApplying = apply.isPending(issue.id) + const isBusy = triage.isPending(issue.id) - return ( -
    - - {kindTag.label} - - - - {recSentence(issue)} - - - {" "} - · {formatNumber(issue.usage_count)} spans/24h - - + + {kindTag.label} + + + + } + > + + {recSentence(issue)} + + + {" "} + · {formatNumber(issue.usage_count)} spans/24h + + + + {`${recPlainText(issue)} · ${formatNumber(issue.usage_count)} spans in 24h · opened ${formatRelativeTime(issue.opened_at)}`} + + -
    - {issue.status === "open" ? ( - <> - {issue.kind === "rename" ? ( +
    + {issue.status === "open" ? ( + <> + {issue.kind === "rename" ? ( + + ) : ( + + + } + > + + {mode.label} + + {mode.title} + + )} - ) : ( - + ) : issue.status === "dismissed" ? ( + <> + {status.label} + - - ) : issue.status === "dismissed" ? ( - <> + + Reopen + + + ) : ( {status.label} - - - ) : ( - {status.label} - )} + )} +
    -
    - ) - }) + ) + })} +
    )} -
    + ) } diff --git a/apps/web/src/components/settings/roll-api-key-dialog.tsx b/apps/web/src/components/settings/roll-api-key-dialog.tsx index 49f862b657..eb99401ba9 100644 --- a/apps/web/src/components/settings/roll-api-key-dialog.tsx +++ b/apps/web/src/components/settings/roll-api-key-dialog.tsx @@ -2,7 +2,6 @@ import { useAtomSet } from "@/lib/effect-atom" import { useState } from "react" import { Exit } from "effect" import type { V2ApiKey } from "@maple/domain/http/v2" -import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" import { @@ -16,7 +15,7 @@ import { } from "@maple/ui/components/ui/dialog" import { useApiKeyMutationSync } from "@/hooks/use-api-keys" import { useAsyncAction } from "@/hooks/use-mutation-action" -import { displayError } from "@/lib/error-messages" +import { toastExit } from "@/lib/error-toast" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { ApiKeySecretReveal } from "./api-key-secret-reveal" @@ -39,14 +38,13 @@ export function RollApiKeyDialog({ open, onOpenChange, apiKey, onRolled }: RollA if (!apiKey) return prepareForMutation() const result = await rollMutation({ params: { id: apiKey.id } }) - if (Exit.isSuccess(result)) { - setNewSecret(result.value.secret) - onRolled?.() - void reconcileTxid(result.value.txid) - } else { - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + if (!Exit.isSuccess(result)) { + toastExit(result, { error: "Couldn't roll API key" }) + return } + setNewSecret(result.value.secret) + onRolled?.() + void reconcileTxid(result.value.txid) }) function handleClose(nextOpen: boolean) { @@ -72,7 +70,7 @@ export function RollApiKeyDialog({ open, onOpenChange, apiKey, onRolled }: RollA API key rolled A new secret has been issued and the previous key was revoked. Copy your new - key now — you won't be able to see it again. + key now; you won't be able to see it again. diff --git a/apps/web/src/components/settings/scrape-targets-section.tsx b/apps/web/src/components/settings/scrape-targets-section.tsx index 6c6159571b..6e186a2a26 100644 --- a/apps/web/src/components/settings/scrape-targets-section.tsx +++ b/apps/web/src/components/settings/scrape-targets-section.tsx @@ -3,9 +3,13 @@ import { ScrapeIntervalSeconds } from "@maple/domain/http" import type { ScrapeAuthType, ScrapeTargetId } from "@maple/domain/http" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" import { useState, type KeyboardEvent } from "react" -import { Exit, Schema } from "effect" +import { Exit, Option, Schema } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" -import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field" +import { Field, FieldLabel, FieldDescription, FieldError } from "@maple/ui/components/ui/field" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" +import { Panel } from "@maple/ui/components/ui/panel" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { trySync } from "@maple/ui/lib/try-sync" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" @@ -16,21 +20,7 @@ import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/ale import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@maple/ui/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Input } from "@maple/ui/components/ui/input" @@ -47,7 +37,6 @@ import { CircleInfoIcon, CircleWarningIcon, CircleXmarkIcon, - DotsVerticalIcon, ExternalLinkIcon, FireIcon, HistoryIcon, @@ -57,14 +46,17 @@ import { TrashIcon, } from "@/components/icons" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatDuration, formatNumber } from "@maple/ui/lib/format" import { RelativeTime } from "@/components/common/relative-time" import { diagnoseScrapeError } from "@/lib/scrape-error-diagnosis" import { scheduledStatusFromChecks, scheduledStatusFromRollup } from "@/lib/scrape-target-status" import { catalogEntry } from "../integrations/integration-catalog" import { DocsLink } from "@/components/common/docs-link" import { ErrorState } from "@/components/common/error-state" -import { useAsyncAction } from "@/hooks/use-mutation-action" +import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" import { IntegrationEmpty, IntegrationEmptyCard, @@ -87,47 +79,36 @@ const AUTH_TYPE_LABELS: Record = { planetscale_oauth: "PlanetScale OAuth", } satisfies Record -const asScrapeIntervalSeconds = Schema.decodeUnknownSync(ScrapeIntervalSeconds) +const decodeScrapeInterval = Schema.decodeUnknownOption(ScrapeIntervalSeconds) function formatDurationSeconds(value: number | null): string { - if (value == null) return "-" + if (value == null) return EMPTY_VALUE return formatDuration(value * 1000) } function formatOptionalCount(value: number | null): string { - if (value == null) return "-" + if (value == null) return EMPTY_VALUE return formatNumber(Math.round(value)) } -function formatDateTime(value: string): string { - return new Date(value).toLocaleString(undefined, { - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) +/** Created/updated/check times, in the viewer's chosen timezone. */ +function useFormatDateTime(): (value: string) => string { + const { effectiveTimezone } = useTimezonePreference() + return (value) => formatTimestampInTimezone(value, { timeZone: effectiveTimezone }) } function hostnameFromUrl(value: string): string { - try { - return new URL(value).host - } catch { - return value - } + return Option.getOrElse( + trySync(() => new URL(value).host), + () => value, + ) } function labelEntries(labelsJson: string | null): Array<[string, string]> { if (!labelsJson) return [] - try { - const parsed = JSON.parse(labelsJson) as unknown - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [] - return Object.entries(parsed).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ) - } catch { - return [] - } + const parsed = Option.getOrNull(trySync((): Record => JSON.parse(labelsJson))) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [] + return Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string") } function checksFromResult(result: ScrapeTargetChecksResult): ScrapeTargetCheck[] { @@ -161,7 +142,7 @@ const COPY = { /** * Prometheus scrape-target manager. PlanetScale metrics collection is fully - * managed by its integration and never surfaces here — this section only + * managed by its integration and never surfaces here; this section only * lists and edits user-created prometheus targets. */ export function ScrapeTargetsSection({ @@ -170,9 +151,7 @@ export function ScrapeTargetsSection({ sourceFilter?: "prometheus" } = {}) { const [dialogOpen, setDialogOpen] = useState(false) - const [togglingId, setTogglingId] = useState(null) const [deleteConfirmTarget, setDeleteConfirmTarget] = useState(null) - const [probingId, setProbingId] = useState(null) const [selectedTargetId, setSelectedTargetId] = useState(null) const [editingTarget, setEditingTarget] = useState(null) @@ -209,31 +188,28 @@ export function ScrapeTargetsSection({ .filter((target) => target.target_type === sourceFilter) const selectedTarget = targets.find((target) => target.id === selectedTargetId) ?? null const copy = COPY - // When empty, the centered empty state owns the primary action — hide the toolbar row. + // When empty, the centered empty state owns the primary action, so hide the toolbar row. const isEmpty = Result.isSuccess(listResult) && targets.length === 0 const emptyEntry = catalogEntry(sourceFilter) - async function handleProbe(target: ScrapeTarget) { - setProbingId(target.id) + const probe = useKeyedAsyncAction(async (_id: ScrapeTargetId, target: ScrapeTarget) => { const result = await probeMutation({ params: { id: target.id }, reactivityKeys: ["scrapeTargets"], }) - if (Exit.isSuccess(result)) { - refreshTargets() - if (result.value.success) { - toastManager.add({ title: "Connection successful", type: "success" }) - } else { - toastManager.add({ - title: `Connection failed: ${result.value.last_scrape_error}`, - type: "error", - }) - } + if (!toastExit(result, { error: "Failed to test connection" }) || !Exit.isSuccess(result)) return + refreshTargets() + if (result.value.success) { + toastManager.add({ title: "Connection successful", type: "success" }) } else { - toastManager.add({ title: "Failed to test connection", type: "error" }) + toastManager.add({ + title: "Connection failed", + description: result.value.last_scrape_error ?? undefined, + type: "error", + }) } - setProbingId(null) - } + }) + const handleProbe = (target: ScrapeTarget) => void probe.run(target.id, target) function openAddDialog() { setEditingTarget(null) @@ -276,99 +252,77 @@ export function ScrapeTargetsSection({ return null } - const [handleSave, isSaving] = useAsyncAction(async () => { - if (!formName.trim() || !formUrl.trim()) { - toastManager.add({ title: "Name and URL are required", type: "error" }) - return - } - - let parsedInterval: ScrapeIntervalSeconds - try { - parsedInterval = asScrapeIntervalSeconds(Number.parseInt(formInterval, 10) || 15) - } catch { - toastManager.add({ - title: "Scrape interval must be an integer from 5 to 300 seconds", - type: "error", - }) - return - } + const parsedInterval = Option.getOrNull(decodeScrapeInterval(Number.parseInt(formInterval, 10) || 15)) + const formValid = formName.trim().length > 0 && formUrl.trim().length > 0 && parsedInterval !== null + const [handleSave, isSaving] = useAsyncAction(async () => { + if (!formValid || parsedInterval === null) return const authCredentials = buildAuthCredentials() + const payload = { + name: formName.trim(), + scrape_interval_seconds: parsedInterval, + service_name: formServiceName.trim() || null, + url: formUrl.trim(), + auth_type: formAuthType, + ...(authCredentials !== null ? { auth_credentials: authCredentials } : undefined), + } if (editingTarget) { const result = await updateMutation({ params: { id: editingTarget.id }, - payload: { - name: formName.trim(), - scrape_interval_seconds: parsedInterval, - service_name: formServiceName.trim() || null, - url: formUrl.trim(), - auth_type: formAuthType, - ...(authCredentials !== null ? { auth_credentials: authCredentials } : undefined), - }, + payload, reactivityKeys: ["scrapeTargets"], }) - if (Exit.isSuccess(result)) { - refreshTargets() - toastManager.add({ title: "Scrape target updated", type: "success" }) - setDialogOpen(false) - } else { - toastManager.add({ title: "Failed to update scrape target", type: "error" }) - } + if ( + !toastExit(result, { + success: "Scrape target updated", + error: "Failed to update scrape target", + }) + ) + return + refreshTargets() + setDialogOpen(false) } else { - const result = await createMutation({ - payload: { - name: formName.trim(), - scrape_interval_seconds: parsedInterval, - service_name: formServiceName.trim() || null, - url: formUrl.trim(), - auth_type: formAuthType, - ...(authCredentials !== null ? { auth_credentials: authCredentials } : undefined), - }, - reactivityKeys: ["scrapeTargets"], - }) - if (Exit.isSuccess(result)) { - refreshTargets() - toastManager.add({ title: "Scrape target created", type: "success" }) - setDialogOpen(false) - setSelectedTargetId(result.value.id) - } else { - toastManager.add({ title: "Failed to create scrape target", type: "error" }) - } + const result = await createMutation({ payload, reactivityKeys: ["scrapeTargets"] }) + if ( + !toastExit(result, { + success: "Scrape target created", + error: "Failed to create scrape target", + }) || + !Exit.isSuccess(result) + ) + return + refreshTargets() + setDialogOpen(false) + setSelectedTargetId(result.value.id) } }) async function handleDelete(targetId: ScrapeTargetId) { - setDeleteConfirmTarget(null) const result = await deleteMutation({ params: { id: targetId }, reactivityKeys: ["scrapeTargets"], }) - if (Exit.isSuccess(result)) { + const ok = toastExit(result, { + success: "Scrape target deleted", + error: "Failed to delete scrape target", + }) + if (ok) { refreshTargets() - toastManager.add({ title: "Scrape target deleted", type: "success" }) if (selectedTargetId === targetId) setSelectedTargetId(null) - } else { - toastManager.add({ title: "Failed to delete scrape target", type: "error" }) } + return ok } - async function handleToggleEnabled(target: ScrapeTarget) { - setTogglingId(target.id) + const toggle = useKeyedAsyncAction(async (_id: ScrapeTargetId, target: ScrapeTarget) => { const result = await updateMutation({ params: { id: target.id }, - payload: { - enabled: !target.enabled, - }, + payload: { enabled: !target.enabled }, reactivityKeys: ["scrapeTargets"], }) - if (!Exit.isSuccess(result)) { - toastManager.add({ title: "Failed to update scrape target", type: "error" }) - } else { - refreshTargets() - } - setTogglingId(null) - } + if (toastExit(result, { error: "Failed to update scrape target" })) refreshTargets() + }) + const handleToggleEnabled = (target: ScrapeTarget) => void toggle.run(target.id, target) return ( <> @@ -377,8 +331,8 @@ export function ScrapeTargetsSection({

    {copy.description}

    )} @@ -406,8 +360,8 @@ export function ScrapeTargetsSection({ {copy.emptyHint} {copy.emptyFooter} @@ -415,14 +369,14 @@ export function ScrapeTargetsSection({ ) : (
    -
    + {targets.map((target) => ( ))} -
    + {selectedTarget ? ( ) : ( -
    +
    Click a target to inspect scheduled checks.
    -
    + )}
    )}
    - - - - - {editingTarget ? "Edit Scrape Target" : "Add Scrape Target"} - - - {editingTarget - ? "Update the scrape target configuration." - : "Enter the URL of a Prometheus exporter endpoint. Maple will periodically scrape this endpoint for metrics."} - - -
    - - Name - setFormName(e.target.value)} - /> - - - Service Name - setFormServiceName(e.target.value)} - /> - - Metrics will appear under this service name. Defaults to the target name if - empty. - - + void handleSave()} + submitLabel={editingTarget ? "Save changes" : "Add target"} + pending={isSaving} + submitDisabled={!formValid} + > + + Name + setFormName(e.target.value)} + /> + + + Service name + setFormServiceName(e.target.value)} + /> + + Metrics will appear under this service name. Defaults to the target name if empty. + + + + URL + setFormUrl(e.target.value)} + /> + + + Scrape interval (seconds) + setFormInterval(e.target.value)} + /> + {parsedInterval === null ? ( + + Scrape interval must be an integer from 5 to 300 seconds. + + ) : null} + + + Authentication + + + {formAuthType === "bearer" && ( + + Bearer Token + setFormAuthToken(e.target.value)} + /> + + )} + {formAuthType === "basic" && ( + <> - URL + Username setFormUrl(e.target.value)} + id="scrape-auth-username" + placeholder={ + editingTarget?.has_credentials && editingTarget.auth_type === "basic" + ? "Leave blank to keep existing" + : "Enter username" + } + value={formAuthUsername} + onChange={(e) => setFormAuthUsername(e.target.value)} /> - Scrape Interval (seconds) + Password setFormInterval(e.target.value)} + id="scrape-auth-password" + type="password" + placeholder={ + editingTarget?.has_credentials && editingTarget.auth_type === "basic" + ? "Leave blank to keep existing" + : "Enter password" + } + value={formAuthPassword} + onChange={(e) => setFormAuthPassword(e.target.value)} /> - - Authentication - - - {formAuthType === "bearer" && ( - - Bearer Token - setFormAuthToken(e.target.value)} - /> - - )} - {formAuthType === "basic" && ( - <> - - Username - setFormAuthUsername(e.target.value)} - /> - - - Password - setFormAuthPassword(e.target.value)} - /> - - - )} -
    - - - - -
    -
    + + )} + { if (!open) setDeleteConfirmTarget(null) }} - title="Delete scrape target" + title="Delete scrape target?" description={ <> Are you sure you want to delete{" "} @@ -605,10 +552,7 @@ export function ScrapeTargetsSection({ } confirmLabel="Delete" - onConfirm={() => { - // handleDelete clears the target, which closes the dialog. - if (deleteConfirmTarget) void handleDelete(deleteConfirmTarget.id) - }} + onConfirm={() => (deleteConfirmTarget ? handleDelete(deleteConfirmTarget.id) : undefined)} /> ) @@ -721,35 +665,25 @@ function ScrapeTargetRow({
    event.stopPropagation()}> - - - } + + {/* Managed targets are edited/removed through the owning integration card. */} + onEdit(target)}> + + Edit + + + onDelete(target)} > - - - - {/* Managed targets are edited/removed through the owning integration card. */} - onEdit(target)}> - - Edit - - - onDelete(target)} - > - - Delete - - - + + Delete + +
    ) @@ -788,9 +722,10 @@ function ScrapeTargetDetails({ const failureMessage = latestCheck && !latestCheck.success ? latestCheck.message : target.last_scrape_error const diagnosis = diagnoseScrapeError(failureMessage, target.target_type) + const formatDateTime = useFormatDateTime() return ( - + ) } function MetricBox({ label, value }: { label: string; value: string }) { return ( -
    + {label}
    {value}
    -
    + ) } @@ -952,6 +893,7 @@ export function ScrapeTargetChecksTable({ result: ScrapeTargetChecksResult checks: ScrapeTargetCheck[] }) { + const formatDateTime = useFormatDateTime() if (Result.isInitial(result)) { return } @@ -967,7 +909,7 @@ export function ScrapeTargetChecksTable({ } return ( -
    + @@ -1017,6 +959,6 @@ export function ScrapeTargetChecksTable({ ))}
    -
    + ) } diff --git a/apps/web/src/components/settings/settings-section.tsx b/apps/web/src/components/settings/settings-section.tsx new file mode 100644 index 0000000000..7d6b4eb520 --- /dev/null +++ b/apps/web/src/components/settings/settings-section.tsx @@ -0,0 +1,48 @@ +import * as React from "react" + +import { Panel } from "@maple/ui/components/ui/panel" +import { cn } from "@maple/ui/lib/utils" + +import { SectionHeading } from "@/components/common/section-heading" + +/** + * One block of a settings page: heading, optional description and actions, then the + * content in a `Panel`. Every section shares the fixed `max-w-3xl` column so tabs don't + * jump width as you move between them. `framed={false}` for content that brings its + * own frame (a table, a list of cards). + */ +export function SettingsSection({ + title, + description, + actions, + framed = true, + padded = true, + className, + children, +}: { + title: string + description?: React.ReactNode + /** Right of the heading: the section's primary action. */ + actions?: React.ReactNode + framed?: boolean + /** Panel padding when framed; off for a panel of `SettingRow`s that pad themselves. */ + padded?: boolean + className?: string + children: React.ReactNode +}) { + const id = React.useId() + return ( +
    +
    + + {description ?

    {description}

    : null} +
    + {framed ? {children} : children} +
    + ) +} + +/** Stacks a settings tab's sections at the shared rhythm. */ +export function SettingsSections({ children }: { children: React.ReactNode }) { + return
    {children}
    +} diff --git a/apps/web/src/components/settings/setup-audit-section.tsx b/apps/web/src/components/settings/setup-audit-section.tsx index 6d09836177..52fe316ddf 100644 --- a/apps/web/src/components/settings/setup-audit-section.tsx +++ b/apps/web/src/components/settings/setup-audit-section.tsx @@ -1,6 +1,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { countLabel } from "@maple/ui/lib/format" -import { Card, CardAction, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +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" @@ -9,12 +10,11 @@ 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 { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Button, buttonVariants } from "@maple/ui/components/ui/button" +import { 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 { - ArrowRotateAnticlockwiseIcon, CircleCheckIcon, CircleInfoIcon, CircleWarningIcon, @@ -26,6 +26,7 @@ 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" type Severity = V2SetupAuditCheck["severity"] type Category = V2SetupAuditCheck["category"] @@ -41,7 +42,7 @@ const SEVERITY_ORDER: ReadonlyArray = ["critical", "warn", "info"] /** * Each category's owning surface, so a finding is one click from where it gets fixed. `tab` targets a * sibling settings tab; `to` targets a standalone route. Kept as separate fields because TanStack - * Router types `search` per route — a widened `object` does not typecheck. + * Router types `search` per route; a widened `object` does not typecheck. */ type CategoryTarget = | { label: string; fixLabel: string; tab: SettingsTab } @@ -89,7 +90,7 @@ const CATEGORY_ORDER: ReadonlyArray = [ function SummaryPill({ count, label, className }: { count: number; label: string; className: string }) { return ( - + 0 && className)}>{count} {label} ) @@ -110,7 +111,7 @@ function CheckRow({ check }: { check: V2SetupAuditCheck }) {
    {check.title} - + {check.id} {check.status === "skip" && ( @@ -128,13 +129,13 @@ function CheckRow({ check }: { check: V2SetupAuditCheck }) { {entity.name} ))} {check.affected_count > check.affected.length && ( - + +{check.affected_count - check.affected.length} more )} @@ -168,30 +169,28 @@ function CategoryCard({ const meta = CATEGORY[category] const findings = checks.filter((check) => check.status === "fail").length return ( - - - } className="flex items-center gap-3 text-sm font-medium"> + +
    +

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

    + +
    {checks.map((check) => ( ))} -
    + ) } -function Report({ audit }: { audit: V2SetupAudit }) { +function Report({ audit, refreshing }: { audit: V2SetupAudit; refreshing: boolean }) { const [showPassing, setShowPassing] = useState(false) const refresh = useAtomRefresh(setupAuditAtom) @@ -211,34 +210,36 @@ function Report({ audit }: { audit: V2SetupAudit }) { 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 - - - - + + + + + + + No telemetry yet + + The audit runs once your first service reports in. Connect one from the Ingestion + tab and come back. + + + + + Go to Ingestion + + + + + ) } return (
    -
    + @@ -248,15 +249,17 @@ function Report({ audit }: { audit: V2SetupAudit }) { )}
    - - -
    + + refresh()} + /> +
    {!audit.telemetry_checks_available && ( - + Telemetry checks skipped. @@ -267,14 +270,14 @@ function Report({ audit }: { audit: V2SetupAudit }) { )} {findingCount === 0 && !showPassing ? ( -
    +

    Everything checks out

    All {summary.pass} checks passed. Alerts can deliver, and your telemetry follows the conventions Maple reads.

    -
    + ) : ( grouped.map((group) => ( @@ -297,18 +300,18 @@ function Report({ audit }: { audit: V2SetupAudit }) { export function SetupAuditSection() { const result = useAtomValue(setupAuditAtom) - if (Result.isFailure(result)) { - return - } - - if (!Result.isSuccess(result)) { - return ( -
    - - -
    - ) - } - - return + return ( + + {Result.isFailure(result) ? ( + + ) : !Result.isSuccess(result) ? ( +
    + + +
    + ) : ( + + )} +
    + ) } diff --git a/apps/web/src/components/settings/spend-chart.tsx b/apps/web/src/components/settings/spend-chart.tsx index 947d310874..08c6845fc1 100644 --- a/apps/web/src/components/settings/spend-chart.tsx +++ b/apps/web/src/components/settings/spend-chart.tsx @@ -2,21 +2,16 @@ import { useMemo } from "react" import { areaY, d3Curve, defineChart, dot, lineY, ruleX, stack, text } from "@tanstack/charts" import { decorative } from "@tanstack/charts/mark/decorative" import { scaleLinear } from "@tanstack/charts-scales/linear" -import { scalePoint } from "@tanstack/charts-scales/point" import { curveMonotoneX } from "d3-shape" import { - PlotFrame, - PlotTooltipBody, - createTooltipFocusStore, - cursorTooltip, + CursorPlot, DASHED_Y_GRID, focusCrosshair, - resolvePlotColor, - usePlotChromeColors, - type PlotTooltipSeries, + makeBucketAxis, + useCursorPlot, + type CursorPlotSeries, } from "@maple/ui/components/plot" -import { useTheme } from "@maple/ui/hooks/use-theme" /** One stacked band segment: a day, the band it belongs to, and its dollars. */ interface SpendCell { @@ -33,12 +28,15 @@ type SpendDatum = CumulativePoint | SpendCell /** The day behind a datum — every band's cumulative total at that point. */ function dayOf(datum: SpendDatum): CumulativePoint { - return typeof (datum as SpendCell).point === "object" - ? (datum as SpendCell).point - : (datum as CumulativePoint) + return "point" in datum ? datum.point : datum } + +const numberOrNull = (value: unknown): number | null => (typeof value === "number" ? value : null) import { ChartEmpty, ChartLoading } from "@maple/ui/components/charts" +import { ChartCard } from "@/components/common/chart-card" +import { SeriesLegend } from "@/components/common/series-legend" + import { formatCurrency } from "@maple/domain/format" import { buildCumulativeSeries, @@ -49,6 +47,7 @@ import { type SpendModel, } from "@/lib/billing/spend" import type { DailySpendResponse } from "@maple/domain/http" +import { formatDateInTimezone } from "@/lib/timezone-format" /** * Cumulative spend for the cycle, stacked by what's driving it. @@ -91,6 +90,15 @@ export function SpendChartSkeleton() { return } +/** Spend days are UTC calendar days: label them on that clock whatever the viewer's zone. */ +const SPEND_TIME_ZONE = "UTC" + +const dateLabel = (value: string) => + formatDateInTimezone(new Date(`${value}T00:00:00Z`), { timeZone: SPEND_TIME_ZONE, withYear: false }) + +/** The dashed projection's series key; not a band, so not in the tooltip. */ +const PROJECTED = "projected" + export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySpendResponse | undefined }) { const data = useMemo(() => buildCumulativeSeries({ daily, model }), [daily, model]) @@ -122,31 +130,12 @@ export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySp const lastPoint = data[data.length - 1] const hasFuture = lastPoint !== undefined && lastPoint.future - if (data.length === 0) { - return No ingest recorded this cycle yet - } - - const chromeColors = usePlotChromeColors() - const focusStore = useMemo(() => createTooltipFocusStore(), []) - const { theme } = useTheme() - // oxlint-disable-next-line react-hooks/exhaustive-deps - const primary = useMemo(() => resolvePlotColor("--primary", "#6366f1"), [theme]) - - const dateLabel = useMemo( - () => (value: string) => - new Date(`${value}T00:00:00Z`).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }), - [], - ) - - const tooltipSeries = useMemo[]>( - () => - BANDS.map((band) => ({ - label: chartConfig[band]?.label ?? band, - color: chartConfig[band]?.color ?? chromeColors.border, + const series = useMemo[]>( + () => [ + ...BANDS.map((band) => ({ + key: band, + label: chartConfig[band].label, + color: chartConfig[band].color, // Read off the DAY, so a hovered band still prints every other band's // running total at that point rather than only its own. value: (datum: SpendDatum) => { @@ -155,11 +144,33 @@ export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySp }, format: (value: number) => formatCurrency(value, model.currency), })), - [chromeColors.border, model.currency], + { + key: PROJECTED, + label: "Projected", + color: "--primary", + value: (datum: SpendDatum) => dayOf(datum).projected, + format: (value: number) => formatCurrency(value, model.currency), + tooltip: false, + }, + ], + [model.currency], + ) + const plot = useCursorPlot(series) + const primary = plot.color(PROJECTED) + + // A daily time axis: the same ticks every other bucketed chart draws over + // the same range, printing "Feb 14" for day buckets. + const axis = useMemo( + () => + makeBucketAxis( + data.map((point) => point.date), + SPEND_TIME_ZONE, + ), + [data], ) const definition = useMemo(() => { - const at = (point: CumulativePoint) => point.date + const at = (point: CumulativePoint) => new Date(point.dayMs) /** * Stacking groups on `z`, so the bands are built as CELLS — one datum per @@ -170,19 +181,19 @@ export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySp BANDS.map((band) => ({ point, band, - value: typeof point[band] === "number" ? (point[band] as number) : null, + value: numberOrNull(point[band]), })), ) return defineChart({ marks: [ areaY(cells, { - x: (cell: SpendCell) => cell.point.date, + x: (cell: SpendCell) => at(cell.point), y: (cell: SpendCell) => cell.value, z: (cell: SpendCell) => cell.band, - fill: (cell: SpendCell) => chartConfig[cell.band]?.color ?? chromeColors.border, + fill: (cell: SpendCell) => plot.color(cell.band), fillOpacity: 0.35, - stroke: (cell: SpendCell) => chartConfig[cell.band]?.color ?? chromeColors.border, + stroke: (cell: SpendCell) => plot.color(cell.band), strokeWidth: 1, curve: d3Curve(curveMonotoneX), layout: stack({ order: [...BANDS] }), @@ -233,24 +244,17 @@ export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySp decorative( ruleX([todayPoint], { x: at, - stroke: chromeColors.border, + stroke: plot.chrome.border, strokeOpacity: 1, strokeWidth: 1, }), ), ] : []), - focusCrosshair(chromeColors), + focusCrosshair(plot.chrome), ], scales: { - x: { - scale: scalePoint, - axis: { - line: false, - ticks: { size: 0, padding: 8, format: dateLabel }, - tickLabels: { thin: { minGap: 12 } }, - }, - }, + x: axis.x, y: { grid: DASHED_Y_GRID, scale: scaleLinear().domain([0, yMax]), @@ -271,74 +275,45 @@ export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySp margin: { top: 8, right: 56, left: 52 }, focus: "group-x", focusRing: false, - tooltip: cursorTooltip(focusStore.anchor), + tooltip: plot.tooltip, }) - }, [ - data, - hasFuture, - lastPoint, - todayPoint, - projected, - yMax, - primary, - chromeColors, - dateLabel, - model.currency, - focusStore, - ]) + }, [data, hasFuture, lastPoint, todayPoint, projected, yMax, primary, plot, axis, model.currency]) - return ( -
    -
    -
    -

    Spend this cycle

    -

    - Cumulative estimated spend by feature -

    -
    - {/* The legend carries each band's cycle-to-date dollars, not just its - color: a row of six dots tells you which color is which, but not - which band is worth reading. With the amounts it doubles as the - breakdown, and the "$0.00" bands say plainly that they contribute - nothing rather than hiding somewhere on the axis. */} -
    - {BANDS.map((band) => ( - - - - {chartConfig[band]?.label} - - - {formatCurrency(bandTotals[band], model.currency)} - - - ))} -
    -
    + if (data.length === 0) { + return No ingest recorded this cycle yet + } -
    -
    - ( - dateLabel(dayOf(datum).date)} - /> - )} - /> -
    + return ( + ({ + key: band, + label: chartConfig[band].label, + color: chartConfig[band].color, + value: formatCurrency(bandTotals[band], model.currency), + }))} + /> + } + > +
    + dateLabel(dayOf(datum).date)} + ariaLabel="Cumulative spend this cycle" + height={CHART_HEIGHT} + />
    -
    +
    {model.cycleDays}-day cycle · day {model.dayOfCycle} @@ -346,6 +321,6 @@ export function SpendChart({ model, daily }: { model: SpendModel; daily: DailySp projected {formatCurrency(projected, model.currency)}
    -
    +
    ) } diff --git a/apps/web/src/components/support/support-channel-body.tsx b/apps/web/src/components/support/support-channel-body.tsx index a3b82d6394..d628864f53 100644 --- a/apps/web/src/components/support/support-channel-body.tsx +++ b/apps/web/src/components/support/support-channel-body.tsx @@ -10,7 +10,6 @@ import { ExternalLinkIcon } from "@/components/icons" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { trackProduct } from "@/lib/analytics" import { useAsyncAction } from "@/hooks/use-mutation-action" -import { displayError } from "@/lib/error-messages" import { inviteToSupportChannelMutation, supportChannelAtom, @@ -24,7 +23,8 @@ export function SupportChannelBody() { const result = useAtomValue(channelAtom) const refresh = useAtomRefresh(channelAtom) const runInvite = useAtomSet(inviteToSupportChannelMutation, { mode: "promiseExit" }) - const [error, setError] = useState(null) + // The failed Exit itself, so ErrorState words it like every other inline failure. + const [error, setError] = useState(null) const [invited, setInvited] = useState(null) const [invite, pending] = useAsyncAction(async () => { @@ -36,7 +36,7 @@ export function SupportChannelBody() { refresh() return } - setError(displayError(exit).message) + setError(exit) }) if (invited !== null) { @@ -105,7 +105,14 @@ export function SupportChannelBody() { here.

    )} - {error ?

    {error}

    : null} + {error !== null ? ( + + ) : null} {channel.status === "active" && channel.slack_url ? ( diff --git a/apps/web/src/components/time-range-picker/custom-range-picker.tsx b/apps/web/src/components/time-range-picker/custom-range-picker.tsx index 056f8be762..603358d836 100644 --- a/apps/web/src/components/time-range-picker/custom-range-picker.tsx +++ b/apps/web/src/components/time-range-picker/custom-range-picker.tsx @@ -7,6 +7,7 @@ import type { DateRange } from "react-day-picker" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatForTinybird } from "@/lib/time-utils" import { normalizeTimestampInput } from "@/lib/timezone-format" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" interface CustomRangePickerProps { startTime?: string @@ -108,24 +109,28 @@ export function CustomRangePicker({ startTime, endTime, onApply, onCancel }: Cus
    -
    - + + + Start time + setStartTimeInput(e.target.value)} className="font-mono" /> -
    -
    - + + + + End time + setEndTimeInput(e.target.value)} className="font-mono" /> -
    +
    diff --git a/apps/web/src/components/time-range-picker/quick-select-grid.tsx b/apps/web/src/components/time-range-picker/quick-select-grid.tsx index 722e2a2e87..ef5e6bf2dc 100644 --- a/apps/web/src/components/time-range-picker/quick-select-grid.tsx +++ b/apps/web/src/components/time-range-picker/quick-select-grid.tsx @@ -20,7 +20,7 @@ export function QuickSelectGrid({ onSelect }: QuickSelectGridProps) { onSelect(range, option.value, option.label) } }} - className="flex h-7 items-center justify-center rounded-md border border-border/60 bg-background/30 font-mono text-[11px] tracking-wide text-foreground/90 transition-colors hover:border-border hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="flex h-7 items-center justify-center rounded-md border border-border/60 bg-background/30 font-mono text-2xs tracking-wide text-foreground/90 transition-colors hover:border-border hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > {option.label} diff --git a/apps/web/src/components/time-range-picker/refresh-controls.tsx b/apps/web/src/components/time-range-picker/refresh-controls.tsx index 7ba88de7a7..c54620a2d6 100644 --- a/apps/web/src/components/time-range-picker/refresh-controls.tsx +++ b/apps/web/src/components/time-range-picker/refresh-controls.tsx @@ -8,9 +8,10 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@maple/ui/components/ui/dropdown-menu" +import { RefreshButton } from "@maple/ui/components/ui/refresh-button" import { cn } from "@maple/ui/lib/utils" -import { ArrowRotateAnticlockwiseIcon, ChevronDownIcon } from "@/components/icons" +import { ChevronDownIcon } from "@/components/icons" import { REFRESH_INTERVAL_OPTIONS } from "@/lib/dashboard-controls/search-params" import { usePageRefreshContext } from "./page-refresh-context" @@ -54,21 +55,16 @@ export function RefreshControls({ return (
    - + /> {formatRefreshInterval(seconds)} {savedDefault === seconds && ( - default + default )} ))} diff --git a/apps/web/src/components/time-range-picker/reload-controls.tsx b/apps/web/src/components/time-range-picker/reload-controls.tsx index 1df5d797f5..5f2156184f 100644 --- a/apps/web/src/components/time-range-picker/reload-controls.tsx +++ b/apps/web/src/components/time-range-picker/reload-controls.tsx @@ -1,17 +1,9 @@ -import { Button } from "@maple/ui/components/ui/button" - -import { ArrowRotateAnticlockwiseIcon } from "@/components/icons" -import { cn } from "@maple/ui/lib/utils" +import { RefreshButton } from "@maple/ui/components/ui/refresh-button" import { usePageRefreshContext } from "./page-refresh-context" export function ReloadControls() { const { isReloading, reload } = usePageRefreshContext() - return ( - - ) + return } diff --git a/apps/web/src/components/time-range-picker/shorthand-input.tsx b/apps/web/src/components/time-range-picker/shorthand-input.tsx index 21ceba1ab1..a38ca19326 100644 --- a/apps/web/src/components/time-range-picker/shorthand-input.tsx +++ b/apps/web/src/components/time-range-picker/shorthand-input.tsx @@ -1,3 +1,4 @@ +import { TONE_TEXT } from "@maple/ui/lib/tone" import { useCallback, useState } from "react" import { relativeToAbsolute } from "@/lib/time-utils" import { Kbd } from "@maple/ui/components/ui/kbd" @@ -41,7 +42,7 @@ export function ShorthandInput({ onApply }: ShorthandInputProps) { error ? "border-destructive/70" : "border-border/70 hover:border-border", )} > - › + ›

    {error ? "Try 5m, 2h, 4d, 1w, 2mo, or today" : "Type a duration and press enter"} diff --git a/apps/web/src/components/time-range-picker/time-range-header-controls.tsx b/apps/web/src/components/time-range-picker/time-range-header-controls.tsx index 6db6f3d4c0..c100491812 100644 --- a/apps/web/src/components/time-range-picker/time-range-header-controls.tsx +++ b/apps/web/src/components/time-range-picker/time-range-header-controls.tsx @@ -5,11 +5,19 @@ import { XmarkIcon } from "@/components/icons" import { TimeRangePicker } from "./time-range-picker" import { ReloadControls } from "./reload-controls" import type { TimeRange } from "./types" +import type { TimeRangeSearch } from "./search" import type { TimePreset } from "@/lib/time-utils" interface TimeRangeHeaderControlsProps { + /** + * The page's URL window. When given, it wins over `startTime`/`endTime` (which then act as + * the effective-window fallback) and the preset derives as "URL preset, else `defaultPreset` + * unless the URL carries an absolute range". + */ + search?: TimeRangeSearch startTime?: string endTime?: string + /** Explicit preset; overrides the one derived from `search`. */ presetValue?: string defaultPreset?: string onTimeChange: (range: TimeRange) => void @@ -17,15 +25,24 @@ interface TimeRangeHeaderControlsProps { maxRangeSeconds?: number } +/** The preset a URL window resolves to: its own, else the page default unless it pins an absolute range. */ +export function resolveSearchPreset(search: TimeRangeSearch, defaultPreset: string): string | undefined { + return search.timePreset ?? (search.startTime ? undefined : defaultPreset) +} + export function TimeRangeHeaderControls({ - startTime, - endTime, - presetValue, + search, + startTime: startTimeProp, + endTime: endTimeProp, + presetValue: presetValueProp, defaultPreset = "12h", onTimeChange, presets, maxRangeSeconds, }: TimeRangeHeaderControlsProps) { + const startTime = search?.startTime ?? startTimeProp + const endTime = search?.endTime ?? endTimeProp + const presetValue = presetValueProp ?? (search ? resolveSearchPreset(search, defaultPreset) : undefined) const hasCustomRange = !presetValue && !!startTime return ( diff --git a/apps/web/src/components/time-range-picker/timezone-select.tsx b/apps/web/src/components/time-range-picker/timezone-select.tsx index 31ffa8c5c7..6b375df6fb 100644 --- a/apps/web/src/components/time-range-picker/timezone-select.tsx +++ b/apps/web/src/components/time-range-picker/timezone-select.tsx @@ -12,6 +12,7 @@ import { } from "@maple/ui/components/ui/combobox" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" import { getBrowserTimeZone, SYSTEM_VALUE } from "@/atoms/timezone-preference-atoms" import { ChevronDownIcon, GlobeIcon } from "@/components/icons" @@ -171,7 +172,7 @@ export function TimezoneSelect() { aria-label="Timezone" className={cn( "-mr-1.5 inline-flex h-6 min-w-0 cursor-pointer items-center gap-2 rounded-md px-1.5", - "text-[11px] tracking-tight outline-none transition-colors", + "text-2xs tracking-tight outline-none transition-colors", "hover:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/24 data-popup-open:bg-muted/60", )} > @@ -181,9 +182,9 @@ export function TimezoneSelect() { {formatUtcOffset(effectiveTimezone)} {selectedTimezone === null && ( - + System - + )} @@ -245,10 +246,10 @@ export function TimezoneSelect() { )} - + {formatClock(zone, now)} - + {formatUtcOffset(zone, now)} diff --git a/apps/web/src/components/traces/span-detail-panel.tsx b/apps/web/src/components/traces/span-detail-panel.tsx index 6cf371eed0..36e5c5fdf0 100644 --- a/apps/web/src/components/traces/span-detail-panel.tsx +++ b/apps/web/src/components/traces/span-detail-panel.tsx @@ -1,10 +1,12 @@ -import { useState, type ReactNode } from "react" +import { useState } from "react" import { Result, useAtomValue } from "@/lib/effect-atom" -import { XmarkIcon, ClockIcon, CircleInfoIcon, SquareTerminalIcon, ServerIcon } from "@/components/icons" +import { XmarkIcon, ClockIcon, CircleInfoIcon, ServerIcon } from "@/components/icons" +import { CONCEPT_ICON } from "@/components/icons/concept" import { ErrorSection } from "@maple/ui/components/error-section" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Badge } from "@maple/ui/components/ui/badge" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@maple/ui/components/ui/tabs" @@ -42,27 +44,6 @@ interface SpanDetailPanelProps { className?: string } -/** - * Label/value row that survives a narrow panel: the label holds its width, the value takes the rest - * and ellipsises. Values are click-to-copy, so a truncated display still yields the full string. - */ -function PlatformRow({ - label, - children, - className, -}: { - label: string - children: ReactNode - className?: string -}) { - return ( -

    - {label} - {children} -
    - ) -} - /** * Where this span sits inside the whole trace: a muted track spanning the * trace's wall-clock time, with the span's own window in its service color. @@ -93,7 +74,7 @@ function SpanPositionBar({ style={{ left: `${offsetPct}%`, width: `${widthPct}%`, backgroundColor: color }} />
    -
    +
    +{formatDuration(Math.max(offsetMs, 0))} {formatDuration(totalDurationMs)} total
    @@ -110,7 +91,7 @@ function LogEntry({ log, timeZone, onClick }: { log: Log; timeZone: string; onCl className="block w-full p-2 text-left hover:bg-muted/30 cursor-pointer" onClick={() => onClick?.(log)} > -
    +
    {formatTimestampInTimezone(log.timestamp, { timeZone })}
    @@ -261,17 +242,17 @@ export function SpanDetailPanel({
    - + {span.serviceName} - {kindLabel} + {kindLabel}
    - +
    @@ -336,20 +317,23 @@ export function SpanDetailPanel({ )}
    {/* Two columns only once the panel is wide enough for them to hold real values. */} -
    + {platform.edge && ( - + {platform.edge} - + )} {platform.location && ( - {platform.location} + + {platform.location} + )} {platform.fields.map((field) => ( - + ))} -
    +
    )} @@ -390,7 +374,7 @@ export function SpanDetailPanel({ Details - Logs + Logs {logCount !== null && logCount > 0 && ( {logCountLabel} @@ -418,24 +402,26 @@ export function SpanDetailPanel({ color={serviceColor} /> )} - - - {formatTimestampInTimezone(span.startTime, { - timeZone: effectiveTimezone, - withMilliseconds: true, - })} - - - - {span.spanId} - - {span.parentSpanId && ( - - - {span.parentSpanId} + + + + {formatTimestampInTimezone(span.startTime, { + timeZone: effectiveTimezone, + withMilliseconds: true, + })} - - )} + + + {span.spanId} + + {span.parentSpanId && ( + + + {span.parentSpanId} + + + )} +
    @@ -460,7 +446,7 @@ export function SpanDetailPanel({ )) .onError(() => ( <> -

    +

    Couldn't load all attributes. Showing the ones loaded with the trace.

    diff --git a/apps/web/src/components/traces/trace-anatomy-strip.tsx b/apps/web/src/components/traces/trace-anatomy-strip.tsx index 69ab9215e7..f34f76853a 100644 --- a/apps/web/src/components/traces/trace-anatomy-strip.tsx +++ b/apps/web/src/components/traces/trace-anatomy-strip.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { ServiceSpectrumBar, computeServiceShares } from "@maple/ui/components/traces/service-spectrum-bar" import { ServiceDot } from "@maple/ui/components/service-dot" -import { countLabel, formatDuration } from "@maple/ui/lib/format" +import { countLabel, formatDuration, formatPercent } from "@maple/ui/lib/format" import { httpStatusTone } from "@maple/ui/lib/http" import { shortId } from "@maple/ui/lib/ids" import { TONE_TEXT } from "@maple/ui/lib/tone" @@ -11,6 +11,7 @@ import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card" import { TraceIdBadge } from "@/components/traces/trace-id-badge" import type { Span } from "@/api/warehouse/traces" import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { StatFigure } from "@/components/common/stat-rail" interface TraceAnatomyStripProps { spans: ReadonlyArray @@ -48,14 +49,11 @@ export function TraceAnatomyStrip({ return (
    -
    - - {formatDuration(totalDurationMs)} - - - {countLabel(spans.length, "span")} · {countLabel(shares.length, "service")} - -
    +
    {share.serviceName} - - {share.percent.toFixed(share.percent < 10 ? 1 : 0)}% + + {formatPercent(share.percent / 100)} ))} diff --git a/apps/web/src/components/traces/trace-peek-sheet.tsx b/apps/web/src/components/traces/trace-peek-sheet.tsx index 4cc4795131..89a79528f2 100644 --- a/apps/web/src/components/traces/trace-peek-sheet.tsx +++ b/apps/web/src/components/traces/trace-peek-sheet.tsx @@ -8,6 +8,7 @@ import { TraceId } from "@maple/domain" import { countLabel } from "@maple/ui/lib/format" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { Kbd } from "@maple/ui/components/ui/kbd" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@maple/ui/components/ui/resizable" import { @@ -182,28 +183,26 @@ export function TracePeekSheet({
    - - + {position ? ( - + {position.index + 1} of {position.count} ) : null} @@ -283,6 +282,7 @@ function TracePeekBody({ .onError((error) => ( <> diff --git a/apps/web/src/components/traces/trace-product-events.tsx b/apps/web/src/components/traces/trace-product-events.tsx index 6c2a32cde2..e54e7a3e88 100644 --- a/apps/web/src/components/traces/trace-product-events.tsx +++ b/apps/web/src/components/traces/trace-product-events.tsx @@ -3,6 +3,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { productEventsForTraceResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { ChartBarTrendUpIcon } from "@/components/icons" import { Badge } from "@maple/ui/components/ui/badge" +import { Panel } from "@maple/ui/components/ui/panel" import type { TraceProductEvent } from "@/api/warehouse/product-events" /** Same margin as `TraceLogsLink`: clock skew can stamp an annotated span just @@ -57,7 +58,7 @@ function LoadedTraceProductEvents({ .onSuccess((response) => { if (response.data.length === 0) return null return ( -
    +

    Product events

    @@ -74,7 +75,7 @@ function LoadedTraceProductEvents({ /> ))} -
    + ) }) .onError(() => null) diff --git a/apps/web/src/components/traces/traces-table.tsx b/apps/web/src/components/traces/traces-table.tsx index 868acfa7cb..ad5693fdce 100644 --- a/apps/web/src/components/traces/traces-table.tsx +++ b/apps/web/src/components/traces/traces-table.tsx @@ -1,5 +1,5 @@ import { refreshingClass } from "@maple/ui/lib/refreshing" -import { formatDuration, pluralize } from "@maple/ui/lib/format" +import { formatDuration, formatNumber, pluralize } from "@maple/ui/lib/format" import { TableSkeleton } from "@maple/ui/components/ui/table-skeleton" import * as React from "react" import { cn } from "@maple/ui/lib/utils" @@ -18,8 +18,9 @@ import { import { useVirtualizer } from "@tanstack/react-virtual" import { Badge } from "@maple/ui/components/ui/badge" -import { LoadingMoreRow, ListFooter } from "@maple/ui/components/ui/list-footer" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { ListFooter } from "@maple/ui/components/ui/list-footer" +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { VirtualTableBody } from "@/components/common/virtual-table-body" import { httpStatusTone } from "@maple/ui/lib/http" import { shortId } from "@maple/ui/lib/ids" import { TONE_SOFT } from "@maple/ui/lib/tone" @@ -307,7 +308,7 @@ function TracesTableView({ * glance); the full timestamp stays available on the tooltip. */} ( - {row.original.spanCount.toLocaleString()} + {formatNumber(row.original.spanCount)} ), }, @@ -391,8 +392,6 @@ function TracesTableView({ overscan: 10, }) - const virtualItems = virtualizer.getVirtualItems() - useVirtualReachEnd(virtualizer, { count: rows.length, hasMore: hasNextPage, @@ -452,7 +451,7 @@ function TracesTableView({ {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( ))} - - {virtualItems.length > 0 && ( - - - )} - {virtualItems.map((virtualRow) => { - const row = rows[virtualRow.index] - return ( - { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - onTraceClick(row.original) - } - }} - > - {row.getAllCells().map((cell) => { - const { responsive, cellClass } = columnClasses(cell.column.id) - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ) - })} - - ) - })} - {virtualItems.length > 0 && ( - ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onTraceClick(row.original) + } }} - aria-hidden="true" > - - )} - {isFetchingNextPage && ( - - - + {row.getAllCells().map((cell) => { + const { responsive, cellClass } = columnClasses(cell.column.id) + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + )} - + />
    -
    - -
    @@ -554,8 +531,7 @@ function TracesTableView({ {hiddenCount > 0 && ( {" · "} - {hiddenCount.toLocaleString()} single-span noise {pluralize(hiddenCount, "trace")}{" "} - hidden{" "} + {formatNumber(hiddenCount)} single-span noise {pluralize(hiddenCount, "trace")} hidden{" "}
    ) } @@ -313,7 +314,7 @@ export function CommitListBody({ commits }: { commits: ReadonlyArray<{ sha: stri
    {/* Sticky so the count stays visible while the rows scroll. */}
    - {commits.length} deploys + {countLabel(commits.length, "deploy")}
    ) @@ -457,7 +458,7 @@ function CopyableSha({ sha }: { sha: string }) { type="button" onClick={() => void copy(sha)} aria-label="Copy commit SHA" - className="group inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80 transition-colors hover:bg-muted/70" + className="group inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 font-mono text-2xs text-foreground/80 transition-colors hover:bg-muted/70" > {shortId(sha, "sha")} {name.slice(0, 2)} diff --git a/apps/web/src/hooks/use-mutation-action.browser.test.tsx b/apps/web/src/hooks/use-mutation-action.browser.test.tsx index aecbc06c6e..a291656317 100644 --- a/apps/web/src/hooks/use-mutation-action.browser.test.tsx +++ b/apps/web/src/hooks/use-mutation-action.browser.test.tsx @@ -1,6 +1,6 @@ import { act, cleanup, renderHook } from "@testing-library/react" import { afterEach, describe, expect, it } from "vitest" -import { useAsyncAction } from "./use-mutation-action" +import { useAsyncAction, useKeyedAsyncAction } from "./use-mutation-action" describe("useAsyncAction", () => { afterEach(cleanup) @@ -27,3 +27,40 @@ describe("useAsyncAction", () => { expect(result.current[1]).toBe(false) }) }) + +describe("useKeyedAsyncAction", () => { + afterEach(cleanup) + + it("tracks pending per key and clears each as it settles", async () => { + const gates = new Map void>() + const { result } = renderHook(() => + useKeyedAsyncAction( + (key: string) => + new Promise((resolve) => { + gates.set(key, () => resolve(key)) + }), + ), + ) + let first: Promise = Promise.resolve("") + act(() => { + first = result.current.run("a") + void result.current.run("b") + }) + expect(result.current.isPending("a")).toBe(true) + expect(result.current.isPending("b")).toBe(true) + expect(result.current.isPending("c")).toBe(false) + + await act(async () => { + gates.get("a")?.() + await first + }) + expect(result.current.isPending("a")).toBe(false) + expect(result.current.isPending("b")).toBe(true) + expect(result.current.anyPending).toBe(true) + + await act(async () => { + gates.get("b")?.() + }) + expect(result.current.anyPending).toBe(false) + }) +}) diff --git a/apps/web/src/hooks/use-mutation-action.ts b/apps/web/src/hooks/use-mutation-action.ts index 1cab86b844..5917f75ac6 100644 --- a/apps/web/src/hooks/use-mutation-action.ts +++ b/apps/web/src/hooks/use-mutation-action.ts @@ -25,6 +25,37 @@ export function useAsyncAction, R>( return [run, pending] as const } +export interface KeyedAction, R> { + readonly run: (key: K, ...args: Args) => Promise + readonly isPending: (key: K) => boolean + /** True while any key is in flight. */ + readonly anyPending: boolean +} + +/** + * `useAsyncAction` for per-row actions: each call carries a key (a row id), and `isPending(key)` + * reports only that row, so one row's spinner does not disable the whole list. + */ +export function useKeyedAsyncAction, R>( + action: (key: K, ...args: Args) => Promise, +): KeyedAction { + const [pendingKeys, setPendingKeys] = useState>(() => new Set()) + const actionRef = useRef(action) + actionRef.current = action + const run = useCallback((key: K, ...args: Args): Promise => { + setPendingKeys((prev) => new Set(prev).add(key)) + return new Promise((resolve) => resolve(actionRef.current(key, ...args))).finally(() => + setPendingKeys((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }), + ) + }, []) + const isPending = useCallback((key: K) => pendingKeys.has(key), [pendingKeys]) + return { run, isPending, anyPending: pendingKeys.size > 0 } +} + interface MutationActionOptions
    { /** Success toast title; a function receives the mutation's value. Omit for no success toast. */ readonly success?: string | ((value: A) => string) @@ -34,18 +65,14 @@ interface MutationActionOptions { readonly onSuccess?: (value: A) => void } -/** - * A `promiseExit` mutation atom with its pending flag and outcome toasts. `run` resolves to the - * Exit, so a caller can still branch on it: `if (Exit.isSuccess(await run(payload))) close()`. - */ -export function useMutationAction( +function useMutationRunner( atom: Atom.Writable, W>, options: MutationActionOptions, -): readonly [run: (value: W) => Promise>, pending: boolean] { +): (value: W) => Promise> { const mutate = useAtomSet(atom, { mode: "promiseExit" }) const optionsRef = useRef(options) optionsRef.current = options - return useAsyncAction((value: W) => + return (value: W) => mutate(value).then((exit) => { const { success, error, onSuccess } = optionsRef.current if (!Exit.isSuccess(exit)) { @@ -55,6 +82,28 @@ export function useMutationAction( toastExit(exit, { error, success: typeof success === "function" ? success(exit.value) : success }) onSuccess?.(exit.value) return exit - }), - ) + }) +} + +/** + * A `promiseExit` mutation atom with its pending flag and outcome toasts. `run` resolves to the + * Exit, so a caller can still branch on it: `if (Exit.isSuccess(await run(payload))) close()`. + */ +export function useMutationAction( + atom: Atom.Writable, W>, + options: MutationActionOptions, +): readonly [run: (value: W) => Promise>, pending: boolean] { + return useAsyncAction(useMutationRunner(atom, options)) +} + +/** + * `useMutationAction` for row actions: `run(rowId, payload)` and `isPending(rowId)`, so a list + * shows the spinner on the row being mutated instead of a shared flag. + */ +export function useKeyedMutationAction( + atom: Atom.Writable, W>, + options: MutationActionOptions, +): KeyedAction> { + const runner = useMutationRunner(atom, options) + return useKeyedAsyncAction((_key: K, value: W) => runner(value)) } diff --git a/apps/web/src/lab/agent-tools-lab.tsx b/apps/web/src/lab/agent-tools-lab.tsx index 747c26bb81..a9be99312e 100644 --- a/apps/web/src/lab/agent-tools-lab.tsx +++ b/apps/web/src/lab/agent-tools-lab.tsx @@ -161,7 +161,7 @@ export function AgentToolsLab() { ) : null} - + {JSON.stringify(search)}
    diff --git a/apps/web/src/lab/bench/tanstack/error-rate-area.tsx b/apps/web/src/lab/bench/tanstack/error-rate-area.tsx index 1197e88f91..1e5290099b 100644 --- a/apps/web/src/lab/bench/tanstack/error-rate-area.tsx +++ b/apps/web/src/lab/bench/tanstack/error-rate-area.tsx @@ -16,13 +16,13 @@ import { usePlotChromeColors, usePlotColors, verticalGradient, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, } from "@maple/ui/components/plot" import { type TanstackRenderer, plotRendererFor } from "@/lab/bench/tanstack/renderer-arm" const ERROR_RATE_TOKENS = { - error: ["--chart-error", "#ef4444"], -} as const satisfies Record + error: "--chart-error", +} as const satisfies Record /** * TanStack port of `packages/ui/src/components/charts/area/error-rate-area-chart.tsx`. diff --git a/apps/web/src/lab/bench/tanstack/latency-line.tsx b/apps/web/src/lab/bench/tanstack/latency-line.tsx index 4945111403..99134d9055 100644 --- a/apps/web/src/lab/bench/tanstack/latency-line.tsx +++ b/apps/web/src/lab/bench/tanstack/latency-line.tsx @@ -13,16 +13,16 @@ import { focusDot, usePlotChromeColors, usePlotColors, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, } from "@maple/ui/components/plot" import { overviewBenchRows, type OverviewBenchRow } from "./bench-data" import { type TanstackRenderer, plotRendererFor } from "@/lab/bench/tanstack/renderer-arm" const LATENCY_TOKENS = { - p99: ["--chart-p99", "#f97316"], - p95: ["--chart-p95", "#eab308"], - p50: ["--chart-p50", "#22c55e"], -} as const satisfies Record + p99: "--chart-p99", + p95: "--chart-p95", + p50: "--chart-p50", +} as const satisfies Record /** * TanStack port of `packages/ui/src/components/charts/line/latency-line-chart.tsx`. diff --git a/apps/web/src/lab/bench/tanstack/throughput-area.tsx b/apps/web/src/lab/bench/tanstack/throughput-area.tsx index b40b9415c7..ed2e590966 100644 --- a/apps/web/src/lab/bench/tanstack/throughput-area.tsx +++ b/apps/web/src/lab/bench/tanstack/throughput-area.tsx @@ -17,7 +17,7 @@ import { usePlotChromeColors, usePlotColors, verticalGradient, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, } from "@maple/ui/components/plot" import { type TanstackRenderer, plotRendererFor } from "@/lab/bench/tanstack/renderer-arm" @@ -27,9 +27,9 @@ interface ThroughputBenchRow extends OverviewBenchRow { } const THROUGHPUT_TOKENS = { - throughput: ["--chart-throughput", "#3b82f6"], - error: ["--chart-error", "#ef4444"], -} as const satisfies Record + throughput: "--chart-throughput", + error: "--chart-error", +} as const satisfies Record /** * TanStack port of `packages/ui/src/components/charts/area/throughput-area-chart.tsx`, diff --git a/apps/web/src/lab/charts/area-spike.tsx b/apps/web/src/lab/charts/area-spike.tsx index 590cb923cb..2345b7df69 100644 --- a/apps/web/src/lab/charts/area-spike.tsx +++ b/apps/web/src/lab/charts/area-spike.tsx @@ -15,7 +15,7 @@ import { usePlotColors, usePlotLegendHighlight, verticalGradient, - type PlotColorToken, + type PlotColorSource, type PlotLegendSeries, type PlotTooltipSeries, } from "@maple/ui/components/plot" @@ -34,9 +34,9 @@ import { } from "@/lab/charts/timeseries-data" const AREA_TOKENS = { - throughput: ["--chart-throughput", "#3b82f6"], - error: ["--chart-error", "#ef4444"], -} as const satisfies Record + throughput: "--chart-throughput", + error: "--chart-error", +} as const satisfies Record /** * The faded fill production uses under a dashed tail diff --git a/apps/web/src/lab/charts/box-plot-spike.tsx b/apps/web/src/lab/charts/box-plot-spike.tsx index 90cef53185..63eb636ee7 100644 --- a/apps/web/src/lab/charts/box-plot-spike.tsx +++ b/apps/web/src/lab/charts/box-plot-spike.tsx @@ -1,4 +1,4 @@ -import { PlotFrame, cursorTooltip, usePlotColors, type PlotColorToken } from "@maple/ui/components/plot" +import { PlotFrame, cursorTooltip, usePlotColors, type PlotColorSource } from "@maple/ui/components/plot" import { formatLatency, formatNumber } from "@maple/ui/lib/format" import { boxY, defineChart } from "@tanstack/charts" import { scaleBand } from "@tanstack/charts-scales/band" @@ -27,8 +27,8 @@ export interface BoxPlotSpikeRow { } const BOX_TOKENS = { - box: ["--chart-1", "#6366f1"], -} as const satisfies Record + box: "--chart-1", +} as const satisfies Record /** Median latency and lognormal sigma per operation — a realistic APM spread. */ const OPERATIONS: readonly { name: string; medianMs: number; sigma: number }[] = [ diff --git a/apps/web/src/lab/charts/heatmap-spike.tsx b/apps/web/src/lab/charts/heatmap-spike.tsx index 813dba5fce..2ec1fb516e 100644 --- a/apps/web/src/lab/charts/heatmap-spike.tsx +++ b/apps/web/src/lab/charts/heatmap-spike.tsx @@ -4,7 +4,7 @@ import { createSequentialColorScale, rampStops, usePlotColors, - type PlotColorToken, + type PlotColorSource, type SequentialScaleType, } from "@maple/ui/components/plot" import { formatNumber } from "@maple/ui/lib/format" @@ -41,12 +41,12 @@ interface HeatmapHole { * literal per render would re-read computed style on every frame. */ const HEATMAP_CHROME_TOKENS = { - foreground: ["--foreground", "#fafafa"], + foreground: "--foreground", // Production's grout (`query-builder-heatmap-chart.tsx`), the recessed // surface a hole is a hole *in*. `tokens.css` defines it for both themes and // it inverts between them, hence the token rather than a literal. grout: ["--heatmap-grout", "oklch(0.175 0.008 62)"], -} as const satisfies Record +} as const satisfies Record /** Preserve first-appearance order — a `Set` over the rows, not a sort. */ function uniqueInOrder(values: readonly string[]): string[] { @@ -437,7 +437,7 @@ export const HeatmapSpike = memo(function HeatmapSpike({ /> ))}
    - No data + No data
    ) @@ -451,7 +451,7 @@ export const HeatmapSpike = memo(function HeatmapSpike({ definition={definition} footer={ model.prunedColumns > 0 || model.prunedRows > 0 ? ( -

    +

    {[ model.prunedColumns > 0 ? pluralize(model.prunedColumns, "empty column") : null, model.prunedRows > 0 ? pluralize(model.prunedRows, "empty row") : null, @@ -491,7 +491,7 @@ export const HeatmapSpike = memo(function HeatmapSpike({ return (

    {label} diff --git a/apps/web/src/lab/charts/histogram-spike.tsx b/apps/web/src/lab/charts/histogram-spike.tsx index 4a2b21258a..344f21d507 100644 --- a/apps/web/src/lab/charts/histogram-spike.tsx +++ b/apps/web/src/lab/charts/histogram-spike.tsx @@ -1,4 +1,4 @@ -import { PlotFrame, cursorTooltip, usePlotColors, type PlotColorToken } from "@maple/ui/components/plot" +import { PlotFrame, cursorTooltip, usePlotColors, type PlotColorSource } from "@maple/ui/components/plot" import { formatNumber } from "@maple/ui/lib/format" import { defineChart, rect } from "@tanstack/charts" import { scaleLinear } from "@tanstack/charts-scales/linear" @@ -20,8 +20,8 @@ export interface HistogramSpikeRow { } const HISTOGRAM_TOKENS = { - bar: ["--chart-1", "#6366f1"], -} as const satisfies Record + bar: "--chart-1", +} as const satisfies Record /** Bin count, matching the production chart's `histogram.bucketCount` default. */ const BIN_COUNT = 30 @@ -192,7 +192,7 @@ export const HistogramSpike = memo(function HistogramSpike({ return (
    - No data + No data
    ) @@ -217,7 +217,7 @@ export const HistogramSpike = memo(function HistogramSpike({
    diff --git a/apps/web/src/lab/charts/line-spike.tsx b/apps/web/src/lab/charts/line-spike.tsx index 6b93c4ca57..db0f684bf8 100644 --- a/apps/web/src/lab/charts/line-spike.tsx +++ b/apps/web/src/lab/charts/line-spike.tsx @@ -13,7 +13,7 @@ import { usePlotChromeColors, usePlotColors, usePlotLegendHighlight, - type PlotColorToken, + type PlotColorSource, type PlotLegendSeries, type PlotTooltipSeries, } from "@maple/ui/components/plot" @@ -31,10 +31,10 @@ import { } from "@/lab/charts/timeseries-data" const LATENCY_TOKENS = { - p99: ["--chart-p99", "#f97316"], - p95: ["--chart-p95", "#eab308"], - p50: ["--chart-p50", "#22c55e"], -} as const satisfies Record + p99: "--chart-p99", + p95: "--chart-p95", + p50: "--chart-p50", +} as const satisfies Record /** * Recharts and TanStack both nominally draw at 2px diff --git a/apps/web/src/lab/charts/pie-spike.tsx b/apps/web/src/lab/charts/pie-spike.tsx index dacd91a1b6..0357f25fb3 100644 --- a/apps/web/src/lab/charts/pie-spike.tsx +++ b/apps/web/src/lab/charts/pie-spike.tsx @@ -6,7 +6,7 @@ import { usePlotChromeColors, usePlotColors, usePlotLegendHighlight, - type PlotColorToken, + type PlotColorSource, type PlotLegendSeries, } from "@maple/ui/components/plot" import { formatNumber } from "@maple/ui/lib/format" @@ -29,12 +29,12 @@ export interface PieSpikeRow { } const SLICE_TOKENS = { - c1: ["--chart-1", "#6366f1"], - c2: ["--chart-2", "#ec4899"], - c3: ["--chart-3", "#f59e0b"], - c4: ["--chart-4", "#10b981"], - c5: ["--chart-5", "#3b82f6"], -} as const satisfies Record + c1: "--chart-1", + c2: "--chart-2", + c3: "--chart-3", + c4: "--chart-4", + c5: "--chart-5", +} as const satisfies Record /** * How much the hovered slice grows, and how far the rest fade — both lifted from @@ -226,7 +226,7 @@ function PieFigure({ return (
    {slice.name} diff --git a/apps/web/src/lab/charts/sankey-spike.tsx b/apps/web/src/lab/charts/sankey-spike.tsx index a075b4bc86..28f2939464 100644 --- a/apps/web/src/lab/charts/sankey-spike.tsx +++ b/apps/web/src/lab/charts/sankey-spike.tsx @@ -5,7 +5,7 @@ import { muteColor, usePlotColors, usePlotLegendHighlight, - type PlotColorToken, + type PlotColorSource, type PlotLegendSeries, } from "@maple/ui/components/plot" import { defineChart, type ChartCurve } from "@tanstack/charts" @@ -42,15 +42,15 @@ interface SankeySpikeNode { } const FLOW_TOKENS = { - node: ["--chart-2", "#ec4899"], - nodeLabel: ["--foreground", "#e6e6e6"], - healthy: ["--chart-1", "#6366f1"], - warning: ["--chart-3", "#f59e0b"], - error: ["--destructive", "#ef4444"], + node: "--chart-2", + nodeLabel: "--foreground", + healthy: "--chart-1", + warning: "--chart-3", + error: "--destructive", // What a muted ribbon mixes toward, resolved in the same call as the rest so // it re-reads on a theme flip. - background: ["--background", "#0c0a09"], -} as const satisfies Record + background: "--background", +} as const satisfies Record /** * Deterministic fixture: 8 services, 14 edges, shaped exactly like the warehouse @@ -363,7 +363,7 @@ function SankeyFigure({ return (
    {datum.data.service} @@ -380,7 +380,7 @@ function SankeyFigure({
    diff --git a/apps/web/src/lab/charts/stacked-bar-spike.tsx b/apps/web/src/lab/charts/stacked-bar-spike.tsx index 81676b90d4..24dc4de27d 100644 --- a/apps/web/src/lab/charts/stacked-bar-spike.tsx +++ b/apps/web/src/lab/charts/stacked-bar-spike.tsx @@ -7,7 +7,7 @@ import { usePlotChromeColors, usePlotColors, usePlotLegendHighlight, - type PlotColorToken, + type PlotColorSource, type PlotLegendSeries, } from "@maple/ui/components/plot" import { formatBucketLabel, formatNumber } from "@maple/ui/lib/format" @@ -30,12 +30,12 @@ import { * the same reason (`treemap-spike.tsx` has to wrap past the fifth). */ const STACK_TOKENS = { - api: ["--chart-1", "#6366f1"], - web: ["--chart-2", "#22c55e"], - ingest: ["--chart-3", "#f59e0b"], - worker: ["--chart-4", "#ec4899"], - db: ["--chart-5", "#06b6d4"], -} as const satisfies Record + api: "--chart-1", + web: "--chart-2", + ingest: "--chart-3", + worker: "--chart-4", + db: "--chart-5", +} as const satisfies Record /** * The partial bucket's outline. Open enough to read as dashes at a bar's width — @@ -48,8 +48,8 @@ const PARTIAL_DASHARRAY = "4 4" /** The outline colour. Module scope — `usePlotColors` memoizes on identity. */ const BAR_CHROME_TOKENS = { - foreground: ["--foreground", "#fafafa"], -} as const satisfies Record + foreground: "--foreground", +} as const satisfies Record /** SVG's "no dashes" — an empty string is not a valid `stroke-dasharray`. */ const SOLID_DASHARRAY = "0" @@ -256,8 +256,8 @@ function StackedBarFigure({ {datum.service} diff --git a/apps/web/src/lab/charts/trace-scatter-spike.tsx b/apps/web/src/lab/charts/trace-scatter-spike.tsx index 14521909c6..fa91112376 100644 --- a/apps/web/src/lab/charts/trace-scatter-spike.tsx +++ b/apps/web/src/lab/charts/trace-scatter-spike.tsx @@ -279,7 +279,7 @@ export const TraceScatterSpike = memo(function TraceScatterSpike({
    diff --git a/apps/web/src/lab/charts/treemap-spike.tsx b/apps/web/src/lab/charts/treemap-spike.tsx index 538fea82cd..e0e881b329 100644 --- a/apps/web/src/lab/charts/treemap-spike.tsx +++ b/apps/web/src/lab/charts/treemap-spike.tsx @@ -6,7 +6,7 @@ import { usePlotChromeColors, usePlotColors, usePlotLegendHighlight, - type PlotColorToken, + type PlotColorSource, type PlotLegendSeries, } from "@maple/ui/components/plot" import { defineChart } from "@tanstack/charts" @@ -32,14 +32,14 @@ export interface TreemapSpikeRow { } const TILE_TOKENS = { - c1: ["--chart-1", "#6366f1"], - c2: ["--chart-2", "#ec4899"], - c3: ["--chart-3", "#f59e0b"], - c4: ["--chart-4", "#10b981"], - c5: ["--chart-5", "#3b82f6"], - label: ["--background", "#0b0b0f"], - tileStroke: ["--background", "#0b0b0f"], -} as const satisfies Record + c1: "--chart-1", + c2: "--chart-2", + c3: "--chart-3", + c4: "--chart-4", + c5: "--chart-5", + label: "--background", + tileStroke: "--background", +} as const satisfies Record /** * Deterministic fixture: 6 services × 3–5 operations, span counts in a realistic @@ -278,7 +278,7 @@ function TreemapFigure({
    {service} diff --git a/apps/web/src/lab/lab-index.tsx b/apps/web/src/lab/lab-index.tsx index f77356ada4..91b258bbca 100644 --- a/apps/web/src/lab/lab-index.tsx +++ b/apps/web/src/lab/lab-index.tsx @@ -56,7 +56,7 @@ export function LabIndex() { {entry.title} - + {entry.path} @@ -64,7 +64,7 @@ export function LabIndex() { {entry.description} {entry.session === "required" && ( - + Session required )} diff --git a/apps/web/src/lab/query-builder-lab.tsx b/apps/web/src/lab/query-builder-lab.tsx index 5951dd4b38..e361fb543b 100644 --- a/apps/web/src/lab/query-builder-lab.tsx +++ b/apps/web/src/lab/query-builder-lab.tsx @@ -239,9 +239,9 @@ function QueryBuilderAtomResults({ input }: { input: QueryBuilderTimeseriesInput Combined result error - query_engine + query_engine
    -

    {displayError(error).message}

    +

    {displayError(error).message}

    )) .onSuccess((response) => { @@ -258,8 +258,8 @@ function QueryBuilderAtomResults({ input }: { input: QueryBuilderTimeseriesInput Combined result success - query_engine - + query_engine + {data.length} buckets
    @@ -267,7 +267,7 @@ function QueryBuilderAtomResults({ input }: { input: QueryBuilderTimeseriesInput {warnings.length > 0 && (
    {warnings.map((warning) => ( -

    +

    - {warning}

    ))} @@ -287,13 +287,13 @@ function QueryBuilderAtomResults({ input }: { input: QueryBuilderTimeseriesInput {data.slice(0, 12).map((point) => ( - + {point.bucket} {seriesKeys.map((key) => ( {point.series[key] ?? 0} @@ -495,13 +495,13 @@ function QueryBuilderLabInner({ startTime, endTime }: QueryBuilderLabProps) {
    - + {startTime} {" -> "} {endTime} {lastRunAt && ( - last run: {lastRunAt} + last run: {lastRunAt} )}
    -

    {noQueriesError}

    +

    {noQueriesError}

    ) : submittedInput ? ( @@ -552,7 +552,7 @@ function QueryBuilderLabInner({ startTime, endTime }: QueryBuilderLabProps) {
    {query.name} @@ -607,7 +607,7 @@ function QueryBuilderLabInner({ startTime, endTime }: QueryBuilderLabProps) { /> @@ -760,7 +760,7 @@ function QueryBuilderLabInner({ startTime, endTime }: QueryBuilderLabProps) { ) if (!hasSlowHints(hints)) return null return ( -

    +

    {slowHintsSummary(hints)}

    ) @@ -980,7 +980,7 @@ function QueryBuilderLabInner({ startTime, endTime }: QueryBuilderLabProps) {
    {formula.name} @@ -1042,7 +1042,7 @@ function QueryBuilderLabInner({ startTime, endTime }: QueryBuilderLabProps) { UI State Preview -
    +						
     							{JSON.stringify(
     								{
     									startTime,
    diff --git a/apps/web/src/lab/service-map-3d/index.tsx b/apps/web/src/lab/service-map-3d/index.tsx
    index e439e09d4d..f6fc2b7426 100644
    --- a/apps/web/src/lab/service-map-3d/index.tsx
    +++ b/apps/web/src/lab/service-map-3d/index.tsx
    @@ -135,7 +135,7 @@ function ServiceInventory({
     						
    ) })} -

    +

    Select a connected service to follow the request path.

    @@ -150,7 +150,7 @@ function ServiceInventory({

    Services & dependencies

    {topology.nodes.length}
    -

    Select a node to inspect its calls.

    +

    Select a node to inspect its calls.

    -
    +
    Sorted by error rate {degraded} degraded
    @@ -178,17 +178,17 @@ function ServiceInventory({ {node.label} - + {node.kind === "service" ? node.namespace : node.kind} - + {formatErrorRate(node.errorRate)} ))}
    -

    +

    Sample health thresholds
    Elevated > 1% · Degraded > 5% errors @@ -213,7 +213,7 @@ export function ServiceMap3DLab() {

    Service map

    - + Voxel lab
    @@ -221,7 +221,7 @@ export function ServiceMap3DLab() { A voxel landscape of services and their connections.

    -
    +
    Sample topology diff --git a/apps/web/src/lab/widget-lab/widget-lab.tsx b/apps/web/src/lab/widget-lab/widget-lab.tsx index ed047a7dc1..58ebedc78c 100644 --- a/apps/web/src/lab/widget-lab/widget-lab.tsx +++ b/apps/web/src/lab/widget-lab/widget-lab.tsx @@ -202,7 +202,7 @@ function CopyScenarios() { // for a glyph. It toasts, and tints the row it copied. label: "CopyableValue (inline)", node: ( - + 7f3c9a21b4e8 diff --git a/apps/web/src/lib/agent-sessions/tool-analytics.test.ts b/apps/web/src/lib/agent-sessions/tool-analytics.test.ts index 53cc5102b7..c6e7dfe38c 100644 --- a/apps/web/src/lib/agent-sessions/tool-analytics.test.ts +++ b/apps/web/src/lib/agent-sessions/tool-analytics.test.ts @@ -177,12 +177,12 @@ describe("toolSeriesColors", () => { it("never hands a series the selection colour", () => { const colors = toolSeriesColors(["a", "b", "c", "d", "e", "f"]) - expect([...colors.values()]).not.toContain("--chart-1") + expect([...colors.values()]).not.toContain("var(--chart-1)") }) it("paints Other grey wherever it lands, without consuming a palette slot", () => { const colors = toolSeriesColors(["a", OTHER_SERIES_KEY, "b"]) - expect(colors.get(OTHER_SERIES_KEY)).toBe("--muted-foreground") + expect(colors.get(OTHER_SERIES_KEY)).toBe("var(--muted-foreground)") expect(colors.get("b")).toBe(TOOL_SERIES_COLOR_TOKENS[1]) }) }) diff --git a/apps/web/src/lib/agent-sessions/tool-analytics.ts b/apps/web/src/lib/agent-sessions/tool-analytics.ts index f422736208..fc3f685dfb 100644 --- a/apps/web/src/lib/agent-sessions/tool-analytics.ts +++ b/apps/web/src/lib/agent-sessions/tool-analytics.ts @@ -8,7 +8,14 @@ // that ever sees a wire shape. import { AI_TOOLS_BREAKDOWN_MAX } from "@maple/domain/http" -import { formatErrorRate, formatLatency, formatNumber, formatPercent } from "@maple/ui/lib/format" +import { + EMPTY_VALUE, + formatErrorRate, + formatLatency, + formatNumber, + formatPercent, +} from "@maple/ui/lib/format" +import { OTHER_COLOR } from "@maple/ui/components/charts/_shared/bucket-series" /** Which measure the strip, the chart and the breakdown columns are reading. */ export const TOOL_METRICS = ["calls", "sessions", "error_rate", "duration"] as const @@ -228,7 +235,7 @@ export function errorRate(measures: Pick): num /** Nanoseconds as a latency string — the warehouse stores ns, `formatLatency` takes ms. */ export function formatDurationNs(ns: number): string { - if (!Number.isFinite(ns) || ns <= 0) return "—" + if (!Number.isFinite(ns) || ns <= 0) return EMPTY_VALUE return formatLatency(ns / 1_000_000) } @@ -278,7 +285,12 @@ const metricRiseIsBad = (metric: ToolMetric): boolean => metric === "error_rate" * wearing it would read as "this series is selected", which is never what it * means. */ -export const TOOL_SERIES_COLOR_TOKENS = ["--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const +export const TOOL_SERIES_COLOR_TOKENS = [ + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +] as const /** * The folded tail. Grey, because it is a residue rather than a thing. @@ -294,10 +306,9 @@ export const OTHER_SERIES_KEY = "other" * selection contract has no spelling for "the unnamed one". */ export const UNATTRIBUTED_LABEL = "Unattributed" export const breakdownKeyLabel = (key: string): string => (key === "" ? UNATTRIBUTED_LABEL : key) -export const OTHER_SERIES_COLOR_TOKEN = "--muted-foreground" /** - * Key → colour token, assigned in the order given and stable for that order. + * Key → colour (a wrapped `var()`), assigned in the order given and stable for that order. * * Callers pass the RANKED key list, so a tool keeps its colour as long as its * rank holds; `Other` is always grey wherever it lands. @@ -307,7 +318,7 @@ export function toolSeriesColors(keys: ReadonlyArray): ReadonlyMap = {} for (const i of incidents) { @@ -846,38 +848,20 @@ export function computeIncidentStats(incidents: AlertIncidentDocument[]) { /* Shared Formatters */ /* -------------------------------------------------------------------------- */ -export function formatAlertDateTime(value: string | null, timeZone?: string): string { +export function formatAlertDateTime(value: string | null, timeZone: string): string { if (!value) return "Never" - return new Date(value).toLocaleString(undefined, { - timeZone, - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }) + return `${formatDateInTimezone(value, { timeZone })}, ${formatTimeInTimezone(value, { timeZone })}` } -export function formatAlertDateTimeFull(value: string | null, timeZone?: string): string { - if (!value) return "—" - return new Date(value).toLocaleString(undefined, { - timeZone, - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) +export function formatAlertDateTimeFull(value: string | null, timeZone: string): string { + if (!value) return EMPTY_VALUE + return formatTimestampInTimezone(value, { timeZone }) } -/** Time of day only (`03:10 PM`) — used where a day header already carries the date. */ -export function formatAlertTime(value: string | null, timeZone?: string): string { - if (!value) return "—" - return new Date(value).toLocaleTimeString(undefined, { - timeZone, - hour: "2-digit", - minute: "2-digit", - }) +/** Time of day only (`03:10 PM`), used where a day header already carries the date. */ +export function formatAlertTime(value: string | null, timeZone: string): string { + if (!value) return EMPTY_VALUE + return formatTimeInTimezone(value, { timeZone }) } /** Midnight of the day holding `ms`, in `timeZone` or the browser's zone. */ @@ -895,7 +879,7 @@ function formatAlertDayHeading(value: string, timeZone: string | undefined): str if (target === today) return "Today" // Any instant inside the previous day, so a 23- or 25-hour day still counts. if (target === startOfDay(today - 1, timeZone)) return "Yesterday" - return date.toLocaleDateString(undefined, { timeZone, month: "short", day: "numeric", year: "numeric" }) + return formatDateInTimezone(date, { timeZone: timeZone ?? getBrowserTimeZone() }) } /* -------------------------------------------------------------------------- */ @@ -955,11 +939,11 @@ export function groupDeliveryEventsByDay( } export function formatAlertDuration(startStr: string | null, endStr: string | null): string { - if (!startStr) return "—" + if (!startStr) return EMPTY_VALUE const start = new Date(startStr).getTime() const end = endStr ? new Date(endStr).getTime() : Date.now() const diffMs = end - start - if (diffMs < 0) return "—" + if (diffMs < 0) return EMPTY_VALUE const mins = Math.floor(diffMs / 60_000) if (mins < 60) return `${mins}m` const hours = Math.floor(mins / 60) diff --git a/apps/web/src/lib/alerts/rule-status.ts b/apps/web/src/lib/alerts/rule-status.ts index ed86a3b721..44d72e022a 100644 --- a/apps/web/src/lib/alerts/rule-status.ts +++ b/apps/web/src/lib/alerts/rule-status.ts @@ -1,5 +1,6 @@ import type { AlertDeliveryEventDocument, AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" import type { AlertRuleStateRow } from "@/lib/collections/alerts" +import { countLabel } from "@maple/ui/lib/format" /** * Derived health of an alert rule, in display-priority order: @@ -101,7 +102,7 @@ export function deriveRuleStatus(input: { return { status: "firing", attention, - reason: openIncidents.length === 1 ? "1 open incident" : `${openIncidents.length} open incidents`, + reason: countLabel(openIncidents.length, "open incident"), } } diff --git a/apps/web/src/lib/billing/cost-estimate.ts b/apps/web/src/lib/billing/cost-estimate.ts index 46188222bd..a9b5835f95 100644 --- a/apps/web/src/lib/billing/cost-estimate.ts +++ b/apps/web/src/lib/billing/cost-estimate.ts @@ -50,7 +50,7 @@ const featureUnit = (featureId: string): string => COUNT_UNITS[featureId] ?? "GB // Sub-cent rates (e.g. $0.003/session) would round to "$0.00" through the // 2-decimal currency formatter, so render those raw. -const formatRate = (rate: number): string => +const formatUnitPrice = (rate: number): string => rate > 0 && rate < 0.01 ? `$${rate}` : formatCurrency(rate, "usd") const formatQuantity = (featureId: string, value: number): string => { @@ -146,8 +146,8 @@ export function estimateCycleCost({ const unit = featureUnit(featureId) const rateLabel = billingUnits > 1 - ? `${formatRate(rate)} / ${billingUnits.toLocaleString("en-US")} ${unit}s` - : `${formatRate(rate)} / ${unit}` + ? `${formatUnitPrice(rate)} / ${formatCount(billingUnits)} ${unit}s` + : `${formatUnitPrice(rate)} / ${unit}` lines.push({ key: `overage:${featureId}`, label: `${FEATURE_LABELS[featureId] ?? featureId} overage`, diff --git a/apps/web/src/lib/billing/spend.ts b/apps/web/src/lib/billing/spend.ts index e44d65d85d..d0ee0bd1c5 100644 --- a/apps/web/src/lib/billing/spend.ts +++ b/apps/web/src/lib/billing/spend.ts @@ -9,6 +9,7 @@ import { type FeatureUsagePricing, type PlanLike, } from "@maple/domain/billing" +import { formatCount } from "./usage" /** * The billing page's spend model: one place that turns the Autumn snapshot plus @@ -90,7 +91,7 @@ export const formatRateLabel = (feature: FeatureSpend): string | null => { if (unit === undefined) return `$${feature.ratePerUnit.toFixed(2)}/GB` if (feature.billingUnits > 1) { const perBlock = feature.ratePerUnit * feature.billingUnits - return `$${Number(perBlock.toPrecision(6))}/${feature.billingUnits.toLocaleString("en-US")} ${unit}s` + return `$${Number(perBlock.toPrecision(6))}/${formatCount(feature.billingUnits)} ${unit}s` } return `$${feature.ratePerUnit}/${unit}` } diff --git a/apps/web/src/lib/billing/usage.ts b/apps/web/src/lib/billing/usage.ts index a1995f639f..4597bafad9 100644 --- a/apps/web/src/lib/billing/usage.ts +++ b/apps/web/src/lib/billing/usage.ts @@ -4,7 +4,10 @@ export function formatUsage(gb: number): string { return `${gb.toFixed(2)} GB` } -/** Format a raw count for display: "0", "1,234", "1,200,000". */ +/** + * Format a raw count for display: "0", "1,234", "1,200,000". Exact, unlike `formatNumber` + * (which compacts to "1.2M"): billed quantities must read to the unit. + */ export function formatCount(value: number): string { return Math.max(0, Math.round(value)).toLocaleString("en-US") } diff --git a/apps/web/src/lib/timezone-format.browser.test.ts b/apps/web/src/lib/timezone-format.browser.test.ts index 3c6bea3f6a..514ca87ccb 100644 --- a/apps/web/src/lib/timezone-format.browser.test.ts +++ b/apps/web/src/lib/timezone-format.browser.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { EMPTY_VALUE } from "@maple/ui/lib/format" import { formatTimeInTimezone, formatTimestampInTimezone } from "./timezone-format" describe("timezone-format", () => { @@ -54,7 +55,7 @@ describe("timezone-format", () => { }) it("returns '-' for invalid values", () => { - expect(formatTimestampInTimezone("not-a-date", { timeZone: "UTC" })).toBe("-") - expect(formatTimeInTimezone("not-a-date", { timeZone: "UTC" })).toBe("-") + expect(formatTimestampInTimezone("not-a-date", { timeZone: "UTC" })).toBe(EMPTY_VALUE) + expect(formatTimeInTimezone("not-a-date", { timeZone: "UTC" })).toBe(EMPTY_VALUE) }) }) diff --git a/apps/web/src/lib/timezone-format.ts b/apps/web/src/lib/timezone-format.ts index a5dbbef2f6..d69881e08c 100644 --- a/apps/web/src/lib/timezone-format.ts +++ b/apps/web/src/lib/timezone-format.ts @@ -1,4 +1,5 @@ import { warehouseDateTimeToIso } from "@maple/query-engine" +import { EMPTY_VALUE } from "@maple/ui/lib/format" import { getBrowserTimeZone, isValidIanaTimeZone } from "@/atoms/timezone-preference-atoms" type TimezoneFormatInput = string | number | Date @@ -32,7 +33,7 @@ export function formatTimestampInTimezone( options: { timeZone: string; withMilliseconds?: boolean; withYear?: boolean; style?: "full" | "range" }, ): string { const date = toValidDate(input) - if (!date) return "-" + if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) const style = options.style ?? "full" @@ -74,7 +75,7 @@ export function formatTimeInTimezone( options: { timeZone: string; withSeconds?: boolean }, ): string { const date = toValidDate(input) - if (!date) return "-" + if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) const key = `${tz}|${options.withSeconds ? "s" : ""}` @@ -98,7 +99,7 @@ const clockFormatters = new Map() * `formatCompactTimeInTimezone` adds is noise down a thousand rows. */ export function formatClockInTimezone(input: TimezoneFormatInput, options: { timeZone: string }): string { const date = toValidDate(input) - if (!date) return "-" + if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) let formatter = clockFormatters.get(tz) @@ -121,7 +122,7 @@ export function formatCompactTimeInTimezone( options: { timeZone: string }, ): string { const date = toValidDate(input) - if (!date) return "-" + if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) let formatter = compactTimeFormatters.get(tz) @@ -139,3 +140,30 @@ export function formatCompactTimeInTimezone( return formatter.format(date) } + +const dateFormatters = new Map() + +/** `Oct 7, 2026`: a calendar date (created, joined, billed) in the user's timezone. */ +export function formatDateInTimezone( + input: TimezoneFormatInput, + options: { timeZone: string; withYear?: boolean }, +): string { + const date = toValidDate(input) + if (!date) return EMPTY_VALUE + + const tz = resolveTimeZone(options.timeZone) + const withYear = options.withYear ?? true + const key = `${tz}|${withYear ? "y" : ""}` + let formatter = dateFormatters.get(key) + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: withYear ? "numeric" : undefined, + month: "short", + day: "numeric", + }) + dateFormatters.set(key, formatter) + } + + return formatter.format(date) +} diff --git a/apps/web/src/routes/account.tsx b/apps/web/src/routes/account.tsx index 4de644174f..1814351baa 100644 --- a/apps/web/src/routes/account.tsx +++ b/apps/web/src/routes/account.tsx @@ -1,7 +1,7 @@ import { Navigate, useNavigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { AccountNav, @@ -44,26 +44,17 @@ function AccountPage() { } return ( - - - - - - - - - {activeTab === "profile" && } - {activeTab === "emails" && } - {activeTab === "password" && } - {activeTab === "two-factor" && } - {activeTab === "passkeys" && } - {activeTab === "connections" && } - {activeTab === "sessions" && } - - - - + } + > + {activeTab === "profile" && } + {activeTab === "emails" && } + {activeTab === "password" && } + {activeTab === "two-factor" && } + {activeTab === "passkeys" && } + {activeTab === "connections" && } + {activeTab === "sessions" && } + ) } diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx index b4020d3bc2..20f788641a 100644 --- a/apps/web/src/routes/agent-sessions/$sessionId.tsx +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, type ReactNode } from "react" +import { useCallback, useEffect, useMemo } from "react" import { createFileRoute, Link, useNavigate, useRouterState } from "@tanstack/react-router" import { Schema } from "effect" @@ -10,7 +10,9 @@ import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { SquareSparkleIcon } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import type { BreadcrumbEntry } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { DetailHeaderSkeleton } from "@/components/common/detail-header" import { ErrorState } from "@/components/common/error-state" import { SessionHeader } from "@/components/agent-sessions/session-detail/session-header" import { SessionLoadIndicator } from "@/components/agent-sessions/session-detail/session-load-indicator" @@ -80,67 +82,59 @@ function AgentSessionDetailPage() { : disabledResultAtom(), ) + const searchStr = useRouterState({ select: (state) => state.location.searchStr }) + const breadcrumbs = [ + { label: "Agent Sessions", href: buildBackToSessionsHref(searchStr) }, + { label: breadcrumbSessionId(sessionId) }, + ] + return Result.builder(spansState.firstPage) .onInitial(() => ( - - - - - - - {/* The Overview's own shape — switcher, verdict, vitals, time bar — - so the page doesn't reflow on resolve. */} -
    - -
    -
    - - -
    -
    - {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
    -
    - - + } fill> + {/* The Overview's own shape — switcher, verdict, vitals, time bar — + so the page doesn't reflow on resolve. */} +
    + +
    +
    + +
    - - - +
    + {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
    +
    + + +
    +
    )) .onError((error) => ( - - - - - - - + + + )) .onSuccess((value) => value.data.length === 0 ? ( - - - - - - - + + + ) : ( @@ -151,10 +145,12 @@ function AgentSessionDetailPage() { function SessionDetailBody({ sessionId, + breadcrumbs, spansState, totals, }: { sessionId: string + breadcrumbs: ReadonlyArray spansState: SessionSpansState totals: GetAiSessionSummaryResponse | undefined }) { @@ -225,70 +221,45 @@ function SessionDetailBody({ ) return ( - - - - } - > - {/* The one sign a session larger than a page is still arriving. - Every view renders what is in hand and grows as pages land; - nothing below asks the reader to fetch anything. */} - {progress !== undefined && progress.phase !== "complete" && ( - - )} - - - {/* `py-0` (the content blocks carry the padding instead) so the views' - sticky elements pin flush to the scroller's edges — sticky offsets - resolve against the padding edge. The top edge is the control bar; - the bottom is the Flow view's floor, whose legend and zoom otherwise - float a padding's height short of the viewport with the canvas - scrolling visibly beneath them. `pr-6` keeps the overlay - scrollbar off the right-aligned duration/cost columns, and - `overflow-x-hidden` means a span that escapes its truncation can - never make the whole page scroll sideways. */} - - {/* Content-driven height inside the scroller: `shrink-0` because a - scroll container's flex items shrink to fit before they overflow, - which would collapse the views instead of scrolling them; `grow` - (basis auto, not `flex-1`'s basis 0) so short content still fills - the viewport; the floor keeps the empty states from a sliver. */} -
    - -
    -
    -
    - {/* No side panel at any width: span detail opens as a popover against - the row, node or finding the reader clicked. */} -
    - ) -} - -/** Root, breadcrumbs, and the body row every branch fills with a `Content` column. */ -function SessionShell({ sessionId, children }: { sessionId: string; children: ReactNode }) { - const searchStr = useRouterState({ select: (state) => state.location.searchStr }) - - return ( - - - {children} - + // No side panel at any width: span detail opens as a popover against the row, + // node or finding the reader clicked. + } + headerActions={ + // The one sign a session larger than a page is still arriving. Every view + // renders what is in hand and grows as pages land; nothing below asks the + // reader to fetch anything. + progress !== undefined && progress.phase !== "complete" ? ( + + ) : null + } + // `py-0` (the content blocks carry the padding instead) so the views' sticky + // elements pin flush to the scroller's edges: sticky offsets resolve against the + // padding edge. `pr-6` keeps the overlay scrollbar off the right-aligned + // duration/cost columns, and `overflow-x-hidden` stops an escaped span from + // scrolling the whole page sideways. + scrollClassName="overflow-x-hidden py-0 pr-6" + > + {/* Content-driven height inside the scroller: `shrink-0` because a + scroll container's flex items shrink to fit before they overflow, + which would collapse the views instead of scrolling them; `grow` + (basis auto, not `flex-1`'s basis 0) so short content still fills + the viewport; the floor keeps the empty states from a sliver. */} +
    + +
    +
    ) } diff --git a/apps/web/src/routes/agent-sessions/-index.browser.test.tsx b/apps/web/src/routes/agent-sessions/-index.browser.test.tsx index 29ea543f62..c89b23364d 100644 --- a/apps/web/src/routes/agent-sessions/-index.browser.test.tsx +++ b/apps/web/src/routes/agent-sessions/-index.browser.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/components/layout/dashboard-layout", () => { Filters: passthrough, Content: passthrough, Sticky: passthrough, + Tabs: passthrough, Scroll: passthrough, }, } diff --git a/apps/web/src/routes/agent-sessions/index.tsx b/apps/web/src/routes/agent-sessions/index.tsx index 0b915104a2..649c52c9a0 100644 --- a/apps/web/src/routes/agent-sessions/index.tsx +++ b/apps/web/src/routes/agent-sessions/index.tsx @@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { AiSessionSortDir, AiSessionSortKey } from "@maple/domain/http" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { AgentSessionsList, AgentSessionsListSkeleton } from "@/components/agent-sessions/agent-sessions-list" import { AgentSessionsFilterSidebar } from "@/components/agent-sessions/agent-sessions-filter-sidebar" import { AgentSessionsToolbar } from "@/components/agent-sessions/agent-sessions-toolbar" @@ -78,17 +78,12 @@ export const Route = createFileRoute("/agent-sessions/")({ function AgentSessionsPage() { return ( - - - - - - + ) } -/** The `Filters | Content` siblings, so both share one resolved window. */ +/** The filter rail and the list, so both share one resolved window. */ function AgentSessionsBody() { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) @@ -178,53 +173,41 @@ function AgentSessionsBody() { ) return ( - <> - + - - - {/* `pb-3` over an unpadded scroll area: the layout's `p-4` on both - stacked 32px between the toolbar and the table. */} - - {/* The Tools tab reads the same spans across every session; this - page reads one session at a time. Two routes, one strip — with - the Tools page's hairline and 12px gap, so the strip and the - toolbar sit at the same height on both. */} -
    - - {toolbar} -
    -
    - - {Result.builder(firstPageResult) - .onInitial(() => ) - .onError((error) => ( - - )) - .onSuccess(() => ( - - )) - .render()} - -
    - + } + // The Tools tab reads the same spans across every session; this page reads one + // session at a time. Two routes, one strip, laid out identically on both. + tabs={ + + } + sticky={toolbar} + // The sticky area's own bottom padding already separates the toolbar from the table. + scrollClassName="pt-0" + > + {Result.builder(firstPageResult) + .onInitial(() => ) + .onError((error) => ) + .onSuccess(() => ( + + )) + .render()} + ) } diff --git a/apps/web/src/routes/agent-sessions/tools/$toolName.tsx b/apps/web/src/routes/agent-sessions/tools/$toolName.tsx index f7069208c7..d16d76af1e 100644 --- a/apps/web/src/routes/agent-sessions/tools/$toolName.tsx +++ b/apps/web/src/routes/agent-sessions/tools/$toolName.tsx @@ -14,7 +14,7 @@ import { } from "@/components/agent-sessions/tools/tool-error-modal" import { prepareToolErrors, type ToolErrorsWindow } from "@/components/agent-sessions/tools/tool-errors-table" import { ResultView } from "@/components/common/result-view" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" @@ -75,42 +75,35 @@ function ToolDetailPage() { return ( - - + { + navigate({ + search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), + }) + }} + /> + } /> - - - - { - navigate({ - search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), - }) - }} - /> - } - /> - - - - + ) } diff --git a/apps/web/src/routes/agent-sessions/tools/index.tsx b/apps/web/src/routes/agent-sessions/tools/index.tsx index 192e19f448..e2fbd94c9e 100644 --- a/apps/web/src/routes/agent-sessions/tools/index.tsx +++ b/apps/web/src/routes/agent-sessions/tools/index.tsx @@ -9,7 +9,7 @@ import { AgentSessionsTabs } from "@/components/agent-sessions/tools/agent-sessi import { AgentToolsView } from "@/components/agent-sessions/tools/agent-tools-view" import { ToolMetricStripLoading } from "@/components/agent-sessions/tools/tool-metric-strip" import { ErrorState } from "@/components/common/error-state" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import { TimeRangeSearchFields, @@ -66,34 +66,27 @@ function AgentToolsPage() { return ( - - + + } /> - - - - - } - /> - - - - + ) } diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index 9609ce5013..f08e4d11dc 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -5,7 +5,10 @@ import { Exit, Schema } from "effect" import { Fragment, useCallback, useMemo, useRef, useState } from "react" import { toastManager } from "@maple/ui/components/ui/toast" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { ResultPage } from "@/components/layout/result-page" +import { DetailHeader } from "@/components/common/detail-header" +import { SectionHeading } from "@/components/common/section-heading" +import { Panel } from "@maple/ui/components/ui/panel" import { DocsLink, EmptyActions } from "@/components/common/docs-link" import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { useAlertRuleChecks } from "@/hooks/use-alert-rule-checks" @@ -21,7 +24,7 @@ import { AlertRuleChart } from "@/components/alerts/alert-rule-chart" import { SIGNAL_SOURCE_LABEL, type SignalSource } from "@/lib/alerts/chart-series" import { AlertStatusBadge } from "@/components/alerts/alert-status-badge" import { AlertSeverityBadge } from "@/components/alerts/alert-severity-badge" -import { AlertStatStrip } from "@/components/alerts/alert-stat-card" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { SegmentedSelect } from "@/components/common/segmented-select" import { ErrorState } from "@/components/common/error-state" import { ResultView } from "@/components/common/result-view" @@ -37,7 +40,7 @@ import { v2CheckToDocument, v2DeliveryToDocument, } from "@/lib/alerts/form-utils" -import { getExitErrorMessage } from "@/lib/error-toast" +import { toastExit } from "@/lib/error-toast" import { RuleDiagnosisPanel } from "@/components/alerts/rule-detail/rule-diagnosis-panel" import { useAlertRuleStates } from "@/hooks/use-alert-rule-states" import { @@ -49,7 +52,7 @@ import { type AlertRuleDocument, } from "@maple/domain/http" import { useAlertDestinationsList, useAlertIncidentsList, useAlertRulesList } from "@/hooks/use-alerts-list" -import { CheckIcon, PencilIcon, DotsVerticalIcon, ChatBubbleSparkleIcon } from "@/components/icons" +import { CheckIcon, PencilIcon, ChatBubbleSparkleIcon } from "@/components/icons" import { cn } from "@maple/ui/lib/utils" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" @@ -61,19 +64,20 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@m import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@maple/ui/components/ui/dropdown-menu" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { EMPTY_VALUE, countLabel } from "@maple/ui/lib/format" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { useAlertRulePreview } from "@/hooks/use-alert-rule-preview" +import { useAsyncAction } from "@/hooks/use-mutation-action" import { tokenizeSql } from "@/lib/sql-highlight" import { formatSql } from "@/lib/sql-format" import { runMapleApiV2 } from "@/lib/collections/api-runner" const tabValues = ["overview", "history"] as const type RuleDetailTab = (typeof tabValues)[number] +const isRuleDetailTab = (value: unknown): value is RuleDetailTab => tabValues.some((tab) => tab === value) /** Names what each chart source actually is, so the toggle isn't a guess. */ const SIGNAL_SOURCE_DESCRIPTION: Record = { @@ -286,9 +290,7 @@ function RuleDetailContent() { [ruleStates, ruleIncidents, checks, ruleDeliveryEvents], ) - const activeTab: RuleDetailTab = (tabValues as readonly string[]).includes(search.tab ?? "") - ? (search.tab as RuleDetailTab) - : "overview" + const activeTab: RuleDetailTab = isRuleDetailTab(search.tab) ? search.tab : "overview" const [stateFilter, setStateFilter] = useState<"all" | "open" | "resolved">("all") const [checkStatusFilter, setCheckStatusFilter] = useState("all") @@ -326,27 +328,32 @@ function RuleDetailContent() { setSelectedBucket({ index, ...bucket }) setBucketChecks(NO_CHECKS) setBucketLoading(true) - void (async () => { - try { - const page = await runMapleApiV2((client) => - client.alertRules.checks({ - params: { id: ruleId }, - query: { - since: IsoDateTimeString.make(new Date(bucket.start).toISOString()), - until: IsoDateTimeString.make(new Date(bucket.end).toISOString()), - limit: 100, - }, - }), - ) - if (bucketRequest.current !== token) return - setBucketChecks(page.data.map(v2CheckToDocument)) - } catch { - if (bucketRequest.current !== token) return - toastManager.add({ title: "Checks for that bucket could not be loaded", type: "error" }) - } finally { + void runMapleApiV2((client) => + client.alertRules.checks({ + params: { id: ruleId }, + query: { + since: IsoDateTimeString.make(new Date(bucket.start).toISOString()), + until: IsoDateTimeString.make(new Date(bucket.end).toISOString()), + limit: 100, + }, + }), + ) + .then( + (page) => { + if (bucketRequest.current !== token) return + setBucketChecks(page.data.map(v2CheckToDocument)) + }, + () => { + if (bucketRequest.current !== token) return + toastManager.add({ + title: "Checks for that bucket could not be loaded", + type: "error", + }) + }, + ) + .finally(() => { if (bucketRequest.current === token) setBucketLoading(false) - } - })() + }) }, [clearBucket, ruleId], ) @@ -401,10 +408,7 @@ function RuleDetailContent() { await navigate({ to: "/investigations/$id", params: { id: result.value.id } }) return } - toastManager.add({ - title: getExitErrorMessage(result, "Failed to open investigation"), - type: "error", - }) + toastExit(result, { error: "Failed to open investigation" }) } const stats = useMemo(() => computeIncidentStats(ruleIncidents), [ruleIncidents]) @@ -450,76 +454,6 @@ function RuleDetailContent() { checksPage != null ? ` · ${formatBucketSpan(checksPage.summary.bucketSeconds)} each` : "" }` - if (Result.isInitial(rulesResult)) { - return ( - - - - - - {/* Mirror the settled Overview rhythm so the first paint doesn't snap. */} -
    - -
    - - -
    - - -
    -
    -
    -
    -
    - ) - } - - if (Result.isFailure(rulesResult)) { - return ( - - - - - - refreshRules()} - className="m-6" - /> - - - - - ) - } - - if (!rule) { - return ( - - - - - - } - backLabel="Back to rules" - /> - - - - - ) - } - async function handleToggleEnabled() { if (!rule) return const result = await updateRule({ @@ -527,633 +461,638 @@ function RuleDetailContent() { payload: { enabled: !rule.enabled }, reactivityKeys: ["alertRules"], }) - if (!Exit.isSuccess(result)) { - toastManager.add({ title: getExitErrorMessage(result, "Failed to update rule"), type: "error" }) - } else { - refreshRules() - } + if (toastExit(result, { error: "Failed to update rule" })) refreshRules() } const isFiring = openRuleIncidents.length > 0 - const subtitle = `${signalLabels[rule.signalType]} ${comparatorLabels[rule.comparator]} ${formatSignalValue(rule.signalType, rule.threshold)} over ${rule.windowMinutes}min${rule.serviceNames?.length > 0 ? ` on ${rule.serviceNames.join(", ")}` : ""}${rule.environments?.length > 0 ? ` in ${rule.environments.join(", ")}` : ""}${rule.excludeServiceNames?.length > 0 ? ` (excl. ${rule.excludeServiceNames.join(", ")})` : ""}` // No incident strip here: incidents are painted as bands on the chart and as // the rail's own incident lane, so the header strip was a third lookalike row // telling a story the chart already tells in place. - const stickyContent = ( -
    - navigate({ search: (prev) => ({ ...prev, tab: v as RuleDetailTab }) })} - > - - Overview - History - - -
    - ) - return ( - - - - - - -
    - {rule.name} - - {isFiring ? ( - i.holdReason != null) - ? "held" - : "firing" - } - /> - ) : rule.enabled ? ( - - ) : ( - - )} -
    -

    {subtitle}

    -
    - } - > -
    - - navigate({ search: (prev) => applyTimeRangeSearch(prev, range) }) + rule} + crumb={(rule) => rule.name} + errorTitle="Failed to load alert rule" + onRetry={() => refreshRules()} + loading={ + // Mirror the settled Overview rhythm so the first paint doesn't snap. +
    + +
    + + +
    + + +
    + } + notFound={ + } + backLabel="Back to rules" + /> + } + header={(rule) => ( + + + {isFiring ? ( + i.holdReason != null) + ? "held" + : "firing" } /> - -
    - - {stickyContent} - - - {activeTab === "overview" && ( -
    - void handleToggleEnabled()} - /> -
    -
    -
    - - {signalLabels[rule.signalType]}: {rangeLabel} - -

    - {SIGNAL_SOURCE_DESCRIPTION[signalSource]} -

    -
    - - options={[ - { - value: "preview", - label: SIGNAL_SOURCE_LABEL.preview, - disabled: !previewAvailable, - }, - { - value: "checks", - label: SIGNAL_SOURCE_LABEL.checks, - disabled: !checksAvailable, - }, - ]} - value={signalSource} - onChange={setSignalSource} - size="sm" - aria-label="Chart data source" + ) : rule.enabled ? ( + + ) : ( + + )} + {ruleSubtitle(rule)} + + } + actions={ + <> + + navigate({ search: (prev) => applyTimeRangeSearch(prev, range) }) + } + /> + + + } + /> + )} + tabs={() => ( + { + if (isRuleDetailTab(v)) navigate({ search: (prev) => ({ ...prev, tab: v }) }) + }} + > + + Overview + History + + + )} + > + {(rule) => ( + <> + {activeTab === "overview" && ( +
    + void handleToggleEnabled()} + /> +
    +
    +
    + +

    + {SIGNAL_SOURCE_DESCRIPTION[signalSource]} +

    - + options={[ + { + value: "preview", + label: SIGNAL_SOURCE_LABEL.preview, + disabled: !previewAvailable, + }, + { + value: "checks", + label: SIGNAL_SOURCE_LABEL.checks, + disabled: !checksAvailable, + }, + ]} + value={signalSource} + onChange={setSignalSource} + size="sm" + aria-label="Chart data source" />
    + +
    - {/* Reserve the slot while incidents sync so the card doesn't pop in - and shove Configuration + Checks down once it resolves. */} - {Result.isInitial(incidentsResult) ? ( - - ) : overviewIncident ? ( -
    -
    - - Investigation - -

    - Review the latest incident in a durable evidence workspace. -

    -
    - + {/* Reserve the slot while incidents sync so the card doesn't pop in + and shove Configuration + Checks down once it resolves. */} + {Result.isInitial(incidentsResult) ? ( + + ) : overviewIncident ? ( + +
    + + Investigation + +

    + Review the latest incident in a durable evidence workspace. +

    - ) : null} + +
    + ) : null} -
    -

    Configuration

    - - - - {rule.notes && ( -
    -
    Notes
    -
    - {rule.notes} -
    +
    +

    Configuration

    + + + + {rule.notes && ( +
    +
    Notes
    +
    + {rule.notes} +
    +
    + )} + + + {signalLabels[rule.signalType]} + + + +
    + {rule.serviceNames?.length > 0 ? ( + rule.serviceNames.map((s) => ( + + {s} + + )) + ) : ( + + {rule.groupBy && rule.groupBy.length > 0 + ? `all (per ${rule.groupBy.join(" \u00b7 ")})` + : "all"} + + )} +
    +
    + +
    + {rule.environments?.length > 0 ? ( + rule.environments.map((env) => ( + + {env} + + )) + ) : ( + all + )} +
    +
    + {rule.excludeServiceNames?.length > 0 && ( + +
    + {rule.excludeServiceNames.map((s) => ( + + {s} + + ))}
    - )} - - - {signalLabels[rule.signalType]} - - -
    - {rule.serviceNames?.length > 0 ? ( - rule.serviceNames.map((s) => ( - - {s} - - )) - ) : ( + )} + + + {comparatorLabels[rule.comparator]}{" "} + {formatSignalValue(rule.signalType, rule.threshold)} /{" "} + {rule.windowMinutes}min + + + + + + + + {rule.consecutiveBreachesRequired} + + + + + {rule.consecutiveHealthyRequired} + + + + + {rule.minimumSampleCount} + + + + + {rule.renotifyIntervalMinutes}min + + + {rule.signalType === "builder_query" && + rule.queryBuilderDraft && ( + <> + + + {rule.queryBuilderDraft.dataSource} + + + - {rule.groupBy && rule.groupBy.length > 0 - ? `all (per ${rule.groupBy.join(" \u00b7 ")})` - : "all"} + {rule.queryBuilderDraft.aggregation} - )} -
    -
    - -
    - {rule.environments?.length > 0 ? ( - rule.environments.map((env) => ( - - {env} - - )) - ) : ( - all - )} -
    -
    - {rule.excludeServiceNames?.length > 0 && ( - -
    - {rule.excludeServiceNames.map((s) => ( - - {s} - - ))} -
    -
    - )} - - - {comparatorLabels[rule.comparator]}{" "} - {formatSignalValue(rule.signalType, rule.threshold)} /{" "} - {rule.windowMinutes}min - - - - - - - - {rule.consecutiveBreachesRequired} - - - - - {rule.consecutiveHealthyRequired} - - - - - {rule.minimumSampleCount} - - - - - {rule.renotifyIntervalMinutes}min - - - {rule.signalType === "builder_query" && - rule.queryBuilderDraft && ( - <> - - - {rule.queryBuilderDraft.dataSource} - - - - - {rule.queryBuilderDraft.aggregation} + + {rule.queryBuilderDraft.whereClause && ( + + + {rule.queryBuilderDraft.whereClause} - {rule.queryBuilderDraft.whereClause && ( - - - {rule.queryBuilderDraft.whereClause} - - - )} - - )} - {rule.signalType === "raw_query" && rule.rawQuerySql && ( -
    -
    Raw SQL
    -
    -
    -																
    -																	{tokenizeSql(
    -																		formatSql(rule.rawQuerySql),
    -																	).map((token) => (
    +														)}
    +													
    +												)}
    +											{rule.signalType === "raw_query" && rule.rawQuerySql && (
    +												
    +
    Raw SQL
    +
    +
    +															
    +																{tokenizeSql(formatSql(rule.rawQuerySql)).map(
    +																	(token) => (
     																		
     																			{token.text}
     																		
    -																	))}
    -																
    -															
    -
    -
    - )} - - - {rule.destinationIds.length} configured - - - - - - - - -
    - - {Result.builder(checksResult) - .onError((error) => ( -
    -

    Checks

    - refreshChecks()} - /> -
    - )) - .orElse(() => ( - - ))} + ), + )} +
    + + +
    + )} + + + {rule.destinationIds.length} configured + + + + + + + +
    - )} - {activeTab === "history" && ( - (
    - - +

    Checks

    + refreshChecks()} + />
    - } - errorTitle="Failed to load incidents" - onRetry={() => refreshIncidents()} - > - {() => ( -
    -
    -
    -

    History

    -

    - {stats.totalTriggered} total triggers -

    -
    - - options={[ - { value: "all", label: "All" }, - { value: "open", label: "Fired" }, - { value: "resolved", label: "Resolved" }, - ]} - value={stateFilter} - onChange={setStateFilter} - size="sm" - aria-label="Filter incidents" - /> -
    + )) + .orElse(() => ( + + ))} +
    + )} - + + +
    + } + errorTitle="Failed to load incidents" + onRetry={() => refreshIncidents()} + > + {() => ( +
    +
    +
    +

    History

    +

    + {stats.totalTriggered} total triggers +

    +
    + + options={[ + { value: "all", label: "All" }, + { value: "open", label: "Fired" }, + { value: "resolved", label: "Resolved" }, ]} + value={stateFilter} + onChange={setStateFilter} + size="sm" + aria-label="Filter incidents" + /> +
    + + + + + - {stats.topContributors.length > 0 && ( -
    -

    Top contributors

    - - - {stats.topContributors.map(([groupKey, count]) => ( -
    0 && ( +
    +

    Top contributors

    + + + {stats.topContributors.map(([groupKey, count]) => ( +
    + - - {groupKey} - - + + + {count}/{stats.totalTriggered} + +
    + ))} +
    +
    +
    + )} + + {filteredIncidents.length === 0 ? ( + + + + + + No incidents + + {stateFilter === "all" + ? "This rule has not fired. It opens an incident when the threshold is crossed." + : `This rule has no ${stateFilter} incidents.`} + + + + {stateFilter !== "all" && ( + + )} + + + + ) : ( + + + + State + Group + Labels + Triggered at + Duration + Issue + + + + + {filteredIncidents.map((incident) => { + const isOpen = incident.status === "open" + return ( + + + - - {count}/{stats.totalTriggered} + + + + {incident.groupKey ?? "all"} - - ))} - - - - )} - - {filteredIncidents.length === 0 ? ( - - - - - - No incidents - - {stateFilter === "all" - ? "This rule has not fired. It opens an incident when the threshold is crossed." - : `This rule has no ${stateFilter} incidents.`} - - - - {stateFilter !== "all" && ( - - )} - - - - ) : ( -
    - - - State - Group - Labels - - Triggered at - - Duration - Issue - - - - - {filteredIncidents.map((incident) => { - const isOpen = incident.status === "open" - return ( - - - - - - - {incident.groupKey ?? "all"} - - - -
    - - {rule.signalType.replace( - "_", - " ", - )} - :{" "} - {formatSignalValue( - rule.signalType, - incident.lastObservedValue, - )} - - - threshold:{" "} - {formatSignalValue( - rule.signalType, - incident.threshold, - )} - -
    -
    - - {formatAlertDateTimeFull( - incident.firstTriggeredAt, - effectiveTimezone, - )} - - - + +
    + + {rule.signalType.replace("_", " ")}:{" "} + {formatSignalValue( + rule.signalType, + incident.lastObservedValue, )} + + - {formatAlertDuration( - incident.firstTriggeredAt, - incident.resolvedAt, + threshold:{" "} + {formatSignalValue( + rule.signalType, + incident.threshold, )} + +
    +
    + + {formatAlertDateTimeFull( + incident.firstTriggeredAt, + effectiveTimezone, + )} + + + + {formatAlertDuration( + incident.firstTriggeredAt, + incident.resolvedAt, + )} + + + + {incident.errorIssueId != null ? ( + + View + + ) : ( + + {EMPTY_VALUE} - - - {incident.errorIssueId != null ? ( - + +
    + + + void openInvestigation( + incident, + ) + } > - View - - ) : ( - - — - - )} - - -
    - - - } - > - - - - - void openInvestigation( - incident, - ) - } - > - - Open investigation - - - navigate({ - to: "/alerts", - search: { - tab: "overview", - }, - }) - } - > - View all incidents - - - -
    -
    - - ) - })} - -
    - )} -
    - )} - - )} - - - - + + Open investigation + + + navigate({ + to: "/alerts", + search: { + tab: "overview", + }, + }) + } + > + View all incidents + + +
    + + + ) + })} + + + )} +
    + )} + + )} + + )} + ) } +function ruleSubtitle(rule: AlertRuleDocument): string { + return `${signalLabels[rule.signalType]} ${comparatorLabels[rule.comparator]} ${formatSignalValue(rule.signalType, rule.threshold)} over ${rule.windowMinutes}min${rule.serviceNames?.length > 0 ? ` on ${rule.serviceNames.join(", ")}` : ""}${rule.environments?.length > 0 ? ` in ${rule.environments.join(", ")}` : ""}${rule.excludeServiceNames?.length > 0 ? ` (excl. ${rule.excludeServiceNames.join(", ")})` : ""}` +} + function ConfigRow({ label, children, wide }: { label: string; children: React.ReactNode; wide?: boolean }) { return ( @@ -1233,7 +1172,6 @@ function ChecksPanel({ const { effectiveTimezone } = useTimezonePreference() const [extraChecks, setExtraChecks] = useState>([]) const [nextCursorOverride, setNextCursorOverride] = useState(undefined) - const [loadingMore, setLoadingMore] = useState(false) const nextCursor = nextCursorOverride === undefined ? initialNextCursor : nextCursorOverride const loadedChecks = useMemo(() => { const byKey = new Map() @@ -1303,30 +1241,32 @@ function ChecksPanel({ [filteredChecks, medianGapMs], ) - const loadMore = useCallback(async () => { + const [runLoadMore, loadingMore] = useAsyncAction((cursor: string) => + runMapleApiV2((client) => + client.alertRules.checks({ + params: { id: rule.id }, + query: { + since, + until, + limit: 100, + cursor, + ...(!(statusFilter === "all") ? { status: statusFilter } : undefined), + }, + }), + ).then( + (page) => { + setExtraChecks((current) => [...current, ...page.data.map(v2CheckToDocument)]) + setNextCursorOverride(page.next_cursor) + }, + () => { + toastManager.add({ title: "More alert checks could not be loaded", type: "error" }) + }, + ), + ) + const loadMore = () => { if (nextCursor === null || loadingMore) return - setLoadingMore(true) - try { - const page = await runMapleApiV2((client) => - client.alertRules.checks({ - params: { id: rule.id }, - query: { - since, - until, - limit: 100, - cursor: nextCursor, - ...(!(statusFilter === "all") ? { status: statusFilter } : undefined), - }, - }), - ) - setExtraChecks((current) => [...current, ...page.data.map(v2CheckToDocument)]) - setNextCursorOverride(page.next_cursor) - } catch { - toastManager.add({ title: "More alert checks could not be loaded", type: "error" }) - } finally { - setLoadingMore(false) - } - }, [loadingMore, nextCursor, rule.id, since, statusFilter, until]) + void runLoadMore(nextCursor) + } // Ungrouped rules evaluate a single "all" series, so a Group column is a wall // of "all" — only show it when the rule actually fans out per group. @@ -1363,21 +1303,20 @@ function ChecksPanel({ return (

    Checks

    - 0 ? "crit" : undefined, - }, - { label: "Healthy", value: totals.healthy, tone: "ok" }, - ...(totals.errored > 0 - ? [{ label: "Failed", value: totals.errored, tone: "crit" as const }] - : []), - { label: "Transitions", value: totals.transitions }, - ]} - /> + 0 ? 5 : 4}> + + 0 ? "crit" : "neutral"} + /> + + {totals.errored > 0 ? ( + + ) : null} + +
    @@ -1404,10 +1343,10 @@ function ChecksPanel({ {bucket != null ? bucketLoading ? "Loading this bucket's evaluations…" - : `${bucketChecks.length} evaluation${bucketChecks.length === 1 ? "" : "s"} in this bucket — nothing hidden.` + : `${countLabel(bucketChecks.length, "evaluation")} in this bucket, nothing hidden.` : totals.total > loadedChecks.length - ? `Showing the latest ${loadedChecks.length} of ${totals.total} in this window — the rail above covers all of them.` - : `All ${totals.total} evaluation${totals.total === 1 ? "" : "s"} in this window.`} + ? `Showing the latest ${loadedChecks.length} of ${totals.total} in this window; the rail above covers all of them.` + : `All ${countLabel(totals.total, "evaluation")} in this window.`}

    @@ -1458,10 +1397,10 @@ function ChecksPanel({ - {skipped} evaluation{skipped === 1 ? "" : "s"} hidden by the - status filter + {countLabel(skipped, "evaluation")} hidden by the status + filter )} @@ -1496,14 +1435,15 @@ function ChecksPanel({ {check.status === "error" ? ( - {check.errorMessage ?? "Evaluation failed"} - + ) : check.observedValue == null ? ( - — + {EMPTY_VALUE} ) : (
    {check.incidentTransition === "none" ? ( - – + {EMPTY_VALUE} ) : ( - + {loadedChecks.length} of {totals.total} loaded diff --git a/apps/web/src/routes/alerts/incidents/$incidentId.tsx b/apps/web/src/routes/alerts/incidents/$incidentId.tsx index 259e1d0322..6414779f39 100644 --- a/apps/web/src/routes/alerts/incidents/$incidentId.tsx +++ b/apps/web/src/routes/alerts/incidents/$incidentId.tsx @@ -1,20 +1,20 @@ import { useMemo, useState } from "react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Result, useAtomSet } from "@/lib/effect-atom" -import { Exit, Schema } from "effect" +import { type Cause, Exit, Schema } from "effect" import { decodeAlertContextFromSearchParam, toAlertContext, type AlertContext, } from "@/components/chat/alert-context" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { DetailHeaderSkeleton } from "@/components/common/detail-header" +import { ErrorState } from "@/components/common/error-state" import { ResourceNotFound } from "@/components/common/resource-not-found" import { useMountEffect } from "@/hooks/use-mount-effect" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { useAlertIncidentsList, useAlertRulesList } from "@/hooks/use-alerts-list" -import { Button } from "@maple/ui/components/ui/button" -import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" import { Skeleton } from "@maple/ui/components/ui/skeleton" import type { AlertIncidentDocument, ErrorIssueId } from "@maple/domain/http" @@ -66,43 +66,28 @@ function AlertIncidentPage() { if (loading && !alertContext) { return ( - - - - - -
    - - - - -
    -
    -
    -
    -
    + } + width="narrow" + gap="md" + > + + + ) } if (!alertContext) { return ( - - + } + backLabel="Back to alerts" /> - - - - } - backLabel="Back to alerts" - /> - - - - + ) } @@ -137,9 +122,9 @@ function AlertInvestigationRedirect({ const create = useAtomSet(MapleApiV2AtomClient.mutation("investigations", "create"), { mode: "promiseExit", }) - const [failed, setFailed] = useState(false) + const [failure, setFailure] = useState | null>(null) const openInvestigation = () => { - setFailed(false) + setFailure(null) void create({ payload: { subject: { @@ -175,53 +160,23 @@ function AlertInvestigationRedirect({ replace: true, }) } else { - setFailed(true) + setFailure(result.cause) } }) } useMountEffect(openInvestigation) - if (failed) { - return ( - - - - - - - - Investigation start failed - - The investigation could not be created. You can safely retry. - - - - - - - - - ) - } return ( - - - - - -
    - - - -
    -
    -
    -
    -
    + } + width="narrow" + gap="md" + > + {failure ? ( + + ) : ( + + )} + ) } diff --git a/apps/web/src/routes/alerts/index.tsx b/apps/web/src/routes/alerts/index.tsx index d7341ecdce..322cad8734 100644 --- a/apps/web/src/routes/alerts/index.tsx +++ b/apps/web/src/routes/alerts/index.tsx @@ -5,7 +5,7 @@ import { AlertsOverviewTab } from "@/components/alerts/overview/alerts-overview- import { AlertsSettingsTab, useDestinationManager } from "@/components/alerts/overview/settings-tab" import { DestinationDialog } from "@/components/alerts/destination-dialog" import { OpenDestinationDialogProvider } from "@/components/alerts/destination-manager-context" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { PlusIcon } from "@/components/icons" import { useAlertDestinationsList } from "@/hooks/use-alerts-list" import { retainedQuery } from "@/lib/services/common/atom-client" @@ -59,7 +59,10 @@ function AlertsPage() { const tabBar = ( navigate({ search: (prev) => ({ ...prev, tab: tab as AlertsTab }) })} + onValueChange={(tab) => { + if (tab === "overview" || tab === "settings") + navigate({ search: (prev) => ({ ...prev, tab }) }) + }} > Overview @@ -91,33 +94,22 @@ function AlertsPage() { return ( destinationManager.openDialog() : null}> - - - - - - {headerActions} - {tabBar} - - - {activeTab === "overview" ? ( - - ) : ( - - )} - - - - - + + {activeTab === "overview" ? ( + + ) : ( + + )} + + ) } diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx index ec5dd3c151..f12dc9667d 100644 --- a/apps/web/src/routes/analytics/index.tsx +++ b/apps/web/src/routes/analytics/index.tsx @@ -5,9 +5,11 @@ import { Result } from "@/lib/effect-atom" import { Button } from "@maple/ui/components/ui/button" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ChartLoading } from "@maple/ui/components/charts" import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { DocsLink } from "@/components/common/docs-link" import { SignalEmptyState } from "@/components/common/signal-empty-state" @@ -62,8 +64,6 @@ import { Badge } from "@maple/ui/components/ui/badge" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const ANALYTICS_TABS = ["overview", "ai"] as const type AnalyticsTab = (typeof ANALYTICS_TABS)[number] @@ -108,10 +108,7 @@ function WebAnalyticsPage() { }) } - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -164,138 +161,116 @@ function WebAnalyticsPage() { const sessionsPresence = useSignalPresence("sessions") return ( - - - - - - - - - - {/* View tabs share the header row with the range controls on wide - screens, same as Hosts; the header stacks them on narrow ones. */} - - - - Overview - - - AI traffic - - - - } - > -
    - {/* Ahead of the range controls, because it is the one number - on the page they do not govern: "right now" is its own - window, and the filters still narrow it. */} - - {/* The reciprocal of the Analytics button on Session Replays: this - page aggregates the sessions that page plays back one at a time, - and "who are these people actually" is the next question from - either side. Carries the window across, same as the outbound link. */} - - -
    - - - -
    - {/* Active filters, removable one at a time. The page title used to carry - them; the breadcrumb and tab bar already say where you are. */} - {chips.length > 0 ? ( -
    - {chips.map((chip) => ( - onFilterChange(chip.key, undefined)} - /> - } - > - {chip.label} ✕ - - ))} - -
    - ) : null} - {activeTab === "ai" ? ( - - ) : sessionsPresence.status === "absent" && chips.length === 0 ? ( - - ) : ( - - )} -
    -
    - - - - + + {/* Ahead of the range controls, because it is the one number + on the page they do not govern: "right now" is its own + window, and the filters still narrow it. */} + + {/* The reciprocal of the Analytics button on Session Replays: this + page aggregates the sessions that page plays back one at a time, + and "who are these people actually" is the next question from + either side. Carries the window across, same as the outbound link. */} + + + } + time={{ search, startTime, endTime, defaultPreset: DEFAULT_PRESET, onChange: handleTimeChange }} + // View tabs share the header row with the range controls on wide + // screens, same as Hosts; the header stacks them on narrow ones. + titleContent={ + + + + Overview + + + AI traffic + + + + } + filters={ + + } + gap="lg" + > + {/* Active filters, removable one at a time. The page title used to carry + them; the breadcrumb and tab bar already say where you are. */} + {chips.length > 0 ? ( +
    + {chips.map((chip) => ( + onFilterChange(chip.key, undefined)} /> + } + > + {chip.label} ✕ + + ))} + +
    + ) : null} + {activeTab === "ai" ? ( + + ) : sessionsPresence.status === "absent" && chips.length === 0 ? ( + + ) : ( + + )} +
    ) } @@ -408,7 +383,7 @@ function AnalyticsContent({ .onInitial(() => ( <> - + )) .onError((error) => ) diff --git a/apps/web/src/routes/anomalies/$incidentId.tsx b/apps/web/src/routes/anomalies/$incidentId.tsx index 513ee9330c..e8787490e5 100644 --- a/apps/web/src/routes/anomalies/$incidentId.tsx +++ b/apps/web/src/routes/anomalies/$incidentId.tsx @@ -1,11 +1,10 @@ -import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { ChartError, ChartLoading } from "@maple/ui/components/charts/_shared/chart-state" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { useState } from "react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { displayError } from "@/lib/error-messages" import { Exit, Schema } from "effect" -import { toastManager } from "@maple/ui/components/ui/toast" import { PulseIcon } from "@/components/icons" import { AnomalyHero } from "@/components/anomalies/anomaly-hero" @@ -21,13 +20,14 @@ import { severityToneFor, } from "@/components/anomalies/anomaly-format" import { useAnomalyMutations } from "@/components/anomalies/use-anomaly-mutations" -import { DashboardLayout } from "@/components/layout/dashboard-layout" -import { SectionHeader } from "@/components/layout/section-header" +import { ResultPage } from "@/components/layout/result-page" +import { RelativeTime } from "@/components/common/relative-time" +import { toastExit } from "@/lib/error-toast" +import { SectionHeading } from "@/components/common/section-heading" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { anomalyIncidentFromV2, anomalyTimeseriesFromV2 } from "@/lib/services/anomalies" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { formatRelativeTime } from "@maple/ui/lib/time-format" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" @@ -47,7 +47,6 @@ const decodeIncidentId = Schema.decodeSync(AnomalyIncidentId) const isIncidentNotFound = (error: unknown) => displayError(error)._tag === "@maple/http/anomalies/AnomalyIncidentNotFoundError" const LIVE_REFRESH_INTERVAL_MS = 15_000 -const ANOMALY_LOADING_BREADCRUMBS = [{ label: "Anomalies", href: "/anomalies" }, { label: "…" }] as const export const Route = createFileRoute("/anomalies/$incidentId")({ component: AnomalyDetailPage, @@ -64,9 +63,10 @@ function AnomalyDetailPage() { const incidentResult = useAtomValue(incidentQueryAtom) const refreshIncident = useAtomRefresh(incidentQueryAtom) - const isOpen = Result.builder(incidentResult) - .onSuccess((incident) => incident.status === "open") - .orElse(() => false) + const incident = Result.builder(incidentResult) + .onSuccess(anomalyIncidentFromV2) + .orElse(() => null) + const isOpen = incident?.status === "open" // Live monitor: keep an open incident fresh while the page is visible. useIntervalRefresh(refreshIncident, { @@ -74,59 +74,6 @@ function AnomalyDetailPage() { enabled: isOpen, }) - return Result.builder(incidentResult) - .onInitial(() => ( - - - - - -
    - - - -
    -
    -
    -
    -
    - )) - .onError((error) => ( - - - - - - } - backLabel="Back to anomalies" - /> - - - - - )) - .onSuccess((incident) => ( - - )) - .render() -} - -function AnomalyDetailBody({ - incident, - incidentId, -}: { - incident: AnomalyIncidentDocument - incidentId: AnomalyIncidentId -}) { const mutations = useAnomalyMutations() const [linkDialogOpen, setLinkDialogOpen] = useState(false) const [resolveConfirmOpen, setResolveConfirmOpen] = useState(false) @@ -135,38 +82,21 @@ function AnomalyDetailBody({ mode: "promiseExit", }) - const isOpen = incident.status === "open" - const isStale = isStaleOpenIncident(incident) - const tone = severityToneFor(incident) - - const timeseriesQueryAtom = retainedQueryV2("anomalies", "getIncidentTimeseries", { - params: { id: incidentId }, - query: {}, - reactivityKeys: [`anomalyIncident:${incidentId}:timeseries`], - }) - const timeseriesResult = useAtomValue(timeseriesQueryAtom) - const refreshTimeseries = useAtomRefresh(timeseriesQueryAtom) - - useIntervalRefresh(refreshTimeseries, { - intervalMs: LIVE_REFRESH_INTERVAL_MS, - enabled: isOpen, - }) - const [resolve, resolving] = useAsyncAction(async () => { await mutations.resolveIncident(incidentId) setResolveConfirmOpen(false) }) const [linkTo, linking] = useAsyncAction(async (issueId: ErrorIssueId) => { - const result = await mutations.linkIssue(incidentId, issueId, incident.errorIssueId) + const result = await mutations.linkIssue(incidentId, issueId, incident?.errorIssueId ?? null) if (Exit.isSuccess(result)) setLinkDialogOpen(false) }) const [unlink, unlinking] = useAsyncAction(async () => { - await mutations.linkIssue(incidentId, null, incident.errorIssueId) + await mutations.linkIssue(incidentId, null, incident?.errorIssueId ?? null) }) - const [investigate, investigating] = useAsyncAction(async () => { + const [investigate, investigating] = useAsyncAction(async (incident: AnomalyIncidentDocument) => { const result = await createInvestigation({ payload: { subject: { @@ -198,148 +128,185 @@ function AnomalyDetailBody({ await navigate({ to: "/investigations/$id", params: { id: result.value.id } }) return } - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + toastExit(result, { error: "Failed to open investigation" }) }) const busy = resolving || linking || unlinking || investigating return ( - - - - - - -
    - - {isOpen && !isStale ? ( - - - {incident.severity} - - ) : isStale ? ( - `Stale · last seen ${formatRelativeTime(incident.lastTriggeredAt)}` - ) : incident.resolveReason !== null ? ( - RESOLVE_REASON_LABEL[incident.resolveReason] - ) : ( - "Resolved" - )} - - - {isOpen ? ( - - ) : null} -
    -
    -
    - -
    -
    - - } - error={() => ( - - Failed to load signal data. - - )} - > - {(timeseries) => ( - - )} - -
    - -
    - - setLinkDialogOpen(true)} - onUnlink={unlink} - busy={busy} - /> -
    -
    - - - - incident} + crumb={(incident) => + [incident.serviceName, SIGNAL_LABEL[incident.signalType], incident.deploymentEnv] + .filter(Boolean) + .join(" · ") + } + isNotFoundError={isIncidentNotFound} + errorTitle="Failed to load anomaly" + onRetry={refreshIncident} + loading={ +
    + + + +
    + } + notFound={ + } + backLabel="Back to anomalies" + /> + } + headerActions={(incident) => { + const isStale = isStaleOpenIncident(incident) + const tone = severityToneFor(incident) + return ( +
    + + {isOpen && !isStale ? ( + + + {incident.severity} + + ) : isStale ? ( <> - The incident is marked resolved manually - {incident.fingerprints.filter((f) => f.resolvedAt === null).length > 1 - ? ", including every error fingerprint grouped into it" - : ""} - . If the signal keeps deviating, the detector waits out a one-hour - cooldown before re-opening it. + Stale · last seen - } - confirmLabel="Resolve" - pending={busy} - onConfirm={resolve} - /> - - - - + + {isOpen ? ( + + ) : null} +
    + ) + }} + rightPanel={(incident) => ( + setResolveConfirmOpen(true)} + onOpenLinkDialog={() => setLinkDialogOpen(true)} + onUnlink={unlink} + /> + )} + > + {(incident) => ( + <> +
    +
    + + +
    + +
    + + setLinkDialogOpen(true)} + onUnlink={unlink} + busy={busy} + /> +
    +
    + + setResolveConfirmOpen(true)} - onOpenLinkDialog={() => setLinkDialogOpen(true)} - onUnlink={unlink} + open={linkDialogOpen} + onOpenChange={setLinkDialogOpen} + onSelect={linkTo} /> - -
    -
    + + + The incident is marked resolved manually + {incident.fingerprints.filter((f) => f.resolvedAt === null).length > 1 + ? ", including every error fingerprint grouped into it" + : ""} + . If the signal keeps deviating, the detector waits out a one-hour cooldown + before re-opening it. + + } + confirmLabel="Resolve" + pending={busy} + onConfirm={resolve} + /> + + )} + + ) +} + +/** The incident's signal series, fetched once the incident itself has loaded. */ +function AnomalySignalChart({ + incident, + incidentId, +}: { + incident: AnomalyIncidentDocument + incidentId: AnomalyIncidentId +}) { + const timeseriesQueryAtom = retainedQueryV2("anomalies", "getIncidentTimeseries", { + params: { id: incidentId }, + query: {}, + reactivityKeys: [`anomalyIncident:${incidentId}:timeseries`], + }) + const timeseriesResult = useAtomValue(timeseriesQueryAtom) + const refreshTimeseries = useAtomRefresh(timeseriesQueryAtom) + + useIntervalRefresh(refreshTimeseries, { + intervalMs: LIVE_REFRESH_INTERVAL_MS, + enabled: incident.status === "open", + }) + + return ( + } + error={() => Failed to load signal data.} + > + {(timeseries) => ( + + )} + ) } diff --git a/apps/web/src/routes/anomalies/index.tsx b/apps/web/src/routes/anomalies/index.tsx index da606bb5c1..9f6673b565 100644 --- a/apps/web/src/routes/anomalies/index.tsx +++ b/apps/web/src/routes/anomalies/index.tsx @@ -3,7 +3,8 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router" import { Result, useAtomRefresh } from "@/lib/effect-atom" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { useAsyncAction } from "@/hooks/use-mutation-action" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useListNavigation } from "@/hooks/use-list-navigation" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" @@ -112,7 +113,6 @@ function AnomaliesPage() { // tab switch discards them without an effect; a refresh clears them outright, // otherwise the live tick would leave stale rows below fresh ones. const [loadedPages, setLoadedPages] = useState(null) - const [loadingMore, setLoadingMore] = useState(false) const pages = loadedPages?.key === status ? loadedPages : null const refreshIncidents = useCallback(() => { @@ -175,25 +175,25 @@ function AnomaliesPage() { const nextCursor = pages === null ? (firstPage?.next_cursor ?? null) : pages.nextCursor - const loadMore = useCallback(async () => { + const [runLoadMore, loadingMore] = useAsyncAction((cursor: string) => + runMapleApiV2((client) => client.anomalies.listIncidents({ query: { ...listQuery, cursor } })).then( + (page) => { + const rows = page.data.map(anomalyIncidentFromV2) + setLoadedPages((current) => ({ + key: status, + rows: [...(current?.key === status ? current.rows : []), ...rows], + nextCursor: page.next_cursor, + })) + }, + () => { + toastManager.add({ title: "More anomalies could not be loaded", type: "error" }) + }, + ), + ) + const loadMore = () => { if (nextCursor === null || loadingMore) return - setLoadingMore(true) - try { - const page = await runMapleApiV2((client) => - client.anomalies.listIncidents({ query: { ...listQuery, cursor: nextCursor } }), - ) - const rows = page.data.map(anomalyIncidentFromV2) - setLoadedPages((current) => ({ - key: status, - rows: [...(current?.key === status ? current.rows : []), ...rows], - nextCursor: page.next_cursor, - })) - } catch { - toastManager.add({ title: "More anomalies could not be loaded", type: "error" }) - } finally { - setLoadingMore(false) - } - }, [listQuery, loadingMore, nextCursor, status]) + void runLoadMore(nextCursor) + } const filtered = useMemo( () => allIncidents.filter((incident) => matchesAnomalyFilters(incident, filters)), @@ -247,81 +247,61 @@ function AnomaliesPage() { ) - // The three Result branches share one shell. With the compound layout that's a - // local component taking `children`, rather than a props object spread three ways. - const AnomaliesShell = ({ children }: { children: React.ReactNode }) => ( - - - - - - - - - - - navigate({ - search: (prev) => ({ - ...prev, - live: next === (status === "open") ? undefined : next, - }), - }) - } + return ( + + } + headerActions={ + + navigate({ + search: (prev) => ({ + ...prev, + live: next === (status === "open") ? undefined : next, + }), + }) + } + /> + } + > +
    + {toolbar} + {Result.builder(incidentsResult) + .onInitial(() => ) + .onError((error) => ( +
    + - - - {children} - - - - ) - - return Result.builder(incidentsResult) - .onInitial(() => ( - -
    - {toolbar} - -
    -
    - )) - .onError((error) => ( - -
    - {toolbar} -
    - + )) + .onSuccess(() => ( + -
    -
    -
    - )) - .onSuccess(() => ( - - )) - .render() + )) + .render()} +
    + + ) } function AnomaliesPageBody({ @@ -331,8 +311,6 @@ function AnomaliesPageBody({ onClearFilters, excludedValues, onClearExclusions, - toolbar, - Shell, hasMore, loadingMore, onLoadMore, @@ -341,11 +319,9 @@ function AnomaliesPageBody({ status: StatusTab hasActiveFilters: boolean onClearFilters: () => void - toolbar: React.ReactNode /** Flattened active exclusions, for the empty state's hint. */ excludedValues: ReadonlyArray onClearExclusions: () => void - Shell: (props: { children: React.ReactNode }) => React.ReactElement hasMore: boolean loadingMore: boolean onLoadMore: () => void @@ -410,89 +386,78 @@ function AnomaliesPageBody({ scrollTo: (id) => scrollIntoView(id), }) - return ( - -
    - {toolbar} - {incidents.length === 0 ? ( -
    - - - - {hasActiveFilters - ? "No anomalies match the current filters" - : status === "open" - ? "No open anomalies" - : "No anomalies"} - - - {hasActiveFilters - ? "Try widening or clearing the filters." - : "The detector compares every service's error rate, latency, throughput, error fingerprints, and log volume against its own 7-day baseline. Incidents appear here when something deviates."} - {!hasActiveFilters && tracePresence.status === "absent" - ? " Anomaly detection learns a baseline from your traces and logs. Send telemetry to start." - : null} - - - {hasActiveFilters ? ( - - ) : tracePresence.status === "absent" ? ( - - - - Setup guide - - - ) : tracePresence.status === "present" ? ( - - - - Alert rules - - - ) : null} - {/* Named separately from "Clear filters": an inclusion is visible in what - came back, an exclusion only in what did not. */} - - -
    - ) : ( -
    - {visibleGroups.map((key) => ( - - ))} - {hasMore ? ( - - ) : null} -
    - )} -
    -
    + return incidents.length === 0 ? ( +
    + + + + {hasActiveFilters + ? "No anomalies match the current filters" + : status === "open" + ? "No open anomalies" + : "No anomalies"} + + + {hasActiveFilters + ? "Try widening or clearing the filters." + : "The detector compares every service's error rate, latency, throughput, error fingerprints, and log volume against its own 7-day baseline. Incidents appear here when something deviates."} + {!hasActiveFilters && tracePresence.status === "absent" + ? " Anomaly detection learns a baseline from your traces and logs. Send telemetry to start." + : null} + + + {hasActiveFilters ? ( + + ) : tracePresence.status === "absent" ? ( + + + + Setup guide + + + ) : tracePresence.status === "present" ? ( + + + + Alert rules + + + ) : null} + {/* Named separately from "Clear filters": an inclusion is visible in what + came back, an exclusion only in what did not. */} + + +
    + ) : ( +
    + {visibleGroups.map((key) => ( + + ))} + {hasMore ? ( + + ) : null} +
    ) } diff --git a/apps/web/src/routes/cli-login.tsx b/apps/web/src/routes/cli-login.tsx index 55522ab9b4..f06cfa2161 100644 --- a/apps/web/src/routes/cli-login.tsx +++ b/apps/web/src/routes/cli-login.tsx @@ -155,11 +155,11 @@ function CliLoginPage() { return (
    -
    -

    Authorize Maple CLI

    -

    +

    + Authorize Maple CLI + Approve a terminal session without copying an API key. -

    +
    {state._tag === "entry" || state._tag === "error" ? ( diff --git a/apps/web/src/routes/code-review/issues.tsx b/apps/web/src/routes/code-review/issues.tsx index 01c5ba0a9f..a80f02f07a 100644 --- a/apps/web/src/routes/code-review/issues.tsx +++ b/apps/web/src/routes/code-review/issues.tsx @@ -14,7 +14,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeFrom, toEpochMs } from "@maple/ui/lib/time-format" +import { toEpochMs } from "@maple/ui/lib/time-format" +import { ListRow } from "@maple/ui/components/ui/list-row" +import { Panel } from "@maple/ui/components/ui/panel" +import { RelativeTime } from "@/components/common/relative-time" import { CATEGORY_LABELS, @@ -153,15 +156,17 @@ function CodeReviewIssuesPage() { > {(response) => (
    -
      - {response.findings.map((finding) => ( - onChange({ review: finding.reviewId })} - /> - ))} -
    + +
      + {response.findings.map((finding) => ( + onChange({ review: finding.reviewId })} + /> + ))} +
    +
    {response.nextCursor !== null ? ( ({ function FindingRow({ finding, onOpen }: { finding: CodeReviewFinding; onOpen: () => void }) { return (
  • - + } + trailing={ + <> + + {CATEGORY_LABELS[finding.category]} + + + {FINDING_STATUS_LABELS[finding.status]} + + + + } + />
  • ) } diff --git a/apps/web/src/routes/code-review/pull-requests.tsx b/apps/web/src/routes/code-review/pull-requests.tsx index f511bf6e55..37677de3de 100644 --- a/apps/web/src/routes/code-review/pull-requests.tsx +++ b/apps/web/src/routes/code-review/pull-requests.tsx @@ -9,7 +9,12 @@ import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { TONE_SOFT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeFrom, toEpochMs } from "@maple/ui/lib/time-format" +import { toEpochMs } from "@maple/ui/lib/time-format" +import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { Panel } from "@maple/ui/components/ui/panel" +import { rowSelectedClass } from "@maple/ui/components/ui/list-row" +import { Spinner } from "@maple/ui/components/ui/spinner" +import { RelativeTime } from "@/components/common/relative-time" import { formatCount, outcomeOf, SKIP_LABELS } from "@/components/code-review/code-review-format" import { @@ -195,7 +200,7 @@ function ReviewTable({ onOpen: (review: CodeReviewListItem) => void }) { return ( -
    + @@ -223,7 +228,7 @@ function ReviewTable({ onClick={() => onOpen(review)} className={cn( "cursor-pointer transition-colors hover:bg-muted/40", - selected === review.id && "bg-muted/60", + rowSelectedClass(selected === review.id), )} > @@ -260,12 +265,11 @@ function ReviewTable({ - + {outcome.kind === "running" ? ( + + ) : ( + + )} {outcome.label} {review.status === "skipped" && review.skipReason !== null ? ( @@ -276,7 +280,7 @@ function ReviewTable({ {review.confidence === null ? ( - – + {EMPTY_VALUE} ) : ( <> {review.confidence} @@ -286,7 +290,7 @@ function ReviewTable({ {review.score === null ? ( - – + {EMPTY_VALUE} ) : ( <> {review.score} @@ -296,7 +300,7 @@ function ReviewTable({ {review.status !== "completed" ? ( - – + {EMPTY_VALUE} ) : ( {review.criticalFindings > 0 ? ( @@ -314,13 +318,13 @@ function ReviewTable({ )} - {formatRelativeFrom(review.createdAt)} + ) })}
    -
    + ) } diff --git a/apps/web/src/routes/dashboards/$dashboardId.tsx b/apps/web/src/routes/dashboards/$dashboardId.tsx index 9e63c4164b..9f3009532a 100644 --- a/apps/web/src/routes/dashboards/$dashboardId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId.tsx @@ -1,11 +1,12 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { DashboardId, DashboardVersionId } from "@maple/domain/http" import { Atom, useAtom } from "@/lib/effect-atom" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { DashboardSections } from "@/components/dashboard-builder/sections/dashboard-sections" import { BlankDashboardEmpty } from "@/components/dashboard-builder/blank-dashboard-empty" import { LiveWidgetRenderer } from "@/components/dashboard-builder/canvas/live-widget-renderer" @@ -237,70 +238,38 @@ function DashboardViewPage() { } if (!activeDashboard) { - if (isLoading) { - return ( - - - - - - - - - - - ) - } // A dead sync stream must not masquerade as a missing dashboard: the row may // exist and simply never have arrived. - if (isError) { - return ( - - - - - - - - - - - ) - } + const [crumb, body] = isLoading + ? ["...", ] + : isError + ? [ + "Unavailable", + , + ] + : [ + "Not found", + + No dashboard with id{" "} + {dashboardId} + + } + backLink={} + backLabel="Back to all dashboards" + className="py-24" + />, + ] return ( - - - - - - - No dashboard with id{" "} - - {dashboardId} - - - } - backLink={} - backLabel="Back to all dashboards" - className="py-24" - /> - - - - + + {body} + ) } @@ -345,128 +314,113 @@ function DashboardViewPage() { refreshIntervalSeconds={refreshIntervalSeconds} paused={mode === "edit" || isPreviewing} > - - - - - - - updateDashboard(dashboardId, { name }) - } - /> - } - > - setChartPickerOpen(true)} - onOpenHistory={openHistory} - refreshIntervalSeconds={refreshIntervalSeconds} - onRefreshIntervalChange={handleRefreshIntervalChange} - /> - - - - {degraded && } - {persistenceError && ( - - - {persistenceError}. Dashboard editing is temporarily - disabled. - - - )} + updateDashboard(dashboardId, { name })} + /> + } + headerActions={ + setChartPickerOpen(true)} + onOpenHistory={openHistory} + refreshIntervalSeconds={refreshIntervalSeconds} + onRefreshIntervalChange={handleRefreshIntervalChange} + /> + } + rightPanel={ + historyPanelOpen ? ( + { + setHistoryPanelOpen(false) + setPreviewed(null) + }} + /> + ) : undefined + } + > + {degraded && } + {persistenceError && ( + + + {persistenceError}. Dashboard editing is temporarily disabled. + + + )} - {isPreviewing && previewed ? ( - setPreviewed(null)} - onRestored={() => setPreviewed(null)} - /> - ) : activeDashboard.widgets.length === 0 && - (activeDashboard.sections?.length ?? 0) === 0 ? ( - // Shown while editing too: an edit-mode board with nothing on it - // renders as a blank page, which reads as a failed load. - { - if (mode !== "edit") { - navigate({ - to: "/dashboards/$dashboardId", - params: { dashboardId }, - search: (prev) => ({ - ...pickDashboardControlParams(prev), - mode: "edit" as const, - }), - }) - } - setChartPickerOpen(true) - }} - /> - ) : ( - - applySectionView((prev) => - withSectionCollapsed(prev, sectionId, collapsed), - ) - } - onSelectTab={(sectionId, tabId) => - applySectionView((prev) => - withActiveTab(prev, sectionId, tabId), - ) - } - onAddWidget={(sectionId, tabId) => { - setPendingSectionTarget({ sectionId, tabId }) - setChartPickerOpen(true) - }} - /> - )} + {isPreviewing && previewed ? ( + setPreviewed(null)} + onRestored={() => setPreviewed(null)} + /> + ) : activeDashboard.widgets.length === 0 && + (activeDashboard.sections?.length ?? 0) === 0 ? ( + // Shown while editing too: an edit-mode board with nothing on it + // renders as a blank page, which reads as a failed load. + { + if (mode !== "edit") { + navigate({ + to: "/dashboards/$dashboardId", + params: { dashboardId }, + search: (prev) => ({ + ...pickDashboardControlParams(prev), + mode: "edit" as const, + }), + }) + } + setChartPickerOpen(true) + }} + /> + ) : ( + + applySectionView((prev) => + withSectionCollapsed(prev, sectionId, collapsed), + ) + } + onSelectTab={(sectionId, tabId) => + applySectionView((prev) => withActiveTab(prev, sectionId, tabId)) + } + onAddWidget={(sectionId, tabId) => { + setPendingSectionTarget({ sectionId, tabId }) + setChartPickerOpen(true) + }} + /> + )} - undefined - : (open) => { - setChartPickerOpen(open) - // Reset on close so the next toolbar "Add - // widget" lands on the root canvas rather - // than inheriting the last group used. - if (!open) setPendingSectionTarget(null) - } + undefined + : (open) => { + setChartPickerOpen(open) + // Reset on close so the next toolbar "Add + // widget" lands on the root canvas rather + // than inheriting the last group used. + if (!open) setPendingSectionTarget(null) } - /> - - - - {historyPanelOpen ? ( - { - setHistoryPanelOpen(false) - setPreviewed(null) - }} - /> - ) : undefined} - - - + } + /> + diff --git a/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx b/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx index d3f36a2f09..971ed5690a 100644 --- a/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx @@ -4,7 +4,7 @@ import { useAsyncAction } from "@/hooks/use-mutation-action" import { createFileRoute, useNavigate, useBlocker } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { WidgetQueryBuilderPage, type WidgetQueryBuilderPageHandle, @@ -26,6 +26,7 @@ import { variableSearchRest, } from "@/lib/dashboard-controls/search-params" import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { toastManager } from "@maple/ui/components/ui/toast" // The editor carries the dashboard's per-viewer controls through its own search @@ -94,18 +95,9 @@ function WidgetConfigurePage() { if (!activeDashboard || !configureWidget) { return ( - - - - - - - - - - + + + ) } @@ -135,17 +127,16 @@ function WidgetConfigurePage() { urlValues={{}} onValueChange={() => undefined} > - - +
    - - - - - - - - - - - + } + > + + + + - {status === "blocked" && ( -
    -
    -

    Unsaved changes

    -

    - You have unsaved widget changes. Are you sure you want to leave? -

    -
    - - -
    -
    -
    - )} + { + if (!open) reset?.() + }} + title="Unsaved changes" + description="You have unsaved widget changes. Are you sure you want to leave?" + cancelLabel="Stay" + confirmLabel="Discard changes" + onConfirm={() => proceed?.()} + /> ) diff --git a/apps/web/src/routes/dashboards/index.tsx b/apps/web/src/routes/dashboards/index.tsx index 5f10fcdaef..c0d16d8c14 100644 --- a/apps/web/src/routes/dashboards/index.tsx +++ b/apps/web/src/routes/dashboards/index.tsx @@ -4,6 +4,8 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { countLabel } from "@maple/ui/lib/format" import { Unitflow, View } from "@maple/unitflow/react" import { Button } from "@maple/ui/components/ui/button" @@ -21,7 +23,7 @@ import { } from "@/components/dashboard-builder/portable-dashboard" import type { Dashboard } from "@/components/dashboard-builder/types" import { CircleWarningIcon, GridIcon, PlusIcon, UploadIcon } from "@/components/icons" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { isDashboardSortOption, type DashboardSortOption } from "@/atoms/dashboard-preferences-atoms" import { useDashboardPreferences } from "@/hooks/use-dashboard-preferences" import { useDashboardMutations } from "@/hooks/use-dashboard-store" @@ -92,7 +94,7 @@ function DashboardListPage() { const preview = warnings.slice(0, 3).join("\n") const suffix = warnings.length > 3 ? `\n+${warnings.length - 3} more` : "" toastManager.add({ - title: `Imported with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, + title: `Imported with ${countLabel(warnings.length, "warning")}`, description: `${preview}${suffix}`, type: "warning", }) @@ -117,21 +119,16 @@ function DashboardListPage() { const handleCreate = () => { if (readOnly) return - void (async () => { - try { - const dashboard = await createDashboard("Untitled Dashboard") + void createDashboard("Untitled Dashboard").then( + (dashboard) => navigate({ to: "/dashboards/$dashboardId", params: { dashboardId: dashboard.id }, search: { mode: "edit" }, - }) - } catch (error) { - toastManager.add({ - title: errorMessage(error, "Failed to create dashboard"), - type: "error", - }) - } - })() + }), + (error) => + toastManager.add({ title: errorMessage(error, "Failed to create dashboard"), type: "error" }), + ) } /** @@ -141,19 +138,18 @@ function DashboardListPage() { */ const handleDuplicate = (dashboard: Dashboard) => { if (readOnly) return - void (async () => { - try { - const portable = toPortableDashboard(dashboard) - const copy = await importDashboard({ ...portable, name: `${dashboard.name} copy` }) + const portable = toPortableDashboard(dashboard) + void importDashboard({ ...portable, name: `${dashboard.name} copy` }).then( + (copy) => { navigate({ to: "/dashboards/$dashboardId", params: { dashboardId: copy.id } }) toastManager.add({ title: `Duplicated as "${copy.name}"`, type: "success" }) - } catch (error) { + }, + (error) => toastManager.add({ title: errorMessage(error, "Failed to duplicate dashboard"), type: "error", - }) - } - })() + }), + ) } // `undefined` clears the param instead of writing an empty value, so a cleared @@ -260,38 +256,30 @@ function PageShell({ children: React.ReactNode }) { return ( - - - - - - - {summary} -
    - ) : undefined - } - > - {actions} - - {/* Pinned with the header rather than inside Scroll: it explains the - disabled buttons above it, so it must not scroll away from them. */} - {persistenceError && ( - - - - {persistenceError}. Editing, import and delete are disabled until it - recovers; reading is unaffected. - - - )} - - {children} - - - + {summary}
    + ) : undefined + } + headerActions={actions} + // Pinned with the header rather than inside Scroll: it explains the disabled + // buttons above it, so it must not scroll away from them. + sticky={ + persistenceError ? ( + + + + {persistenceError}. Editing, import and delete are disabled until it recovers; + reading is unaffected. + + + ) : null + } + > + {children} + ) } diff --git a/apps/web/src/routes/dashboards/templates.tsx b/apps/web/src/routes/dashboards/templates.tsx index 0c3684e4b1..2c9d809a37 100644 --- a/apps/web/src/routes/dashboards/templates.tsx +++ b/apps/web/src/routes/dashboards/templates.tsx @@ -12,18 +12,14 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { useDashboardMutationSync } from "@/hooks/use-dashboard-store" import { useAsyncAction } from "@/hooks/use-mutation-action" -import { displayError } from "@/lib/error-messages" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { RefreshButton } from "@maple/ui/components/ui/refresh-button" +import { showErrorToast } from "@/lib/error-toast" import { TemplateList, type ReadinessFilter } from "@/components/dashboard-builder/templates/template-list" import { TemplateDetailPanel } from "@/components/dashboard-builder/templates/template-detail-panel" import { BLANK_TEMPLATE_ID } from "@/components/dashboard-builder/templates/template-summary" import { useTemplateReadiness } from "@/components/dashboard-builder/templates/use-template-readiness" -import { - ArrowLeftIcon, - ArrowRotateClockwiseIcon, - CircleWarningIcon, - GridSquareCirclePlusIcon, -} from "@/components/icons" +import { ArrowLeftIcon, CircleWarningIcon, GridSquareCirclePlusIcon } from "@/components/icons" const asTemplateId = Schema.decodeUnknownSync(DashboardTemplateId) @@ -123,8 +119,7 @@ function TemplatesPage() { }) if (Exit.isFailure(result)) { - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + showErrorToast(result) return } @@ -149,97 +144,86 @@ function TemplatesPage() { ) return ( - - - - - - - - {readyCount} ready for your data - - · - - {catalogueCount} templates - -
    - ) - } - > + <> + + {readyCount} ready for your data + · + + {catalogueCount} templates + +
    + ) + } + headerActions={ + + } + fill + > + {failed ? ( + + + + + + Couldn't load the templates + + The template catalogue didn't answer. Nothing you've built is affected — this + page only reads. + + +
    + refreshList()} + /> - - - - {failed ? ( - - - - - - Couldn't load the templates - - The template catalogue didn't answer. Nothing you've built is affected - — this page only reads. - - -
    - - -
    -
    - ) : ( -
    -
    - setSelected(template.id)} - onSelectBlank={() => createFromTemplate(BLANK_TEMPLATE_ID, {})} - query={query} - onQueryChange={(next) => setQuery(next ?? "")} - filter={filter} - onFilterChange={setFilter} - loading={loading} - /> -
    - {/* `--panel-surface` is the colour the panel's sticky footer - paints and fades the scrolling preview into. It has to be - declared by whoever owns the surface — here the page, in the - sheet below its popover. */} - {!panelIsSheet && ( -
    - {panel} -
    - )} +
    + + ) : ( +
    +
    + setSelected(template.id)} + onSelectBlank={() => createFromTemplate(BLANK_TEMPLATE_ID, {})} + query={query} + onQueryChange={(next) => setQuery(next ?? "")} + filter={filter} + onFilterChange={setFilter} + loading={loading} + /> +
    + {/* `--panel-surface` is the colour the panel's sticky footer + paints and fades the scrolling preview into. It has to be + declared by whoever owns the surface — here the page, in the + sheet below its popover. */} + {!panelIsSheet && ( +
    + {panel}
    )} - - - +
    + )} + {panelIsSheet && ( )} - + ) } diff --git a/apps/web/src/routes/developer.tsx b/apps/web/src/routes/developer.tsx index 705dff569c..95b817eed0 100644 --- a/apps/web/src/routes/developer.tsx +++ b/apps/web/src/routes/developer.tsx @@ -1,13 +1,16 @@ import { useNavigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" import { ApiKeysSection } from "@/components/settings/api-keys-section" import { IngestionSection } from "@/components/settings/ingestion-section" +const DeveloperTab = Schema.Literals(["ingestion", "api-keys"]) +const isDeveloperTab = Schema.is(DeveloperTab) + const DeveloperSearch = Schema.Struct({ - tab: Schema.optional(Schema.Literals(["ingestion", "api-keys"])), + tab: Schema.optional(DeveloperTab), }) export const Route = createFileRoute("/developer")({ @@ -18,33 +21,26 @@ export const Route = createFileRoute("/developer")({ function DeveloperPage() { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) + const tab = search.tab ?? "ingestion" return ( - - - - - - - navigate({ search: { tab: tab as "ingestion" | "api-keys" } }) - } - > - - Ingestion - API Keys - - - - - - - - - - - - + { + if (isDeveloperTab(next)) void navigate({ search: { tab: next } }) + }} + > + + Ingestion + API Keys + + + } + > + {tab === "ingestion" ? : } + ) } diff --git a/apps/web/src/routes/errors/index.tsx b/apps/web/src/routes/errors/index.tsx index 9686c2fe30..116cd66020 100644 --- a/apps/web/src/routes/errors/index.tsx +++ b/apps/web/src/routes/errors/index.tsx @@ -4,7 +4,7 @@ import { Schema } from "effect" import { IssueKind } from "@maple/domain/http" import { BooleanFromStringParam } from "@/lib/search-params" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { ErrorsFilterSidebar } from "@/components/errors/errors-filter-sidebar" import { ErrorsHub } from "@/components/errors/errors-hub" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" @@ -78,34 +78,23 @@ function ErrorsContent() { const severity: SeverityFilter = search.severity ?? "all" return ( - - - - - - - - - - - - - - - - - - - + } + headerActions={} + > + + + ) } diff --git a/apps/web/src/routes/errors/issues/$issueId.tsx b/apps/web/src/routes/errors/issues/$issueId.tsx index e839d816d3..584995cf08 100644 --- a/apps/web/src/routes/errors/issues/$issueId.tsx +++ b/apps/web/src/routes/errors/issues/$issueId.tsx @@ -1,9 +1,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { displayError } from "@/lib/error-messages" import { Exit, Schema } from "effect" import { useMemo, useState } from "react" import { toastManager } from "@maple/ui/components/ui/toast" +import { Panel } from "@maple/ui/components/ui/panel" +import { showErrorToast } from "@/lib/error-toast" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -27,7 +28,7 @@ import { Button } from "@maple/ui/components/ui/button" import { IssuePullRequestsPanel } from "@/components/errors/issue-pull-requests-panel" import { IssueVerificationCard } from "@/components/errors/issue-verification-card" import { LinkedInvestigationPanel } from "@/components/errors/linked-investigation-panel" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" @@ -303,8 +304,7 @@ function IssueDetailContent() { // The endpoint fails three ways; only one of them is a bad URL. Telling a // user their valid URL is malformed because Postgres was down sends them // off to re-check a link that was fine. - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + showErrorToast(result) return false }, ) @@ -434,8 +434,7 @@ function IssueDetailContent() { params: { id: result.value.id }, }) } else { - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + showErrorToast(result) } }, ) @@ -478,9 +477,9 @@ function IssueDetailContent() { /> )) .onError((error) => ( - + - + )) .onSuccess((v2Detail) => { const detail = errorIssueDetailFromV2(v2Detail) @@ -533,198 +532,190 @@ function IssueDetailContent() { }) return ( - - - - - - - - - - {tab === "overview" ? ( -
    - - - {issue.kind === "alert" ? ( - - ) : ( - - )} - - {issue.kind === "error" ? ( - - - - ) : null} - -
    - ) : tab === "occurrences" ? ( - - - - ) : ( - - } - errorTitle="Failed to load the activity timeline" - onRetry={refreshEvents} - errorVariant="inline" + + } + tabs={ + + } + rightPanel={ +
    + + {/* Inset here, not on the outer column: `IssueSidebar` is + deliberately full-bleed against the rail's border, while these + two are bordered cards — without the padding their own border + doubles up against the rail's and runs into the viewport edge. */} +
    + {/* Above the PR list: while a check is running it is the most + load-bearing thing on the page — it explains why the issue is + sitting in `verifying` and nobody needs to touch it. */} + {latestVerification ? ( + + ) : AWAITING_FIX_STATES.has(issue.workflowState) && + pullRequests.length === 0 ? ( + // Somebody is working this issue but nothing is attached, so + // there is nothing for verification to trigger on. Said here, + // in the slot the verification card will occupy, rather than + // only on the panel below where the payoff is easy to miss. + +

    + No pull request attached. Maple can only confirm this error + stopped if it knows which fix to watch. +

    + +
    + ) : null} + - - - -
    - +
    + } + > + {tab === "overview" ? ( +
    + + + {issue.kind === "alert" ? ( + + ) : ( + - {/* Inset here, not on the outer column: `IssueSidebar` is - deliberately full-bleed against the rail's border, while these - two are bordered cards — without the padding their own border - doubles up against the rail's and runs into the viewport edge. */} -
    - {/* Above the PR list: while a check is running it is the most - load-bearing thing on the page — it explains why the issue is - sitting in `verifying` and nobody needs to touch it. */} - {latestVerification ? ( - - ) : AWAITING_FIX_STATES.has(issue.workflowState) && - pullRequests.length === 0 ? ( - // Somebody is working this issue but nothing is attached, so - // there is nothing for verification to trigger on. Said here, - // in the slot the verification card will occupy, rather than - // only on the panel below where the payoff is easy to miss. -
    -

    - No pull request attached. Maple can only confirm this - error stopped if it knows which fix to watch. -

    - -
    - ) : null} - + {issue.kind === "error" ? ( + + + + ) : null} + +
    + ) : tab === "occurrences" ? ( + + + + ) : ( + + } + errorTitle="Failed to load the activity timeline" + onRetry={refreshEvents} + errorVariant="inline" + > + {(value) => ( + -
    -
    - - - + )} + + + + )} + { + if (!open) setSeverityConfirmation(null) + }} + tone="default" + icon={null} + title="Notify escalation destinations?" + description={ + <> + Changing severity to {severityConfirmation?.severity} will notify{" "} + {severityConfirmation?.destinationNames.join(", ")}. Manual severity + changes represent explicit human intent and bypass AI confidence gates. + + } + confirmLabel="Change severity and notify" + onConfirm={() => { + const pending = severityConfirmation + setSeverityConfirmation(null) + if (pending) void applySeverity(pending.severity) + }} + /> + ) }) .render() @@ -737,30 +728,10 @@ function windowLabel(search: { startTime?: string; timePreset?: string }): strin return search.timePreset ?? DEFAULT_PRESET } -/** The loading and failure shells, which differ only in what they put in `Scroll`. */ -function IssueShell({ - breadcrumbs, - children, -}: { - breadcrumbs: Array<{ label: string; href?: string }> - children: React.ReactNode -}) { - return ( - - - - - {children} - - - - ) -} - /** * A titled block in the page body. * - * `SectionHeader`'s 10px eyebrow is the rail's typography — it is what + * The eyebrow `SectionHeading`'s 10px overline is the rail's typography, it is what * `DetailRail.Group` uses — so applying it to main-column sections made the two * read at the same rank and left the page with no heading hierarchy at all. This * is the investigation page's section heading instead. diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 3fc3c092f2..92d5b4aa17 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -5,12 +5,13 @@ import { Schema } from "effect" import { useMountEffect } from "@/hooks/use-mount-effect" import { dashboardFacetsHintAtomFamily, type DashboardFacetsHint } from "@/atoms/dashboard-facets-hint-atoms" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { formatErrorRate } from "@maple/ui/lib/format" +import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { ServiceUsageCards } from "@/components/dashboard/service-usage-cards" @@ -40,7 +41,7 @@ const dashboardSearchSchema = Schema.Struct({ }) export const Route = createFileRoute("/")({ - component: DashboardPage, + component: HomePage, validateSearch: Schema.toStandardSchemaV1(dashboardSearchSchema), search: { middlewares: [sessionTimeRangeSearchMiddleware()] }, }) @@ -93,7 +94,7 @@ const OVERVIEW_CHARTS: OverviewChartConfig[] = [ }, ] -function DashboardPage() { +function HomePage() { const search = Route.useSearch() // `orgId` is guaranteed on this route (root `beforeLoad` redirects to // /org-required otherwise) and comes from the router context, so it's @@ -173,14 +174,7 @@ function DashboardContent({ search.timePreset ?? defaultPreset, ) - const handleTimeChange = ( - range: { - startTime?: string - endTime?: string - presetValue?: string - }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev: Record) => applyTimeRangeSearch(prev, range), @@ -408,7 +402,7 @@ function DashboardContent({ <> Total{" "} - {totalVolume.toLocaleString()} + {formatNumber(totalVolume)} )) @@ -435,98 +429,86 @@ function DashboardContent({ [environments], ) + // No `time` prop: the refresh provider sits above this component (HomePage), so the + // atoms read here are page-refreshable too. return ( - - - - - - -
    - - -
    -
    -
    - - {isClerkAuthEnabled && ( - <> - - - )} - {/* Facets drive the environment dropdown and the default preset; when - they fail the charts below still load (unfiltered), so surface the - failure instead of silently offering an empty environment list. */} - {Result.builder(facetsResult) - .onError((error) => ( - - )) - .render()} - {/* Persist the facets-derived defaults once facets resolve, so the next - cold load can fetch optimistically. Gated on `facetsReady` and keyed - by the derived hint so it remounts (re-persists) only when the value - actually changes — no bare effect, no per-render writes. */} - {facetsReady && ( - - )} - - - - - -
    -
    -
    + + + +
    + } + > + {isClerkAuthEnabled && ( + <> + + + )} + {/* Facets drive the environment dropdown and the default preset; when + they fail the charts below still load (unfiltered), so surface the + failure instead of silently offering an empty environment list. */} + {Result.builder(facetsResult) + .onError((error) => ( + + )) + .render()} + {/* Persist the facets-derived defaults once facets resolve, so the next + cold load can fetch optimistically. Gated on `facetsReady` and keyed + by the derived hint so it remounts (re-persists) only when the value + actually changes: no bare effect, no per-render writes. */} + {facetsReady && ( + + )} + + + + +
    ) } diff --git a/apps/web/src/routes/infra/cloudflare/$zoneName.tsx b/apps/web/src/routes/infra/cloudflare/$zoneName.tsx index d3a35c41e5..8c0d6adc9d 100644 --- a/apps/web/src/routes/infra/cloudflare/$zoneName.tsx +++ b/apps/web/src/routes/infra/cloudflare/$zoneName.tsx @@ -8,13 +8,15 @@ import { Result } from "@/lib/effect-atom" import { Button } from "@maple/ui/components/ui/button" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ChartLoading } from "@maple/ui/components/charts" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { DocsLink, EmptyActions } from "@/components/common/docs-link" import { CloudflareIcon } from "@/components/icons" import { HeroChip, PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" -import { formatBytes, formatPercent } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatBytes, formatNumber, formatPercent } from "@maple/ui/lib/format" import { CloudflareBreakdownPanel } from "@/components/infra/cloudflare/cloudflare-breakdown-panel" import { CloudflareIngestEmpty, @@ -47,13 +49,10 @@ import { cloudflareZoneFacetsResultAtom, cloudflareZonesResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" -import { formatNumber } from "@maple/ui/lib/format" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const zoneDetailSearchSchema = Schema.Struct({ ...cloudflareFilterSearchFields, @@ -81,10 +80,7 @@ function ZoneDetailPage() { const serviceName = `${ZONE_SERVICE_PREFIX}${zoneName}` const filters = filtersFromSearch(search) - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -139,63 +135,44 @@ function ZoneDetailPage() { ) return ( - - - - - - - - - - - - - - -
    - zone} - meta={ - - } - /> - -
    -
    -
    -
    -
    -
    + } + gap="lg" + > + zone} + meta={ + + } + /> + + ) } @@ -241,8 +218,8 @@ function ZoneDetailContent({
    - - + +
    } @@ -300,12 +277,12 @@ function ZoneDetailContent({ /> diff --git a/apps/web/src/routes/infra/cloudflare/index.tsx b/apps/web/src/routes/infra/cloudflare/index.tsx index 774441f52f..553696e2b1 100644 --- a/apps/web/src/routes/infra/cloudflare/index.tsx +++ b/apps/web/src/routes/infra/cloudflare/index.tsx @@ -7,7 +7,8 @@ import { Result } from "@/lib/effect-atom" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { DocsLink } from "@/components/common/docs-link" import { ResultView } from "@/components/common/result-view" import { CloudflareIcon } from "@/components/icons" @@ -33,7 +34,9 @@ import { CloudflareZoneTable, CloudflareZoneTableLoading, } from "@/components/infra/cloudflare/cloudflare-zone-table" -import { MAX_ZONE_SERIES, OTHER_ZONES_COLOR } from "@/components/infra/cloudflare/constants" +import { MAX_ZONE_SERIES, OTHER_ZONES_SERIES } from "@/components/infra/cloudflare/constants" +import { SeriesLegend } from "@/components/common/series-legend" +import { ChartLoading, OTHER_COLOR } from "@maple/ui/components/charts" import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" import { chartBucketSeconds } from "@/components/infra/chart-utils" import { @@ -47,8 +50,6 @@ import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const cloudflareSearchSchema = Schema.Struct({ ...TimeRangeSearchFields, @@ -70,10 +71,7 @@ function CloudflarePage() { search.timePreset ?? "12h", ) - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -86,55 +84,29 @@ function CloudflarePage() { const { statusResult, phase } = useCloudflareIngestPhase() return ( - - - - - - - - - - - -
    - - - -
    - } - > - {(status) => { - if (!status.connected) - return - if (!status.analyticsCapable) { - return - } - return ( - - ) - }} - -
    - - - - - + + + + +
    + } + > + {(status) => { + if (!status.connected) return + if (!status.analyticsCapable) { + return + } + return + }} + + ) } @@ -246,39 +218,33 @@ function CloudflareData({ zoneSeries && (
    {(zoneSeries.top.length > 1 || zoneSeries.otherCount > 0) && ( -
    - {zoneSeries.top.map((name) => ( - - - - {name} - - - ))} - {zoneSeries.otherCount > 0 && ( - - - - Other zones ({zoneSeries.otherCount}) - - - )} -
    + ({ + key: name, + color: zoneSeries.colors.get(name), + label: ( + + {name} + + ), + })), + ...(zoneSeries.otherCount > 0 + ? [ + { + key: OTHER_ZONES_SERIES, + color: OTHER_COLOR, + label: `Other zones (${zoneSeries.otherCount})`, + }, + ] + : []), + ]} + /> )}
    - + {containerName}} + description="Container metrics from the Docker stats receiver." + meta={ + <> + {summary?.hostName && host {summary.hostName}} + {summary?.imageName && image {summary.imageName}} + {summary?.runtime && runtime {summary.runtime}} + + } /> - - - - {toolbar} - - -
    - {containerName}} - description="Container metrics from the Docker stats receiver." - meta={ - <> - {summary?.hostName && host {summary.hostName}} - {summary?.imageName && image {summary.imageName}} - {summary?.runtime && runtime {summary.runtime}} - - } - /> - {Result.isInitial(summaryResult) ? ( - - ) : Result.isFailure(summaryResult) ? ( - - ) : summary ? ( - - - - - 0 ? "warn" : undefined} - compact - /> - - - ) : ( - -

    - This container sent no metrics in the selected window. It may have - stopped earlier: try a wider range, or go back to the containers list. -

    -
    - {preset === "7d" ? null : ( - - )} - -
    -
    - )} + {Result.isInitial(summaryResult) ? ( + + ) : Result.isFailure(summaryResult) ? ( + + ) : summary ? ( + + + + + 0 ? "warn" : undefined} + compact + /> + + + ) : ( + +

    + This container sent no metrics in the selected window. It may have stopped earlier: + try a wider range, or go back to the containers list. +

    +
    + {preset === "7d" ? null : ( + + )} + +
    +
    + )} -
    - - ariaLabel="Metric" - options={METRIC_TABS} - value={metric} - onChange={setMetric} - /> - -
    -
    -
    -
    - {rightSidebar} -
    - +
    + + ariaLabel="Metric" + options={METRIC_TABS} + value={metric} + onChange={setMetric} + /> + +
    + ) } diff --git a/apps/web/src/routes/infra/containers/index.tsx b/apps/web/src/routes/infra/containers/index.tsx index 8f4e5c346d..8a93703e8b 100644 --- a/apps/web/src/routes/infra/containers/index.tsx +++ b/apps/web/src/routes/infra/containers/index.tsx @@ -8,7 +8,8 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@m import { Button } from "@maple/ui/components/ui/button" import { OptionalStringArrayParam } from "@/lib/search-params" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { DockerIcon, MagnifierIcon } from "@/components/icons" import { PageHero } from "@/components/common/page-hero" @@ -36,8 +37,6 @@ import { import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { useDebouncedValue } from "@maple/ui/hooks/use-debounced-value" import type { ContainerSortKey, SortDirection } from "@/api/warehouse/infra" @@ -186,10 +185,7 @@ function ContainersPage() { patchSearch({ sortBy: key, sortDir: key === "containerName" ? "asc" : "desc" }) } - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -200,149 +196,115 @@ function ContainersPage() { const hasAnyNarrowing = hasStructuredFilter || Boolean(searchText.trim()) || Boolean(scope) return ( - - - } + time={{ search, startTime, endTime, defaultPreset: "12h", onChange: handleTimeChange }} + filters={ + - - - - - - - } - > - - - - -
    - + } + gap="lg" + > + - ({ - id: chip.param, - label: chip.label, - values: chip.values, - negated: chip.negated, - onRemove: () => - navigate({ - search: (prev) => ({ ...prev, [chip.param]: undefined }), - }), - }))} - /> + ({ + id: chip.param, + label: chip.label, + values: chip.values, + negated: chip.negated, + onRemove: () => + navigate({ + search: (prev) => ({ ...prev, [chip.param]: undefined }), + }), + }))} + /> - {Result.builder(summaryResult) - .onInitial(() => ( - - )) - .onError(() => null) - .onSuccess((counts, result) => ( - patchSearch({ scope: next })} - waiting={result.waiting} - className={FLEET_BAND_BOXED} - /> - )) - .render()} + {Result.builder(summaryResult) + .onInitial(() => ) + .onError(() => null) + .onSuccess((counts, result) => ( + patchSearch({ scope: next })} + waiting={result.waiting} + className={FLEET_BAND_BOXED} + /> + )) + .render()} - {Result.builder(containersResult) - .onInitial(() => ) - .onError((err) => ) - .onSuccess((response, result) => { - const containers = response.data - const total = response.totalCount + {Result.builder(containersResult) + .onInitial(() => ) + .onError((err) => ) + .onSuccess((response, result) => { + const containers = response.data + const total = response.totalCount - if (containers.length === 0 && !hasAnyNarrowing) { - return ( - } - title="No containers reporting yet" - description="Run the Maple Docker agent next to your containers to collect per-container CPU, memory, network, and block I/O. On Kubernetes, the Helm chart collects container metrics too." - installTab="docker" - actionLabel="Install the Docker agent" - docs="docker" - /> - ) - } + if (containers.length === 0 && !hasAnyNarrowing) { + return ( + } + title="No containers reporting yet" + description="Run the Maple Docker agent next to your containers to collect per-container CPU, memory, network, and block I/O. On Kubernetes, the Helm chart collects container metrics too." + installTab="docker" + actionLabel="Install the Docker agent" + docs="docker" + /> + ) + } - return ( -
    - - patchSearch({ q: value || undefined }) - } - placeholder="Search all containers…" - trailing={countLabel( - containers.length, - total, - "container", - )} - /> + return ( +
    + patchSearch({ q: value || undefined })} + placeholder="Search all containers…" + trailing={countLabel(containers.length, total, "container")} + /> - {containers.length === 0 ? ( - - - - - - - No containers match these filters - - - {scope - ? `Nothing is ${SCOPE_LABEL[scope]} in this window, which is good news.` - : "Try a different name, or clear the filters to see the whole fleet."} - - - - - ) : ( - - )} -
    - ) - }) - .render()} -
    - - - - - + {containers.length === 0 ? ( + + + + + + No containers match these filters + + {scope + ? `Nothing is ${SCOPE_LABEL[scope]} in this window, which is good news.` + : "Try a different name, or clear the filters to see the whole fleet."} + + + + + ) : ( + + )} +
    + ) + }) + .render()} + ) } diff --git a/apps/web/src/routes/infra/hosts/$hostName.tsx b/apps/web/src/routes/infra/hosts/$hostName.tsx index cb6fcb9e91..ffe97f190e 100644 --- a/apps/web/src/routes/infra/hosts/$hostName.tsx +++ b/apps/web/src/routes/infra/hosts/$hostName.tsx @@ -2,9 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { ResultView } from "@/components/common/result-view" import { Schema } from "effect" import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" +import { countLabel } from "@maple/ui/lib/format" import { Result, useAtomValue, useAtomRefresh } from "@/lib/effect-atom" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { HostDetailHeader, HostDetailHeaderLoading } from "@/components/infra/host-detail-header" import { MetricStrip } from "@/components/infra/host-detail-chart" @@ -20,8 +22,6 @@ import { canWidenTimeRange, } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const DEFAULT_PRESET = "1h" @@ -54,10 +54,7 @@ function HostDetailPage() { const { startTime, endTime } = useEffectiveTimeRange(search.startTime, search.endTime, preset) const bucketSeconds = bucketSecondsForRange(startTime, endTime) - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -81,90 +78,67 @@ function HostDetailPage() { const { containerProps: linkedCursorContainerProps } = useLinkedCursor(true) return ( - - - - - - - - - - - -
    - } - error={(error) => ( - - )} - > - {(r) => ( - - handleTimeChange({ - presetValue: WIDEN_TIME_PRESET, - }) - : undefined - } - /> - )} - + +
    + } + error={(error) => ( + + )} + > + {(r) => ( + handleTimeChange({ presetValue: WIDEN_TIME_PRESET }) + : undefined + } + /> + )} + - - - {METRIC_STRIPS.length} signals - - } - /> -
    - {METRIC_STRIPS.map((strip) => ( - - ))} -
    -
    -
    - - - {rightSidebar} - - - + + + {countLabel(METRIC_STRIPS.length, "signal")} + + } + /> +
    + {METRIC_STRIPS.map((strip) => ( + + ))} +
    +
    +
    + ) } diff --git a/apps/web/src/routes/infra/hosts/index.tsx b/apps/web/src/routes/infra/hosts/index.tsx index b467f7b091..5bdb7f8959 100644 --- a/apps/web/src/routes/infra/hosts/index.tsx +++ b/apps/web/src/routes/infra/hosts/index.tsx @@ -7,8 +7,10 @@ import { useAtomValue } from "@/lib/effect-atom" import { Button } from "@maple/ui/components/ui/button" import { cn } from "@maple/ui/lib/utils" +import { formatNumber } from "@maple/ui/lib/format" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { MagnifierIcon, PlusIcon, ServerIcon } from "@/components/icons" import { ResultView } from "@/components/common/result-view" import { HostTable, HostTableLoading, type HostRow } from "@/components/infra/host-table" @@ -28,8 +30,6 @@ import { listHostsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const DEFAULT_PRESET = "12h" @@ -66,10 +66,7 @@ function HostsPage() { navigate({ search: (prev) => ({ ...prev, ...patch }) }) } - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -77,89 +74,59 @@ function HostsPage() { } return ( - - - - - - - } - > - - - - -
    - setInstallOpen(true)} - > - - Add host - - } - /> - - - - -
    - } - isEmpty={(response) => response.data.length === 0} - empty={ - } - title="No hosts reporting yet" - description="Run the OpenTelemetry Collector with the hostmetrics receiver on a host, or install the Helm chart to report every Kubernetes node." - installTab="hosts" - actionLabel="Add host" - docs="hosts" - /> - } - > - {(response, { waiting }) => ( - patchSearch({ q: q || undefined })} - onScopeChange={(scope) => patchSearch({ scope })} - onClear={() => patchSearch({ q: undefined, scope: undefined })} - /> - )} - -
    - - - - - - - + } + time={{ search, startTime, endTime, defaultPreset: DEFAULT_PRESET, onChange: handleTimeChange }} + gap="lg" + > + setInstallOpen(true)}> + + Add host + + } + /> + + + + +
    + } + isEmpty={(response) => response.data.length === 0} + empty={ + } + title="No hosts reporting yet" + description="Run the OpenTelemetry Collector with the hostmetrics receiver on a host, or install the Helm chart to report every Kubernetes node." + installTab="hosts" + actionLabel="Add host" + docs="hosts" + /> + } + > + {(response, { waiting }) => ( + patchSearch({ q: q || undefined })} + onScopeChange={(scope) => patchSearch({ scope })} + onClear={() => patchSearch({ q: undefined, scope: undefined })} + /> + )} + + + + ) } @@ -208,7 +175,7 @@ function HostList({ placeholder="Search hosts…" trailing={ hosts.length >= HOST_LIST_LIMIT - ? `${filtered.length.toLocaleString()} of the ${HOST_LIST_LIMIT.toLocaleString()} most recently seen hosts` + ? `${formatNumber(filtered.length)} of the ${formatNumber(HOST_LIST_LIMIT)} most recently seen hosts` : filtered.length === hosts.length ? countLabel(hosts.length, hosts.length, "host") : `${filtered.length} of ${hosts.length} hosts` diff --git a/apps/web/src/routes/infra/index.tsx b/apps/web/src/routes/infra/index.tsx index a6ca4bfe80..5037436498 100644 --- a/apps/web/src/routes/infra/index.tsx +++ b/apps/web/src/routes/infra/index.tsx @@ -3,8 +3,9 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { Button } from "@maple/ui/components/ui/button" +import { countLabel } from "@maple/ui/lib/format" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { PlusIcon } from "@/components/icons" import { InstallHostModal } from "@/components/infra/install-modal" import { @@ -23,8 +24,7 @@ import { pickTimeRangeSearch, } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" +import type { TimeRange } from "@/components/time-range-picker/types" const DEFAULT_PRESET = "1h" @@ -55,10 +55,7 @@ function InfraOverviewPage() { const sources = presentSources(surfaces) const missing = SOURCE_ORDER.filter((id) => !sources.includes(id)) - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -66,82 +63,47 @@ function InfraOverviewPage() { } return ( - - - - - - - - - - - -
    - 0 - ? `${sources.length} ${sources.length === 1 ? "source" : "sources"} reporting. What needs a look comes first.` - : "Nothing is reporting yet. Install a collector or connect a provider to get started." - } - /> + +
    + 0 + ? `${countLabel(sources.length, "source")} reporting. What needs a look comes first.` + : "Nothing is reporting yet. Install a collector or connect a provider to get started." + } + /> - {sources.length > 0 ? ( - <> - - - - ) : null} + {sources.length > 0 ? ( + <> + + + + ) : null} - {missing.length > 0 ? ( -
    -
    - Add a source - - Not reporting yet:{" "} - {missing.map((id) => SOURCE_TITLE[id]).join(", ")}. - -
    - - -
    - ) : null} -
    + {missing.length > 0 ? ( +
    +
    + Add a source + + Not reporting yet: {missing.map((id) => SOURCE_TITLE[id]).join(", ")}. + +
    + + +
    + ) : null} +
    - -
    -
    -
    -
    -
    + + ) } diff --git a/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx b/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx index 75f9074ea9..c767ca7361 100644 --- a/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx +++ b/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx @@ -8,7 +8,7 @@ import { Schema } from "effect" import { ResourceAttributesCard } from "@/components/infra/primitives/resource-attributes-card" import { NoMetricsMessage } from "@/components/infra/primitives/no-metrics-message" -import { formatUptime } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatUptime } from "@maple/ui/lib/format" import type { NodeInfraMetric } from "@/api/warehouse/infra" import { ServerIcon } from "@/components/icons" @@ -114,11 +114,17 @@ function NodeDetailPage() { - + ) : ( diff --git a/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx b/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx index d47a85f446..3870d5e3b1 100644 --- a/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx +++ b/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx @@ -6,7 +6,6 @@ import { Schema } from "effect" import { ResourceAttributesCard } from "@/components/infra/primitives/resource-attributes-card" import { NoMetricsMessage } from "@/components/infra/primitives/no-metrics-message" -import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatPercent } from "@maple/ui/lib/format" import type { PodInfraMetric } from "@/api/warehouse/infra" @@ -18,7 +17,7 @@ import { bucketSecondsForRange } from "@/components/infra/constants" import { severityLevel } from "@/components/infra/format" import { PageHero, HeroChip } from "@/components/common/page-hero" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" -import { StatRail, StatRailItem } from "@/components/common/stat-rail" +import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" import { podDetailSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -115,7 +114,7 @@ function PodDetailPage() { /> {Result.isInitial(summaryResult) ? ( - + ) : Result.isFailure(summaryResult) ? ( Nothing running right now - {`Every pod that reported in this window has since ended. Open the Ended scope to see the ${endedPods.toLocaleString()} that ran.`} + {`Every pod that reported in this window has since ended. Open the Ended scope to see the ${formatNumber(endedPods)} that ran.`}
    - - - - - + + + + + +
    + } + > + {(status) => { + if (!status.connected) return + return ( + + ) + }} + + ) } @@ -228,10 +205,7 @@ function PlanetScaleData({ /> ) : null} {Result.isFailure(statsResult) ? ( - + ) : null} {neverCollected ? : null} {/* Inventory counts have no time series, so no sparkline slot to reserve. */} @@ -249,7 +223,7 @@ function PlanetScaleData({ eyebrow="Worst storage" value={ totals.storageMax === null - ? "—" + ? EMPTY_VALUE : formatStoragePercent(totals.storageMax) } tone={ @@ -270,7 +244,7 @@ function PlanetScaleData({ eyebrow="Worst replica lag" value={ metricsPaused && stats.length === 0 - ? "—" + ? EMPTY_VALUE : formatLag(totals.lagMax) } tone={lagTone(totals.lagMax)} diff --git a/apps/web/src/routes/infra/railway/$serviceId.tsx b/apps/web/src/routes/infra/railway/$serviceId.tsx index cc6efa8354..c1d8b778cb 100644 --- a/apps/web/src/routes/infra/railway/$serviceId.tsx +++ b/apps/web/src/routes/infra/railway/$serviceId.tsx @@ -1,13 +1,14 @@ import { useMemo } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" -import { formatBytes } from "@maple/ui/lib/format" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { formatBytes, formatPercent } from "@maple/ui/lib/format" +import { ChartLoading } from "@maple/ui/components/charts" import { ErrorState } from "@/components/common/error-state" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { chartBucketSeconds, type ChartUnit } from "@/components/infra/chart-utils" -import { ChartCard } from "@/components/infra/primitives/chart-card" +import { ChartCard } from "@/components/common/chart-card" import { InfraMetricChart } from "@/components/infra/primitives/infra-metric-chart" import { HeroChip, PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem } from "@/components/common/stat-rail" @@ -22,8 +23,6 @@ import { import type { RailwayServiceTimeseriesRow } from "@/api/warehouse/railway-infra" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const railwayServiceSearchSchema = Schema.Struct({ ...TimeRangeSearchFields, @@ -92,10 +91,7 @@ function RailwayServicePage() { ) const bucketSeconds = Math.max(60, chartBucketSeconds(startTime, endTime)) - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -129,106 +125,77 @@ function RailwayServicePage() { const memoryShare = service ? shareOfLimit(service.memoryAvg, service.memoryLimit) : null return ( - - - - - - - - - - - -
    - - {service.projectName} - {service.environmentName} - - ) : undefined - } - /> - {service ? ( - - - - - - - ) : null} - {Result.isFailure(timeseriesResult) && buckets.length === 0 ? ( - - ) : Result.isInitial(timeseriesResult) ? ( -
    - {CHARTS.map((chart) => ( - - ))} -
    - ) : ( -
    - {CHARTS.map((chart) => ( - - - - ))} -
    - )} -
    -
    -
    -
    -
    -
    + + + {service.projectName} + {service.environmentName} + + ) : undefined + } + /> + {service ? ( + + + + + + + ) : null} + {Result.isFailure(timeseriesResult) && buckets.length === 0 ? ( + + ) : Result.isInitial(timeseriesResult) ? ( +
    + {CHARTS.map((chart) => ( + + ))} +
    + ) : ( +
    + {CHARTS.map((chart) => ( + + + + ))} +
    + )} +
    ) } diff --git a/apps/web/src/routes/infra/railway/index.tsx b/apps/web/src/routes/infra/railway/index.tsx index ffc2add020..a5eedcbfac 100644 --- a/apps/web/src/routes/infra/railway/index.tsx +++ b/apps/web/src/routes/infra/railway/index.tsx @@ -12,7 +12,7 @@ import { railwayStatusAtom, unsyncedEnvironments, } from "@/components/integrations/railway-integration-card" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { PageHero } from "@/components/common/page-hero" import { DataTable } from "@/components/common/data-table" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" @@ -32,9 +32,8 @@ import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { railwayServicesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import type { RailwayServiceRow } from "@/api/warehouse/railway-infra" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" +import type { TimeRange } from "@/components/time-range-picker/types" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" const railwaySearchSchema = Schema.Struct({ q: Schema.optional(Schema.String), @@ -66,10 +65,7 @@ function RailwayPage() { navigate({ search: (prev) => ({ ...prev, ...patch }) }) } - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), @@ -77,59 +73,36 @@ function RailwayPage() { } return ( - - - - - - - - - - - -
    - - } - > - {(status) => - status.connected ? ( - 0 - } - query={search.q ?? ""} - scope={search.scope} - onQueryChange={(q) => patchSearch({ q: q || undefined })} - onScopeChange={(scope) => patchSearch({ scope })} - /> - ) : ( - - ) - } - -
    -
    -
    -
    -
    -
    + + + } + > + {(status) => + status.connected ? ( + 0} + query={search.q ?? ""} + scope={search.scope} + onQueryChange={(q) => patchSearch({ q: q || undefined })} + onScopeChange={(scope) => patchSearch({ scope })} + /> + ) : ( + + ) + } + + ) } diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx index 5fcb50b542..9ade4fb6a6 100644 --- a/apps/web/src/routes/integrations.tsx +++ b/apps/web/src/routes/integrations.tsx @@ -4,7 +4,7 @@ import { useNavigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { CloudflareAccountCard, CloudflareHeaderActions, @@ -35,6 +35,7 @@ import { SettingsNav, useVisibleSettingsSections } from "@/components/settings/s import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { docsUrl } from "@/lib/docs" import { ArrowLeftIcon, CircleInfoIcon, ExternalLinkIcon } from "@/components/icons" @@ -159,26 +160,13 @@ function IntegrationsPage() { if (!integration) { return ( - - - - {settingsSidebar} - - - - - - - - navigate({ search: { integration: id } })} - /> - - - - + } + > + navigate({ search: { integration: id } })} /> + ) } @@ -187,81 +175,68 @@ function IntegrationsPage() { return ( - - - - {settingsSidebar} - - - } - /> - - -
    - {integration === "prometheus" && ( - - - - Maple scrapes any public Prometheus{" "} - /metrics endpoint you add as a target - below and stores the samples as OpenTelemetry metrics.{" "} - - Setup guide - - - - )} - {integration === "warpstream" && ( - - - - WarpStream clusters are scraped as Prometheus targets. Point a - target at an agent's :8080/metrics{" "} - endpoint or the hosted Prometheus endpoint with Basic auth.{" "} - - Setup guide - - - - )} - {chatConnector !== null ? ( - - ) : integration === "cloudflare" ? ( - - ) : integration === "hazel" ? ( - - ) : integration === "github" ? ( - - ) : integration === "planetscale" ? ( - - ) : integration === "railway" ? ( - - ) : ( - // prometheus + warpstream share the generic scrape-target flow - - )} -
    - - - - + } + gap="md" + > + {integration === "prometheus" && ( + + + + Maple scrapes any public Prometheus /metrics endpoint you + add as a target below and stores the samples as OpenTelemetry metrics.{" "} + + Setup guide + + + + )} + {integration === "warpstream" && ( + + + + WarpStream clusters are scraped as Prometheus targets. Point a target at an + agent's :8080/metrics endpoint or the hosted + Prometheus endpoint with Basic auth.{" "} + + Setup guide + + + + )} + {chatConnector !== null ? ( + + ) : integration === "cloudflare" ? ( + + ) : integration === "hazel" ? ( + + ) : integration === "github" ? ( + + ) : integration === "planetscale" ? ( + + ) : integration === "railway" ? ( + + ) : ( + // prometheus + warpstream share the generic scrape-target flow + + )} + ) } @@ -288,14 +263,9 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) { return (
    - + - - - - {children} - - - - ) -} +const INVESTIGATIONS_CRUMB = { label: "Investigations", href: "/investigations" } function LoadFailureShell({ error, onRetry }: { error: unknown; onRetry: () => void }) { return ( - + - + ) } @@ -135,26 +116,26 @@ function LoadFailureShell({ error, onRetry }: { error: unknown; onRetry: () => v */ function LoadingShell() { return ( - -
    - - - -
    -
    + } + width="reading" + gap="md" + > + + ) } function NotFoundShell() { return ( - + } backLabel="View investigations" /> - + ) } diff --git a/apps/web/src/routes/investigations/index.tsx b/apps/web/src/routes/investigations/index.tsx index c7823da2ae..b9034ae558 100644 --- a/apps/web/src/routes/investigations/index.tsx +++ b/apps/web/src/routes/investigations/index.tsx @@ -4,18 +4,17 @@ import { useMemo } from "react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Exit, Schema } from "effect" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { displayError } from "@/lib/error-messages" import type { V2Investigation } from "@maple/domain/http/v2" import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { ToolbarSearch } from "@maple/ui/components/toolbar" -import { toastManager } from "@maple/ui/components/ui/toast" import { formatDuration } from "@maple/ui/lib/format" import { toEpochMs } from "@maple/ui/lib/time-format" import { DocsLink } from "@/components/common/docs-link" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { ResultView } from "@/components/common/result-view" import { ListToolbar } from "@/components/common/list-toolbar" import { PageHero } from "@/components/common/page-hero" @@ -35,7 +34,11 @@ import { InvestigationTableSkeleton, } from "@/components/investigations/investigation-table" import { InvestigateBar } from "@/components/investigations/investigate-bar" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { Panel } from "@maple/ui/components/ui/panel" +import { showErrorToast } from "@/lib/error-toast" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { retainedInternalQuery } from "@/lib/services/common/internal-atom-client" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" @@ -170,8 +173,7 @@ function InvestigationsHub() { if (Exit.isSuccess(created)) { void navigate({ to: "/investigations/$id", params: { id: created.value.id } }) } else { - const { title, message } = displayError(created) - toastManager.add({ title, description: message, type: "error" }) + showErrorToast(created) } }) @@ -213,7 +215,7 @@ function InvestigationsHub() { }) } > - + {/* The trigger renders before the items register, so it resolves its own label rather than echoing the value. */} {kindFilterLabel} @@ -240,7 +242,7 @@ function InvestigationsHub() { }) }} > - + {sortLabel} @@ -263,65 +265,59 @@ function InvestigationsHub() { ) return ( - - - - - {isFirstRun ? ( - - - - ) : ( - <> - - {budget?.enabled && budget.ordinaryPaused ? ( - - ) : null} - - - - - {/* `shrink-0`, or the flex column shrinks this below its - content height and `overflow-hidden` clips the rows the - scroller then thinks it doesn't need to scroll to. */} -
    - {toolbar} - } - errorTitle="Investigations could not be loaded" - onRetry={refresh} - isEmpty={() => investigations.length === 0} - empty={ - - void navigate({ - search: (prev) => ({ - ...prev, - kind: undefined, - q: undefined, - }), - }) - } - /> - } - > - {() => } - -
    -
    - - )} -
    -
    -
    + + {budget?.enabled && budget.ordinaryPaused ? ( + + ) : null} + + + + ) + } + > + {isFirstRun ? ( + + ) : ( + // `shrink-0`, or the flex column shrinks this below its content height and + // `overflow-hidden` clips the rows the scroller then thinks it doesn't need to scroll to. + + {toolbar} + } + errorTitle="Investigations could not be loaded" + onRetry={refresh} + isEmpty={() => investigations.length === 0} + empty={ + + void navigate({ + search: (prev) => ({ + ...prev, + kind: undefined, + q: undefined, + }), + }) + } + /> + } + > + {() => } + + + )} + ) } @@ -351,11 +347,11 @@ function BudgetExhaustedNotice({ resumesAt: string | null canEditSettings: boolean }) { - const resumes = resumesAt === null ? null : new Date(toEpochMs(resumesAt)) + const { effectiveTimezone } = useTimezonePreference() const resets = - resumes === null + resumesAt === null ? "." - : `; it resets ${resumes.toLocaleString(undefined, { timeStyle: "short", dateStyle: "medium" })}.` + : `; it resets ${formatTimestampInTimezone(toEpochMs(resumesAt), { timeZone: effectiveTimezone, style: "range" })}.` // The runs ceiling counts investigations and the passes ceiling counts model // work; naming the wrong one sends the reader to raise a number that was never // the constraint. @@ -364,7 +360,7 @@ function BudgetExhaustedNotice({ ? "Today's investigation limit is reached" : "Today's model budget is spent" return ( - + {priorityPaused ? "Automatic triage paused" : "Automatic triage paused for routine incidents"} @@ -429,27 +425,24 @@ function TriageStrip({ investigations }: { investigations: ReadonlyArray - + 0 ? "info" : "neutral"} value={stats.running.length} - detail={ + hint={ stats.running.length === 0 ? "nothing in flight" : `${stats.running.length === 1 ? "an agent is" : "agents are"} gathering evidence` } /> - 0 ? "warn" : "neutral"} value={stats.review.length} - detail={ + hint={ stats.review.length === 0 ? "nothing waiting on you" : stats.critical > 0 @@ -457,50 +450,16 @@ function TriageStrip({ investigations }: { investigations: ReadonlyArray - -
    - ) -} - -function TriageStat({ - label, - dot, - labelTone, - value, - valueTone = "text-foreground", - detail, -}: { - label: string - dot: string - labelTone: string - value: number - valueTone?: string - detail: string -}) { - return ( -
    -
    - - {label} -
    -
    - - {value} - - {detail} -
    -
    + ) } diff --git a/apps/web/src/routes/lab/bench/service-map.tsx b/apps/web/src/routes/lab/bench/service-map.tsx index 714aa5c129..7fa343aacb 100644 --- a/apps/web/src/routes/lab/bench/service-map.tsx +++ b/apps/web/src/routes/lab/bench/service-map.tsx @@ -59,7 +59,7 @@ function ServiceMapBenchPage() { type="button" onClick={() => navigate({ search: (prev) => ({ ...prev, groups: g }) })} className={cn( - "rounded px-1.5 py-0.5 font-mono text-[11px] transition-colors", + "rounded px-1.5 py-0.5 font-mono text-2xs transition-colors", g === params.groups ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground", diff --git a/apps/web/src/routes/logs/$logId.tsx b/apps/web/src/routes/logs/$logId.tsx index e9d8f34290..7b4fc94411 100644 --- a/apps/web/src/routes/logs/$logId.tsx +++ b/apps/web/src/routes/logs/$logId.tsx @@ -1,9 +1,8 @@ import { warmAtoms } from "@effect-router/core" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" -import { Result, useAtomValue } from "@/lib/effect-atom" +import { useAtomValue } from "@/lib/effect-atom" -import { DashboardLayout } from "@/components/layout/dashboard-layout" -import { ErrorState } from "@/components/common/error-state" +import { ResultPage } from "@/components/layout/result-page" import { ResourceNotFound } from "@/components/common/resource-not-found" import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Panel, PanelBody, PanelHeader } from "@maple/ui/components/ui/panel" @@ -46,12 +45,7 @@ export const Route = createFileRoute("/logs/$logId")({ }, }) -/** - * Standalone, shareable detail view for a single log. Every render path returns - * a `DashboardLayout.Root` at the JSX root — keeping that element type stable - * across the loading / error / success transitions lets React reconcile it in - * place instead of tearing down (and rebuilding) the whole sidebar shell. - */ +/** Standalone, shareable detail view for a single log. */ function LogDetailPage() { const { logId } = Route.useParams() const navigate = useNavigate({ from: Route.fullPath }) @@ -62,154 +56,100 @@ function LogDetailPage() { key ? getLogResultAtom({ data: keyToInput(key) }) : disabledResultAtom(), ) - if (!key) { - return ( - - - - - - - - - - - ) - } - - return Result.builder(result) - .onInitial(() => ( - - - - - -
    - -
    - - -
    -
    -
    -
    -
    -
    - )) - .onError((error) => ( - - - - - - - - - - - )) - .onSuccess(({ data: log }) => { - if (!log) { - return ( - - - - - - -

    - This log could not be found. It may have aged out of - retention. -

    - - - {key.serviceName} - - - {key.timestamp} - - -
    - } - /> - - - - + return ( + response.data} + crumb={(log) => bodyExcerpt(log.body)} + gap="sm" + errorTitle="Failed to load log" + invalid={ + key ? undefined : ( + ) } + loading={ + <> + +
    + + +
    + + } + notFound={ + +

    This log could not be found. It may have aged out of retention.

    + + + {key?.serviceName} + + + {key?.timestamp} + + +
    + } + /> + } + > + {(log) => { + const sev = log.severityText.toUpperCase() + const showErrorBanner = sev === "ERROR" || sev === "FATAL" + return ( + <> + {/* Hero + meta as one card, mirroring the drawer's stacked top section. */} + + + + {showErrorBanner && } + - const sev = log.severityText.toUpperCase() - const showErrorBanner = sev === "ERROR" || sev === "FATAL" +
    + + + + + + - return ( - - - - - -
    - {/* Hero + meta as one card, mirroring the drawer's stacked top section. */} -
    - - + {log.traceId && ( + + + navigate({ + to: "/logs/$logId", + params: { logId: encodeLogKey(next) }, + }) + } /> - {showErrorBanner && } -
    - -
    - - - - - - - -
    - {log.traceId && ( - - - navigate({ - to: "/logs/$logId", - params: { logId: encodeLogKey(next) }, - }) - } - /> - - )} - - - -
    -
    -
    -
    -
    -
    -
    - ) - }) - .render() + + )} + + + +
    +
    + + ) + }} + + ) } -/** Shared by both not-found states: the dashed card with a way back to the list. */ +/** Shared by both not-found states: the way back to the list. */ const NOT_FOUND_PROPS = { - className: "rounded-md border border-dashed py-12", backLink: , backLabel: "Back to Logs", } diff --git a/apps/web/src/routes/logs/index.tsx b/apps/web/src/routes/logs/index.tsx index 164e67c1c8..57a75ae750 100644 --- a/apps/web/src/routes/logs/index.tsx +++ b/apps/web/src/routes/logs/index.tsx @@ -3,14 +3,13 @@ import { shortId } from "@maple/ui/lib/ids" import { Schema } from "effect" import { OptionalStringArrayParam } from "@/lib/search-params" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { LogsTable } from "@/components/logs/logs-table" import { LogsVolumeChart } from "@/components/logs/logs-volume-chart" import { LogsFilterSidebar } from "@/components/logs/logs-filter-sidebar" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { ActiveFilterChips } from "@maple/ui/components/filters/active-filter-chips" import { logFilterChips } from "@/lib/logs/log-filter-chips" import { useGlobalNamespace } from "@/hooks/use-global-namespace" @@ -48,14 +47,7 @@ function LogsPage() { const navigate = useNavigate({ from: Route.fullPath }) const pinnedNamespace = useGlobalNamespace() - const handleTimeChange = ( - range: { - startTime?: string - endTime?: string - presetValue?: string - }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => applyTimeRangeSearch(prev, range), @@ -91,45 +83,30 @@ function LogsPage() { } return ( - - - - - - - - - - - - - - handleTimeChange( - { startTime: range.startTime, endTime: range.endTime }, - { replace: true }, - ) - } - /> - - {/* `Fill`, not `Scroll`: the logs stream is virtualized and owns its - own scroller, so an outer `overflow-auto` only adds a second - scrollbar for the wheel to chain into at the ends. */} - -
    - - -
    -
    -
    -
    -
    -
    + + handleTimeChange( + { startTime: range.startTime, endTime: range.endTime }, + { replace: true }, + ) + } + /> + } + filters={} + // `fill`, not a scroll body: the logs stream is virtualized and owns its own + // scroller, so an outer `overflow-auto` only adds a second scrollbar for the + // wheel to chain into at the ends. + fill + > +
    + + +
    +
    ) } diff --git a/apps/web/src/routes/mcp-authorize.tsx b/apps/web/src/routes/mcp-authorize.tsx index 5a014a16b0..c7078a8690 100644 --- a/apps/web/src/routes/mcp-authorize.tsx +++ b/apps/web/src/routes/mcp-authorize.tsx @@ -154,11 +154,11 @@ function McpAuthorizePage() { return (
    -
    -

    Authorize Maple MCP

    -

    +

    + Authorize Maple MCP + Connect an AI client to your Maple observability workspace. -

    +
    {state._tag === "loading" || state._tag === "redirecting" ? ( diff --git a/apps/web/src/routes/mcp.tsx b/apps/web/src/routes/mcp.tsx index 445a00cbe2..8ff10f8b62 100644 --- a/apps/web/src/routes/mcp.tsx +++ b/apps/web/src/routes/mcp.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { McpSection } from "@/components/settings/mcp-section" export const Route = createFileRoute("/mcp")({ @@ -12,15 +12,8 @@ export const Route = createFileRoute("/mcp")({ // key-creation flow can't drift apart. function McpPage() { return ( - - - - - - - - - - + + + ) } diff --git a/apps/web/src/routes/metrics/$metricName.tsx b/apps/web/src/routes/metrics/$metricName.tsx index e6c903f115..e39839f0d7 100644 --- a/apps/web/src/routes/metrics/$metricName.tsx +++ b/apps/web/src/routes/metrics/$metricName.tsx @@ -1,13 +1,12 @@ import { useNavigate, useRouterState, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { MetricDetail } from "@/components/metrics/metric-detail" import type { MetricQueryPatch } from "@/components/metrics/metric-query-controls" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { AutocompleteValuesProvider } from "@/hooks/use-autocomplete-values" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" @@ -51,7 +50,7 @@ function MetricDetailPage() { search.timePreset ?? "24h", ) - const handleTimeChange = (range: { startTime?: string; endTime?: string; presetValue?: string }) => { + const handleTimeChange = (range: TimeRange) => { navigate({ search: (prev) => applyTimeRangeSearch(prev, range) }) } @@ -73,45 +72,26 @@ function MetricDetailPage() { const backToMetricsHref = buildBackToMetricsHref(searchStr) return ( - - - + + - - - - - - - - - - - - - - - - + + ) } diff --git a/apps/web/src/routes/metrics/index.tsx b/apps/web/src/routes/metrics/index.tsx index 984dba25dd..271d7286ad 100644 --- a/apps/web/src/routes/metrics/index.tsx +++ b/apps/web/src/routes/metrics/index.tsx @@ -1,7 +1,8 @@ import { useNavigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { MetricsBrowse, MetricsBrowseFilters, @@ -10,8 +11,6 @@ import { } from "@/components/metrics/metrics-browse" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { QUERY_BUILDER_METRIC_TYPES, type QueryBuilderMetricType, @@ -41,7 +40,7 @@ function MetricsPage() { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) - const handleTimeChange = (range: { startTime?: string; endTime?: string; presetValue?: string }) => { + const handleTimeChange = (range: TimeRange) => { navigate({ search: (prev) => applyTimeRangeSearch(prev, range) }) } @@ -58,64 +57,42 @@ function MetricsPage() { } return ( - - - - - - - - } - > -
    - - -
    -
    -
    - - { - navigate({ - to: "/metrics/$metricName", - params: { metricName: metric.metricName }, - search: { - startTime: search.startTime, - endTime: search.endTime, - timePreset: search.timePreset, - type: asMetricType(metric.metricType), - }, - }) - }} - /> - -
    -
    -
    -
    + + } + headerActions={} + time={{ search, defaultPreset: "24h", onChange: handleTimeChange }} + > + { + navigate({ + to: "/metrics/$metricName", + params: { metricName: metric.metricName }, + search: { + startTime: search.startTime, + endTime: search.endTime, + timePreset: search.timePreset, + type: asMetricType(metric.metricType), + }, + }) + }} + /> + ) } diff --git a/apps/web/src/routes/org-required.tsx b/apps/web/src/routes/org-required.tsx index 4612f38d21..b2705a7f12 100644 --- a/apps/web/src/routes/org-required.tsx +++ b/apps/web/src/routes/org-required.tsx @@ -43,10 +43,10 @@ function OrgRequiredPageClerk() { return ( -

    Organization required

    -

    + Organization required + Select or create an organization before entering the app. -

    +
    Select or create organization} diff --git a/apps/web/src/routes/recommendations/$recommendationKey.tsx b/apps/web/src/routes/recommendations/$recommendationKey.tsx index 9f2bcb453a..3777ae80f3 100644 --- a/apps/web/src/routes/recommendations/$recommendationKey.tsx +++ b/apps/web/src/routes/recommendations/$recommendationKey.tsx @@ -1,4 +1,4 @@ -import { SectionHeader } from "@/components/layout/section-header" +import { SectionHeading } from "@/components/common/section-heading" import { ResourceNotFound } from "@/components/common/resource-not-found" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { createFileRoute, Link } from "@tanstack/react-router" @@ -10,9 +10,9 @@ import { toastManager } from "@maple/ui/components/ui/toast" import type { V2Recommendation } from "@maple/domain/http/v2" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { ResultPage } from "@/components/layout/result-page" +import { DetailHeader } from "@/components/common/detail-header" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { displayError } from "@/lib/error-messages" import { ingestAttributeMappingsListAtom, recommendationIssuesListAtom, @@ -22,7 +22,8 @@ import { RelativeTime } from "@/components/common/relative-time" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" +import { Panel } from "@maple/ui/components/ui/panel" +import { formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { ArrowRotateAnticlockwiseIcon, @@ -165,94 +166,85 @@ function RecommendationDetailPage() { } } - return Result.builder(listResult) - .onInitial(() => ) - .onError((error) => ) - .onSuccess(() => { - if (!issue) return - return ( - issue} + crumb={(issue) => `Recommendation #${issue.number}`} + width="narrow" + gap="lg" + errorTitle="Couldn't load recommendation" + onRetry={refreshIssues} + loading={ + <> + + + + + } + notFound={ + } + title="Recommendation not found" + description="This recommendation isn't in your list anymore. It may have resolved on its own." + backLink={} + backLabel="Back to recommendations" + /> + } + header={(issue) => { + const status = STATUS_BADGE[issue.status] + return ( + + {status.label} + + } + /> + ) + }} + rightPanel={(issue) => ( + handleApply(issue)} - onDismiss={() => dismiss({ params: { id: issue.id } })} - onReopen={() => reopen({ params: { id: issue.id } })} + isApplyable={isApplyable(issue)} + isLive={isLive(issue)} + onApply={() => void handleApply(issue)} + onDismiss={() => void dismiss({ params: { id: issue.id } })} + onReopen={() => void reopen({ params: { id: issue.id } })} /> - ) - }) - .render() + )} + > + {(issue) => ( + <> + + + + {isApplyable(issue) && issue.canonical_key ? ( + + ) : ( + + )} + + )} + + ) } -/* ------------------------------------------------------------------------------------------------- - * Detail view - * -------------------------------------------------------------------------------------------------*/ - -function DetailView({ - issue, - busy, - onApply, - onDismiss, - onReopen, -}: { - issue: V2Recommendation - busy: BusyAction - onApply: () => void - onDismiss: () => void - onReopen: () => void -}) { - const status = STATUS_BADGE[issue.status] - const isApplyable = issue.kind === "rename" && Boolean(issue.canonical_key) - const isLive = issue.status === "applied" || issue.status === "resolved" +const isApplyable = (issue: V2Recommendation) => issue.kind === "rename" && Boolean(issue.canonical_key) +const isLive = (issue: V2Recommendation) => issue.status === "applied" || issue.status === "resolved" - return ( - - - - - - - {recSentence(issue)} - - {status.label} - -
    - } - /> - - -
    - - - - {isApplyable && issue.canonical_key ? ( - - ) : ( - - )} -
    -
    - - - - - - - ) +/** `recSentence` as plain text, for the truncated title's tooltip. */ +function recTitleText(issue: V2Recommendation): string { + if (issue.kind === "double-emission") { + return `Standardize on ${issue.canonical_key}, spans also emit ${issue.source_key}` + } + if (issue.kind === "naming") return `Rename non-conforming key ${issue.source_key}` + return `Rename ${issue.source_key} → ${issue.canonical_key}` } /** Plain-language explanation of the recommendation, with mono-styled keys. */ @@ -285,7 +277,7 @@ function Summary({ issue }: { issue: V2Recommendation }) { ) } - return

    {body}

    + return

    {body}

    } /** Before → after card — the deprecated key today vs. the key Maple writes. */ @@ -308,8 +300,8 @@ function ChangeBreakdown({ issue }: { issue: V2Recommendation }) { return (
    - -
    + +
    @@ -319,7 +311,7 @@ function ChangeBreakdown({ issue }: { issue: V2Recommendation }) {
    - {issue.usage_count.toLocaleString()} spans · 24h + {formatNumber(issue.usage_count)} spans · 24h
    {issue.canonical_key ? ( @@ -333,7 +325,7 @@ function ChangeBreakdown({ issue }: { issue: V2Recommendation }) {
    ) : null} -
    + {note ?

    {note}

    : null} ) @@ -371,15 +363,15 @@ function MappingBlock({ issue, isLive }: { issue: V2Recommendation; isLive: bool return (
    - -
    + +
    {isLive ? "This mapping is live" : "Ingest attribute mapping"}
    -
    +
    when @@ -395,7 +387,7 @@ function MappingBlock({ issue, isLive }: { issue: V2Recommendation; isLive: bool
    -
    +
    ) } @@ -403,8 +395,8 @@ function MappingBlock({ issue, isLive }: { issue: V2Recommendation; isLive: bool function SdkFixBlock({ issue }: { issue: V2Recommendation }) { return (
    - -
    + +

    Rename {issue.source_key} {issue.canonical_key ? ( @@ -418,7 +410,7 @@ function SdkFixBlock({ issue }: { issue: V2Recommendation }) { where your spans are created (the instrumentation / SDK). Once the conforming key appears on incoming spans, this recommendation resolves automatically.

    -
    +
    ) } @@ -465,7 +457,7 @@ function DetailSidebar({ - {issue.usage_count.toLocaleString()} + {formatNumber(issue.usage_count)} ) } - -/* ------------------------------------------------------------------------------------------------- - * Shells (loading / error / inactive) - * -------------------------------------------------------------------------------------------------*/ - -function ShellLayout({ children }: { children: React.ReactNode }) { - return ( - - - - - {children} - - - - ) -} - -function LoadingShell() { - return ( - -
    - - - -
    -
    - ) -} - -function ErrorShell({ message }: { message: string }) { - return ( - - - - - - - Couldn't load recommendation - {message} - - - - ) -} - -function InactiveShell() { - return ( - - } - title="Recommendation not found" - description="This recommendation isn't in your list anymore. It may have resolved on its own." - backLink={} - backLabel="Back to recommendations" - /> - - ) -} diff --git a/apps/web/src/routes/releases/$commitSha.tsx b/apps/web/src/routes/releases/$commitSha.tsx index 4d91df2f14..ce274b6d82 100644 --- a/apps/web/src/routes/releases/$commitSha.tsx +++ b/apps/web/src/routes/releases/$commitSha.tsx @@ -3,8 +3,8 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Button } from "@maple/ui/components/ui/button" -import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" -import { PageLayout } from "@maple/ui/components/ui/page-layout" +import { Panel } from "@maple/ui/components/ui/panel" +import { formatPercent } from "@maple/ui/lib/format" import { ServiceDot } from "@maple/ui/components/service-dot" import { OptionalStringArrayParam } from "@/lib/search-params" @@ -14,6 +14,9 @@ import { RelativeTime } from "@/components/common/relative-time" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getReleaseDetailResultAtom, getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { SegmentedSelect } from "@/components/common/segmented-select" +import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { MetricsGrid } from "@/components/dashboard/metrics-grid" import { APDEX_HINT } from "@/components/dashboard/chart-hints" @@ -24,8 +27,6 @@ import { pickTimeRangeSearch, } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" import { useCommitMarkers } from "@/components/vcs/commit-markers/use-commit-markers" import type { ReleasePoint } from "@/components/vcs/commit-markers/marker-layout" @@ -110,15 +111,6 @@ const RELEASE_CHARTS: ReleaseChartConfig[] = [ const EMPTY_RELEASES: ReadonlyArray = [] -function ReleaseDetailPage() { - const search = Route.useSearch() - return ( - - - - ) -} - /** Commit message as the page title once the sha resolves; the short sha until then. */ function ReleaseTitle({ commitSha }: { commitSha: string }) { if (!isResolvableSha(commitSha)) { @@ -175,7 +167,7 @@ function ResolvedReleaseMeta({ commitSha }: { commitSha: string }) { )) } -function ReleaseDetailContent() { +function ReleaseDetailPage() { const { commitSha } = Route.useParams() const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) @@ -186,10 +178,7 @@ function ReleaseDetailContent() { ) const handleTimeChange = useCallback( - ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev: Record) => applyTimeRangeSearch(prev, range), @@ -202,83 +191,65 @@ function ReleaseDetailContent() { const service = search.service return ( - - - - - - - - - } - > -
    - {service ? ( - - ) : null} - -
    -
    -
    - - {service === undefined ? ( - - ) : ( - + + + } + headerActions={ + service ? ( + + ) : undefined + } + time={{ + search, + defaultPreset: DEFAULT_PRESET, + onChange: handleTimeChange, + presets: LONG_RANGE_PRESET_OPTIONS, + maxRangeSeconds: ONE_YEAR_SECONDS, + }} + > + {service === undefined ? ( + + ) : ( + + )} + ) } @@ -394,10 +365,12 @@ function ReleaseBodyLoaded({ if (impact === undefined) { return (
    - - {shortReleaseLabel(commitSha)} served no traffic on{" "} - {serviceName} in this window. Widen the time range to include its deploy. - + + + {shortReleaseLabel(commitSha)} served no traffic on{" "} + {serviceName} in this window. Widen the time range to include its deploy. + + first seen{" "} - {" "} - on {serviceName} + on{" "} + {serviceName} {impact.environment ? {impact.environment} : null} {impact.share !== undefined ? ( - {Math.round(impact.share * 100)}% of the latest traffic + {formatPercent(impact.share)} of the latest traffic ) : null} {impact.health === "healthy" ? null : ( @@ -448,26 +416,20 @@ function ReleaseBodyLoaded({
    - + size="sm" + aria-label="Chart series" value={series} - onValueChange={(value) => setSeries(value === "others" ? "others" : "version")} - > - - - This version - - - {baselineCommitSha === undefined ? "Other versions" : "Previous version"} - - - - + onChange={setSeries} + options={[ + { value: "version", label: "This version" }, + { + value: "others", + label: baselineCommitSha === undefined ? "Other versions" : "Previous version", + }, + ]} + /> + {series === "version" ? `Only spans that carried ${shortReleaseLabel(commitSha)}` : baselineCommitSha === undefined diff --git a/apps/web/src/routes/releases/index.tsx b/apps/web/src/routes/releases/index.tsx index 2b103bb749..8d4b20e2bf 100644 --- a/apps/web/src/routes/releases/index.tsx +++ b/apps/web/src/routes/releases/index.tsx @@ -4,14 +4,16 @@ import { Schema } from "effect" import { Button } from "@maple/ui/components/ui/button" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ActiveFilterChips } from "@maple/ui/components/filters/active-filter-chips" -import { formatNumber, pluralize } from "@maple/ui/lib/format" +import { formatNumber, formatRate, pluralize } from "@maple/ui/lib/format" +import { Panel } from "@maple/ui/components/ui/panel" import { OptionalStringArrayParam } from "@/lib/search-params" import { Result, useAtomRefresh } from "@/lib/effect-atom" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { DocsLink } from "@/components/common/docs-link" import { @@ -20,8 +22,6 @@ import { pickTimeRangeSearch, } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" import { ReleasesFilterSidebar } from "@/components/releases/releases-filter-sidebar" import { RELEASES_DEFAULT_PRESET, releasesQueryInput } from "@/components/releases/releases-query-input" @@ -80,10 +80,7 @@ function ReleasesPage() { search.timePreset ?? DEFAULT_PRESET, ) - const handleTimeChange = ( - range: { startTime?: string; endTime?: string; presetValue?: string }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev: Record) => applyTimeRangeSearch(prev, range), @@ -123,36 +120,22 @@ function ReleasesPage() { })) return ( - - - - - - - - - - - - - - - - - - - - - + } + > + + + ) } @@ -201,16 +184,19 @@ function ReleasesContent({ search }: { search: ReleasesSearchParams }) { if (groups.length === 0) { return ( - - No releases detected in this window. - - Releases compare errors and latency before and after each deploy. Release tracking needs - spans to carry the vcs.ref.head.revision resource attribute. - - - - - + + + No releases detected in this window. + + Releases compare errors and latency before and after each deploy. Release tracking + needs spans to carry the vcs.ref.head.revision resource + attribute. + + + + + + ) } @@ -229,7 +215,7 @@ function ReleasesContent({ search }: { search: ReleasesSearchParams }) { - {(groups.length / windowDays).toLocaleString(undefined, { maximumFractionDigits: 1 })} + {formatRate(groups.length / windowDays)} {" "} per day @@ -259,16 +245,18 @@ function ReleasesContent({ search }: { search: ReleasesSearchParams }) { environments={search.environments} /> {visibleGroups.length === 0 ? ( - - No releases match the health filter. - - + + + No releases match the health filter. + + + ) : ( {capped ? ( - + Issue counts cover the 100 most recently active issues introduced in this window. ) : null} diff --git a/apps/web/src/routes/replays/$sessionId.tsx b/apps/web/src/routes/replays/$sessionId.tsx index 5b5e957d16..129c4d73c6 100644 --- a/apps/web/src/routes/replays/$sessionId.tsx +++ b/apps/web/src/routes/replays/$sessionId.tsx @@ -6,7 +6,7 @@ import { Schema } from "effect" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { ResultPage } from "@/components/layout/result-page" import { ReplayStudio } from "@/components/replays/replay-studio" import { Result, useAtomValue } from "@/lib/effect-atom" import { @@ -14,7 +14,6 @@ import { getReplayResultAtom, getSessionTranscriptResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" -import { ErrorState } from "@/components/common/error-state" import { ReplayDetailSkeleton } from "@/components/replays/session-detail-parts" import { replayPartitionWindow } from "@/components/replays/replay-format" @@ -55,73 +54,36 @@ function ReplayDetailPage() { const window = useMemo(() => replayPartitionWindow(t), [t]) const detailResult = useAtomValue(getReplayResultAtom({ data: { sessionId, ...window } })) - const breadcrumbs = [{ label: "Session Replays", href: "/replays" }, { label: shortId(sessionId, "session", { length: 8 }) }] + // The studio owns its scrolling once loaded; the other states scroll like any page. + const loaded = Result.isSuccess(detailResult) && Boolean(detailResult.value.data) - return Result.builder(detailResult) - .onInitial(() => ( - - - - - - - - - - - )) - .onError((error) => ( - - - - - - - - - - - )) - .onSuccess((detail) => { - const session = detail.data - if (!session) { - return ( - - - - - - - No metadata for session {sessionId}. It may - have expired or not been ingested yet. - - - - - - ) + return ( + detail.data} + crumb={() => shortId(sessionId, "session", { length: 8 })} + fill={loaded} + errorTitle="Failed to load session replay" + loading={} + notFound={ + + No metadata for session {sessionId}. It may have expired or not + been ingested yet. + } - - return ( - - - - - {/* No sticky page header: the studio's identity bar is the header, - and `Fill` hands the studio the full height so the player, the - timeline and the rail manage their own scrolling. */} - - - - - - - ) - }) - .render() + > + {/* No sticky page header: the studio's identity bar is the header, and `Fill` + hands the studio the full height so the player, the timeline and the rail + manage their own scrolling. */} + {(session) => ( + + )} + + ) } diff --git a/apps/web/src/routes/replays/index.tsx b/apps/web/src/routes/replays/index.tsx index bb0b152da0..2b05a6d078 100644 --- a/apps/web/src/routes/replays/index.tsx +++ b/apps/web/src/routes/replays/index.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { SessionsList } from "@/components/replays/sessions-list" import { ActiveUserFilter } from "@/components/replays/active-user-filter" import { ReplaysFilterSidebar } from "@/components/replays/replays-filter-sidebar" @@ -17,8 +17,6 @@ import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { listReplaysResultAtom, replaysFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import type { TimeRange } from "@/components/time-range-picker/types" import { ErrorState } from "@/components/common/error-state" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -220,13 +218,6 @@ function ReplaysPage() { Analytics -
    ) @@ -254,80 +245,63 @@ function ReplaysPage() { ) return ( - - - - - - - - - - {headerActions} - {toolbar} - - - {search.userId && ( - handleUserFilter(undefined)} - /> - )} - {search.visitorId && ( - handleVisitorFilter(undefined)} - /> - )} - {Result.builder(firstPageResult) - .onInitial(() => ( -
    - {Array.from({ length: 8 }).map((_, i) => ( -
    -
    - - -
    - -
    - ))} -
    - )) - .onError((error) => ( - - )) - .onSuccess(() => ( - { - const next = nextTagSelection(selectedTags, [ - ...selectedTags, - tag, - ]) - navigate({ search: (prev) => ({ ...prev, tags: next }) }) - }} - onFilterGroup={(group) => - navigate({ search: (prev) => ({ ...prev, group }) }) - } - /> - )) - .render()} -
    -
    -
    -
    -
    + } + headerActions={headerActions} + time={{ search, startTime, endTime, defaultPreset: "24h", onChange: handleTimeChange }} + sticky={toolbar} + > + {search.userId && ( + handleUserFilter(undefined)} + /> + )} + {search.visitorId && ( + handleVisitorFilter(undefined)} + /> + )} + {Result.builder(firstPageResult) + .onInitial(() => ( +
    + {Array.from({ length: 8 }).map((_, i) => ( +
    +
    + + +
    + +
    + ))} +
    + )) + .onError((error) => ) + .onSuccess(() => ( + { + const next = nextTagSelection(selectedTags, [...selectedTags, tag]) + navigate({ search: (prev) => ({ ...prev, tags: next }) }) + }} + onFilterGroup={(group) => navigate({ search: (prev) => ({ ...prev, group }) })} + /> + )) + .render()} +
    ) } diff --git a/apps/web/src/routes/select-plan.tsx b/apps/web/src/routes/select-plan.tsx index 4279d634ea..72367e7fe2 100644 --- a/apps/web/src/routes/select-plan.tsx +++ b/apps/web/src/routes/select-plan.tsx @@ -84,7 +84,7 @@ function SelectPlanPageInner() { {TRIAL_DURATION_DAYS}-day free trial diff --git a/apps/web/src/routes/service-map.tsx b/apps/web/src/routes/service-map.tsx index 085111fbc0..b2a07148e8 100644 --- a/apps/web/src/routes/service-map.tsx +++ b/apps/web/src/routes/service-map.tsx @@ -7,7 +7,8 @@ import { Result, useAtomRefresh } from "@/lib/effect-atom" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getServicesFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ServiceMapView } from "@/components/service-map/service-map-view" import type { DeclutterFocus } from "@maple/ui/components/service-map/service-map-declutter" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" @@ -102,14 +103,7 @@ function ServiceMapContent() { [environments], ) - const handleTimeChange = ( - range: { - startTime?: string - endTime?: string - presetValue?: string - }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => applyTimeRangeSearch(prev, range), @@ -142,88 +136,80 @@ function ServiceMapContent() { }) } + // No `time` prop: the refresh provider sits above this component (ServiceMapPage), so the + // facets atom read here is page-refreshable too. return ( - - - - - - - {/* Wraps, and below the header's side-by-side breakpoint the - environment select takes a row of its own: all three controls - on one narrow row left it ~70px, and unwrapped they stacked - into a ragged two-line block. */} -
    - { - const view = values[0] - if (view === "2d" || view === "3d") - void navigate({ search: (prev) => ({ ...prev, view }) }) - }} - > - - 2D - - - 3D - - - - -
    -
    -
    - - {Result.isFailure(facetsResult) ? ( - - ) : ( -
    - -
    - )} -
    -
    -
    -
    + + { + const view = values[0] + if (view === "2d" || view === "3d") + void navigate({ search: (prev) => ({ ...prev, view }) }) + }} + > + + 2D + + + 3D + + + + +
    + } + > + {Result.isFailure(facetsResult) ? ( + + ) : ( +
    + +
    + )} + ) } diff --git a/apps/web/src/routes/services/$serviceName.tsx b/apps/web/src/routes/services/$serviceName.tsx index ed0b8a4d74..ce2b78f6f3 100644 --- a/apps/web/src/routes/services/$serviceName.tsx +++ b/apps/web/src/routes/services/$serviceName.tsx @@ -4,6 +4,8 @@ import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { Option, Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { UnnamedServiceHint, isUnnamedService } from "@/components/services/unnamed-service-hint" import { ErrorState } from "@/components/common/error-state" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" @@ -22,8 +24,6 @@ import { useCommitMarkers } from "@/components/vcs/commit-markers/use-commit-mar import type { ReleasePoint } from "@/components/vcs/commit-markers/marker-layout" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { Button } from "@maple/ui/components/ui/button" import { BellIcon } from "@/components/icons" import { ServiceDependenciesTab } from "@/components/services/service-dependencies-tab" @@ -37,7 +37,6 @@ import { ServiceTopOperationsPanel } from "@/components/services/service-top-ope import { ServiceUsagePanel } from "@/components/services/service-usage-panel" import { ServiceWorkloadsPanel } from "@/components/services/service-workloads-panel" import { OptionalStringArrayParam } from "@/lib/search-params" -import { PageLayout } from "@maple/ui/components/ui/page-layout" import { ServiceDot } from "@maple/ui/components/service-dot" import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" @@ -116,15 +115,6 @@ const SERVICE_CHARTS: ServiceChartConfig[] = [ ] function ServiceDetailPage() { - const search = Route.useSearch() - return ( - - - - ) -} - -function ServiceDetailContent() { const { serviceName } = Route.useParams() const search = Route.useSearch() // Links minted before the services list labelled the empty environment carried @@ -140,14 +130,7 @@ function ServiceDetailContent() { ) const handleTimeChange = useCallback( - ( - range: { - startTime?: string - endTime?: string - presetValue?: string - }, - options?: { replace?: boolean }, - ) => { + (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev: Record) => applyTimeRangeSearch(prev, range), @@ -193,141 +176,99 @@ function ServiceDetailContent() { const handleShowOperations = useCallback(() => handleTabChange("operations"), [handleTabChange]) return ( - - - - - - - - {serviceName} - {isUnnamedService(serviceName) && } - - } - > -
    - {/* View switch lives inline with other page controls so it reads as a - perspective toggle, not a navigation bar. Sized to match the time - picker buttons (h-7) and tucked left of them so the visual order is: - "what view → what window → what action". */} - - - - Overview - - - API - - - Operations - - - Dependencies - - - - {/* Env scope drives every tab — the Dependencies bundle takes the same - single-select value via its deploymentEnv field (see - toSingleDeploymentEnv). */} - -
    - - -
    -
    -
    -
    - - {activeTab === "overview" && ( - - )} - {activeTab === "api" && ( - - )} - {activeTab === "operations" && ( - - )} - {activeTab === "dependencies" && ( - - )} - -
    -
    -
    + + + {serviceName} + {isUnnamedService(serviceName) && } + + } + headerActions={ + <> + {/* Env scope drives every tab: the Dependencies bundle takes the same + single-select value via its deploymentEnv field (see toSingleDeploymentEnv). */} + + + + } + time={{ + search, + defaultPreset: "12h", + onChange: handleTimeChange, + presets: LONG_RANGE_PRESET_OPTIONS, + maxRangeSeconds: ONE_YEAR_SECONDS, + }} + tabs={ + + + Overview + API + Operations + Dependencies + + + } + > + {activeTab === "overview" && ( + + )} + {activeTab === "api" && ( + + )} + {activeTab === "operations" && ( + + )} + {activeTab === "dependencies" && ( + + )} + ) } diff --git a/apps/web/src/routes/services/index.tsx b/apps/web/src/routes/services/index.tsx index a91f1537d2..a9efed509e 100644 --- a/apps/web/src/routes/services/index.tsx +++ b/apps/web/src/routes/services/index.tsx @@ -9,7 +9,8 @@ import { getServiceOverviewResultAtom, getServicesFacetsResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { ServicesTable } from "@/components/services/services-table" import { ActiveFilterChips } from "@maple/ui/components/filters/active-filter-chips" import { serviceFilterChips } from "@/lib/services-list/service-filter-chips" @@ -17,8 +18,6 @@ import { useGlobalNamespace } from "@/hooks/use-global-namespace" import { ServicesFilterSidebar } from "@/components/services/services-filter-sidebar" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" @@ -103,14 +102,7 @@ function ServicesPage() { search.timePreset ?? "12h", ) - const handleTimeChange = ( - range: { - startTime?: string - endTime?: string - presetValue?: string - }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev: Record) => applyTimeRangeSearch(prev, range), @@ -118,53 +110,40 @@ function ServicesPage() { } return ( - - - - - - - - - - - - - - - - pinnedNamespace === null || - (chip.param !== "namespaces" && - chip.param !== "excludedNamespaces"), - ) - .map((chip) => ({ - id: chip.param, - label: chip.label, - values: chip.values, - negated: chip.negated, - onRemove: () => - navigate({ - search: (prev) => ({ ...prev, [chip.param]: undefined }), - }), - }))} - /> - - - - - - + } + > + + pinnedNamespace === null || + (chip.param !== "namespaces" && chip.param !== "excludedNamespaces"), + ) + .map((chip) => ({ + id: chip.param, + label: chip.label, + values: chip.values, + negated: chip.negated, + onRemove: () => + navigate({ + search: (prev) => ({ ...prev, [chip.param]: undefined }), + }), + }))} + /> + + ) } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 4660cb03c9..ed7629b86b 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,8 +1,9 @@ import { Navigate, useNavigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { EmptyMessage } from "@maple/ui/components/ui/empty" import { BillingSection } from "@/components/settings/billing-section" import { MembersSection } from "@/components/settings/members-section" @@ -75,77 +76,56 @@ function SettingsPage() { // renders as soon as the session resolves. const waitingForGatedTab = search.tab === "data-platform" && isCustomerLoading - if (isLoading || waitingForGatedTab) { - return ( - - - - - -
    - - -
    -
    -
    -
    -
    - ) - } - - if (visibleItems.length === 0) { - return ( - - - - - -

    - No settings are available for the current account. -

    -
    -
    -
    -
    - ) - } + const loading = isLoading || waitingForGatedTab + const empty = !loading && visibleItems.length === 0 + // One shell for every state, so the sidebar reconciles in place as the session resolves. return ( - - - - + - - - - {activeTab === "organization" && } - {activeTab === "members" && } - {activeTab === "audit-log" && } - {activeTab === "setup-audit" && } - {activeTab === "ingestion" && } - {activeTab === "api-keys" && } - {activeTab === "mcp" && } - {activeTab === "notifications" && } - {activeTab === "automation" && ( - - )} - {activeTab === "billing" && } - {activeTab === "data-platform" && ( - - )} - - - - + ) + } + > + {loading ? ( +
    + + +
    + ) : empty ? ( + No settings are available for the current account. + ) : ( + <> + {activeTab === "organization" && } + {activeTab === "members" && } + {activeTab === "audit-log" && } + {activeTab === "setup-audit" && } + {activeTab === "ingestion" && } + {activeTab === "api-keys" && } + {activeTab === "mcp" && } + {activeTab === "notifications" && } + {activeTab === "automation" && ( + + )} + {activeTab === "billing" && } + {activeTab === "data-platform" && ( + + )} + + )} + ) } diff --git a/apps/web/src/routes/share/$token.tsx b/apps/web/src/routes/share/$token.tsx index 0a4f596dac..2158bee4d5 100644 --- a/apps/web/src/routes/share/$token.tsx +++ b/apps/web/src/routes/share/$token.tsx @@ -51,6 +51,7 @@ import { import { MapleMark } from "@maple/ui/components/icons/maple-mark" import { Button } from "@maple/ui/components/ui/button" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" const ShareSearch = Schema.StructWithRest( Schema.Struct({ @@ -279,7 +280,7 @@ function ShareSkeleton() { {[0, 1, 2, 3].map((index) => ( // The skeleton draws its own card because there is no visualization // mounted yet to draw one — unlike the real tiles, which do. - + ))}
    ) @@ -348,13 +349,13 @@ function CenteredCard({ children?: React.ReactNode }) { return ( -
    -
    -

    {title}

    -

    {body}

    - {children ?
    {children}
    : null} -
    -
    + + + {title} + {body} + + {children ? {children} : null} + ) } diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx index 2d68be45dd..28fc045a89 100644 --- a/apps/web/src/routes/sign-in.tsx +++ b/apps/web/src/routes/sign-in.tsx @@ -5,6 +5,7 @@ import { FormEvent, useState } from "react" import { Schema } from "effect" import { Button } from "@maple/ui/components/ui/button" import { Input } from "@maple/ui/components/ui/input" +import { Field, FieldError, FieldLabel } from "@maple/ui/components/ui/field" import { validateInternalRedirect } from "@maple/ui/lib/sanitizers" import { apiBaseUrl } from "@/lib/services/common/api-base-url" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" @@ -90,17 +91,11 @@ export function SelfHostedSignInPage() {
    {/* Label and field are one group: the gap between them stays tighter * than the gap to the next control, or the pairing stops reading. */} -
    - + setPassword(event.target.value)} @@ -109,11 +104,11 @@ export function SelfHostedSignInPage() { required /> {errorMessage ? ( - + ) : null} -
    + diff --git a/apps/web/src/routes/traces/$traceId.tsx b/apps/web/src/routes/traces/$traceId.tsx index c7d8eb7f36..59e5b3a5f0 100644 --- a/apps/web/src/routes/traces/$traceId.tsx +++ b/apps/web/src/routes/traces/$traceId.tsx @@ -1,15 +1,15 @@ import { warmAtoms } from "@effect-router/core" import * as React from "react" import { useNavigate, useRouterState, createFileRoute } from "@tanstack/react-router" -import { Result, useAtomValue } from "@/lib/effect-atom" +import { useAtomValue } from "@/lib/effect-atom" import { Schema } from "effect" import { TraceId } from "@maple/domain" import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { ResultPage } from "@/components/layout/result-page" import { useAppHotkey } from "@/hooks/use-app-hotkey" import { TraceReplayLink } from "@/components/replays/trace-replay-link" import { TraceLogsLink } from "@/components/traces/trace-logs-link" -import { ErrorState } from "@/components/common/error-state" import { DocsLink } from "@/components/common/docs-link" import { ResourceNotFound } from "@/components/common/resource-not-found" import { TraceViewTabs } from "@maple/ui/components/traces/trace-view-tabs" @@ -71,75 +71,70 @@ function TraceDetailPage() { }), ) - return Result.builder(result) - .onInitial(() => ( - - - - - -
    -
    - - -
    - - - -
    -
    - ( -
    - - - - - - -
    - )} - /> -
    -
    -
    -
    -
    - )) - .onError((error) => ( - - { + const traceStartTime = data.traceStartTime + if (data.spans.length === 0 || traceStartTime === undefined) return null + return { data, traceStartTime, rootSpan: data.rootSpans[0] } + }} + crumb={() => shortId(traceId, "trace")} + errorTitle="Failed to load trace details" + loading={} + notFound={ + How long traces are kept} /> - - - - - - - - - )) - .onSuccess((data) => { - if (data.spans.length === 0 || data.traceStartTime === undefined) { - return ( - How long traces are kept} - /> + } + titleContent={({ rootSpan }) => { + if (!rootSpan) return undefined + return getHttpInfo(rootSpan) ? ( + + + + ) : ( + // The breadcrumb only carries the short trace id; the root span name + // is what identifies the trace. + + {rootSpan.spanName ?? "Unknown Trace"} + ) + }} + headerActions={({ data, traceStartTime, rootSpan }) => + rootSpan ? ( +
    + + +
    + ) : undefined } - - if (data.rootSpans.length === 0) { - return ( + > + {({ data, traceStartTime, rootSpan }) => + rootSpan ? ( + + ) : ( ) } + + ) +} - return ( - - ) - }) - .render() +function TraceDetailLoading() { + return ( +
    +
    + + +
    + + + +
    +
    + ( +
    + + + + + + +
    + )} + /> +
    + ) } -function TraceDetailContent({ +function TraceDetailBody({ data, traceStartTime, traceId, - backToTracesHref, + rootSpan, }: { data: SpanHierarchyResponse traceStartTime: string traceId: string - backToTracesHref: string + rootSpan: SpanNode }) { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) @@ -234,12 +251,11 @@ function TraceDetailContent({ /> ) - const rootSpan = data.rootSpans[0] - const rootHttpInfo = rootSpan ? getHttpInfo(rootSpan) : null + const rootHttpInfo = getHttpInfo(rootSpan) const deploymentEnv = - rootSpan?.resourceAttributes?.["deployment.environment.name"] || - rootSpan?.resourceAttributes?.["deployment.environment"] - const commitSha = rootSpan?.resourceAttributes?.["vcs.ref.head.revision"] + rootSpan.resourceAttributes?.["deployment.environment.name"] || + rootSpan.resourceAttributes?.["deployment.environment"] + const commitSha = rootSpan.resourceAttributes?.["vcs.ref.head.revision"] const hasError = data.spans.some((s: Span) => { if (s.statusCode === "Error") return true const httpStatus = @@ -252,129 +268,80 @@ function TraceDetailContent({ }) return ( - - + + + - - - - - - - ) : ( - // The breadcrumb only carries the short trace id; the root span name - // is what identifies the trace. - - {rootSpan?.spanName ?? "Unknown Trace"} - - ) - } - > -
    - +
    {traceViewTabs}
    + { + if (!open) handleCloseSpanDetails() + }} + > + + + Span details + Details for the selected span. + + {selectedSpan && ( + - -
    -
    -
    - -
    - - - - - {isMobile ? ( - // A 60/40 side-by-side split leaves each pane ~150px on a phone. Give the waterfall - // the full width and float the span detail over it instead. - <> -
    - {traceViewTabs} -
    - { - if (!open) handleCloseSpanDetails() - }} - > - - - Span details - - Details for the selected span. - - - {selectedSpan && ( - - )} - - - - ) : ( - - - {traceViewTabs} - - - {selectedSpan && ( - <> - - - - - - )} - )} -
    -
    -
    -
    -
    + + + + ) : ( + + + {traceViewTabs} + + + {selectedSpan && ( + <> + + + + + + )} + + )} +
    ) } +/** Not-found body, shared by "no such trace" and "root span missing". */ function TraceNotFound({ traceId, backToTracesHref, @@ -389,29 +356,17 @@ function TraceNotFound({ footer?: React.ReactNode }) { return ( - - - - - - - -

    {description}

    - {footer} -
    - } - backLink={} - backLabel="Back to Traces" - /> - - - - + + +

    {description}

    + {footer} +
    + } + backLink={} + backLabel="Back to Traces" + /> ) } diff --git a/apps/web/src/routes/traces/index.tsx b/apps/web/src/routes/traces/index.tsx index 641c246fe6..94fb4f12f0 100644 --- a/apps/web/src/routes/traces/index.tsx +++ b/apps/web/src/routes/traces/index.tsx @@ -4,20 +4,21 @@ import { warmAtoms } from "@effect-router/core" import { Schema } from "effect" import { BooleanFromStringParam, OptionalStringArrayParam } from "@/lib/search-params" -import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { DashboardPage } from "@/components/layout/dashboard-page" +import type { TimeRange } from "@/components/time-range-picker/types" import { TracesTable } from "@/components/traces/traces-table" import { TracesFilterSidebar } from "@/components/traces/traces-filter-sidebar" import { AdvancedFilterDialog } from "@/components/traces/advanced-filter-dialog" import { MagnifierIcon, XmarkIcon } from "@/components/icons" -import { Button } from "@maple/ui/components/ui/button" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Panel } from "@maple/ui/components/ui/panel" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { resolveEffectiveTimeRange, useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useAtomValue } from "@/lib/effect-atom" import { applyWhereClause } from "@/lib/traces/advanced-filter-sync" import { getTracesFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" -import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" -import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { AutocompleteValuesProvider } from "@/hooks/use-autocomplete-values" import { ActiveFilterChips } from "@maple/ui/components/filters/active-filter-chips" import { removeTraceFilterChips, traceFilterChips } from "@/lib/traces/trace-filter-chips" @@ -161,14 +162,7 @@ function TracesPage() { }), ) - const handleTimeChange = ( - range: { - startTime?: string - endTime?: string - presetValue?: string - }, - options?: { replace?: boolean }, - ) => { + const handleTimeChange = (range: TimeRange, options?: { replace?: boolean }) => { navigate({ replace: options?.replace, search: (prev) => ({ @@ -182,63 +176,47 @@ function TracesPage() { // keys) are only needed by the advanced-filter editor, which calls // activate() on focus/open — don't fire them on every page mount. - - - - - - - - - - -
    - - -
    -
    -
    - - {search.whereClause && ( -
    -
    - - - {search.whereClause} - -
    - -
    - )} - - -
    -
    -
    -
    -
    + + } + time={{ + search, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + defaultPreset: "12h", + onChange: handleTimeChange, + }} + filters={} + > + {search.whereClause && ( + +
    + + + {search.whereClause} + +
    + handleApplyWhereClause("")} + className="shrink-0 text-muted-foreground hover:text-foreground" + > + + +
    + )} + + +
    ) } diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx index 5133f0a98c..098ef39421 100644 --- a/apps/web/src/routes/unsubscribe.tsx +++ b/apps/web/src/routes/unsubscribe.tsx @@ -36,10 +36,10 @@ function UnsubscribePage() { if (!token) { return ( -

    Invalid unsubscribe link

    -

    + Invalid unsubscribe link + This link is incomplete. Use the unsubscribe link from the email itself. -

    +
    ) } @@ -71,11 +71,11 @@ function UnsubscribeConfirm({ token }: { token: string }) { if (state.kind === "done") { return ( -

    You're unsubscribed

    -

    + You're unsubscribed + You won't receive {label} anymore. You can turn it back on any time in your notification settings. -

    +
    + ) } @@ -224,7 +226,7 @@ export function AttributesTable({ attributes, title, searchQuery, groupByNamespa key={group.namespace} defaultOpen={group.entries.length <= 8 || !!searchQuery} > - + {group.namespace} - + {group.entries.length} @@ -335,7 +337,7 @@ export function AttributesSection({ )} {internalCount > 0 && ( - + {matched.map((group) => (
    -
    +
    {group.label}
    diff --git a/packages/ui/src/components/charts/_shared/__tests__/chart-state.browser.test.tsx b/packages/ui/src/components/charts/_shared/__tests__/chart-state.browser.test.tsx index 31f2f22337..946ad1e4cb 100644 --- a/packages/ui/src/components/charts/_shared/__tests__/chart-state.browser.test.tsx +++ b/packages/ui/src/components/charts/_shared/__tests__/chart-state.browser.test.tsx @@ -73,7 +73,7 @@ describe("empty vs error", () => { expect(emptyClasses).toContain("text-muted-foreground") expect(emptyClasses).not.toContain("destructive") - expect(errorClasses).toContain("text-destructive") + expect(errorClasses).toContain("text-severity-error") // Shared typography — the half that IS supposed to match. expect(emptyClasses).toContain("font-mono") diff --git a/packages/ui/src/components/charts/_shared/chart-skeleton.tsx b/packages/ui/src/components/charts/_shared/chart-skeleton.tsx index 392e1a6300..96aca48575 100644 --- a/packages/ui/src/components/charts/_shared/chart-skeleton.tsx +++ b/packages/ui/src/components/charts/_shared/chart-skeleton.tsx @@ -58,7 +58,7 @@ function Bars({ heights, delay }: { heights: number[]; delay: (i: number) => num {heights.map((h, i) => (
    ))} @@ -120,7 +120,7 @@ function PathsGhost() {
    {PATH_COLUMNS.map((_, i) => ( -
    +
    ))}
    @@ -262,7 +262,7 @@ function renderVariant(variant: ChartSkeletonVariant) { style={{ gridTemplateColumns: "minmax(0, 38%) 1fr 44px" }} >
    @@ -271,7 +271,7 @@ function renderVariant(variant: ChartSkeletonVariant) { style={{ width: `${w}%`, animationDelay: `${-i * 0.13}s` }} />
    -
    +
    ))}
    @@ -284,10 +284,10 @@ function renderVariant(variant: ChartSkeletonVariant) {
    -
    +
    -
    +
    {[100, 44, 20, 8].map((h, i) => { @@ -315,11 +315,11 @@ function renderVariant(variant: ChartSkeletonVariant) {
    -
    +
    {i > 0 && ( diff --git a/packages/ui/src/components/charts/_shared/chart-state.tsx b/packages/ui/src/components/charts/_shared/chart-state.tsx index 66d80a85d2..91f4d1919c 100644 --- a/packages/ui/src/components/charts/_shared/chart-state.tsx +++ b/packages/ui/src/components/charts/_shared/chart-state.tsx @@ -3,6 +3,7 @@ import { createContext, use, type ReactNode } from "react" import { cn } from "../../../lib/utils" +import { TONE_BORDER, TONE_TEXT } from "../../../lib/tone" import { ChartSkeleton, type ChartSkeletonVariant } from "./chart-skeleton" /** @@ -13,7 +14,7 @@ import { ChartSkeleton, type ChartSkeletonVariant } from "./chart-skeleton" * Every async chart renders the same three branches, and before this each one * spelled them out by hand. The result was drift in every dimension that is * supposed to be a constant: `rounded-none` vs `rounded-md` vs `rounded-lg` on - * the skeleton, `font-mono text-[11px]` vs `text-xs` on the message, a + * the skeleton, `font-mono text-2xs` vs `text-xs` on the message, a * `border-destructive/40 bg-destructive/5` error box copied verbatim across four * files. None of that is a per-chart decision, so none of it belongs at a call * site. @@ -132,7 +133,7 @@ export function ChartLoading({ } /** Shared type + layout of the two message states, so they cannot drift apart. */ -const MESSAGE_BASE = "flex items-center justify-center px-3 text-center font-mono text-[11px]" +const MESSAGE_BASE = "flex items-center justify-center px-3 text-center font-mono text-2xs" /** * A chart with nothing to draw — "no data in this window", "not collected yet". @@ -167,7 +168,7 @@ export function ChartEmpty({
    {children} - {hint != null && {hint}} + {hint != null && {hint}}
    ) @@ -230,7 +231,9 @@ export function ChartError({ height={height} className={cn( MESSAGE_BASE, - "border border-destructive/40 bg-destructive/5 text-destructive", + "border bg-severity-error/5", + TONE_BORDER.crit, + TONE_TEXT.crit, className, )} > diff --git a/packages/ui/src/components/charts/_shared/query-builder-legend.tsx b/packages/ui/src/components/charts/_shared/query-builder-legend.tsx index b64b04dc0e..acc1e9c9ad 100644 --- a/packages/ui/src/components/charts/_shared/query-builder-legend.tsx +++ b/packages/ui/src/components/charts/_shared/query-builder-legend.tsx @@ -1,5 +1,5 @@ import { cn } from "../../../lib/utils" -import { formatValueByUnit } from "../../../lib/format" +import { EMPTY_VALUE, formatValueByUnit } from "../../../lib/format" import { isAllZeroStats, type SeriesStats, type StatsSeries } from "../../plot/series-stats" import { PlotLegend, usePlotLegend } from "../../plot/plot-legend" @@ -147,7 +147,7 @@ function CompactStrip({ layout, maxHeight }: { layout: "bottom" | "right"; maxHe )} > {entry.label} @@ -208,7 +208,7 @@ function StatsTable({ @@ -232,7 +232,7 @@ function StatsTable({ > {entryStats ? formatValueByUnit(entryStats[column.field], unit) - : "—"} + : EMPTY_VALUE} )) )} diff --git a/packages/ui/src/components/charts/area/apdex-area-chart.tsx b/packages/ui/src/components/charts/area/apdex-area-chart.tsx index f55ac91130..a5ad563f2a 100644 --- a/packages/ui/src/components/charts/area/apdex-area-chart.tsx +++ b/packages/ui/src/components/charts/area/apdex-area-chart.tsx @@ -21,7 +21,7 @@ import { usePlotLegendSlot, verticalGradient, type FixedMetricSeries, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, type TimeseriesRow, } from "../../plot" @@ -40,8 +40,8 @@ const FILL_OPACITY = 0.6 /** Module scope — see `usePlotColors` on why a fresh literal defeats the memo. */ const APDEX_TOKENS = { - apdexScore: ["--chart-apdex", "#3fb27f"], -} as const satisfies Record + apdexScore: "--chart-apdex", +} as const satisfies Record // Memoized: these charts sit in synced grids whose parent rerenders on every // atom/query settle; with stable props the whole chart subtree is skipped. diff --git a/packages/ui/src/components/charts/area/error-rate-area-chart.tsx b/packages/ui/src/components/charts/area/error-rate-area-chart.tsx index d68ec60982..ed6d1fe14d 100644 --- a/packages/ui/src/components/charts/area/error-rate-area-chart.tsx +++ b/packages/ui/src/components/charts/area/error-rate-area-chart.tsx @@ -22,7 +22,7 @@ import { usePlotLegendSlot, verticalGradient, type FixedMetricSeries, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, type TimeseriesRow, } from "../../plot" @@ -43,8 +43,8 @@ const MIN_CEILING = 0.01 /** Module scope — see `usePlotColors` on why a fresh literal defeats the memo. */ const ERROR_RATE_TOKENS = { - errorRate: ["--chart-error", "#e5484d"], -} as const satisfies Record + errorRate: "--chart-error", +} as const satisfies Record /** * The top of the axis: the worst bucket plus headroom, never below 1% and never diff --git a/packages/ui/src/components/charts/area/throughput-area-chart.tsx b/packages/ui/src/components/charts/area/throughput-area-chart.tsx index 84401933b1..dbd1bf86a0 100644 --- a/packages/ui/src/components/charts/area/throughput-area-chart.tsx +++ b/packages/ui/src/components/charts/area/throughput-area-chart.tsx @@ -22,7 +22,7 @@ import { usePlotLegendSlot, verticalGradient, type FixedMetricSeries, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, type TimeseriesRow, } from "../../plot" @@ -62,9 +62,9 @@ const PARTIAL_ERROR_OPACITY = 0.5 /** Module scope — see `usePlotColors` on why a fresh literal defeats the memo. */ const THROUGHPUT_TOKENS = { - throughput: ["--chart-throughput", "#8b7cf6"], - error: ["--chart-error", "#e5484d"], -} as const satisfies Record + throughput: "--chart-throughput", + error: "--chart-error", +} as const satisfies Record /** * One row with the rate conversion applied and the derived error count attached. @@ -165,7 +165,7 @@ export const ThroughputAreaChart = memo(function ThroughputAreaChart({ }, [colors, hasSampling, hasErrors, rateLabel]) const tooltipSeries = useMemo[]>(() => { - const withSuffix = (value: number) => `${value.toLocaleString()}${rateLabel}` + const withSuffix = (value: number) => formatThroughput(value, rateLabel) const read = (key: string) => (row: TimeseriesRow) => { const value = row[key] return typeof value === "number" ? value : null diff --git a/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx b/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx index 0284ffbe51..d53ed939a2 100644 --- a/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx +++ b/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx @@ -17,7 +17,7 @@ import { trimEmptyTrailingBuckets, usePlotColors, useTimeseriesModel, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, type TimeseriesRow, type TimeseriesSeries, @@ -52,8 +52,8 @@ const DIMMED_PARTIAL_FILL_OPACITY = 0.15 * literal because `OTHER_COLOR` is a `var()` and canvas cannot read one. */ const BAR_TOKENS = { - other: ["--muted-foreground", "#a1a1aa"], -} as const satisfies Record + other: "--muted-foreground", +} as const satisfies Record /** * One bar: a bucket, a series, and the value where the two meet. diff --git a/packages/ui/src/components/charts/funnel/funnel-dropoff-chart.tsx b/packages/ui/src/components/charts/funnel/funnel-dropoff-chart.tsx index dfc8d41517..ee01767a1a 100644 --- a/packages/ui/src/components/charts/funnel/funnel-dropoff-chart.tsx +++ b/packages/ui/src/components/charts/funnel/funnel-dropoff-chart.tsx @@ -2,7 +2,9 @@ import * as React from "react" import type { QueryBuilderFunnelChartProps } from "../_shared/chart-types" import { cn } from "../../../lib/utils" -import { formatNumber, formatValueByUnit } from "../../../lib/format" +import { Eyebrow } from "../../ui/eyebrow" +import { EMPTY_VALUE, formatNumber, formatPercent, formatValueByUnit } from "../../../lib/format" +import { TONE_TEXT } from "../../../lib/tone" import { asFiniteNumber, pickValueField, toBreakdownRows } from "../_shared/breakdown-rows" import { resolveSeriesColors } from "../../../lib/semantic-series-colors" import { useContainerSize } from "../../../hooks/use-container-size" @@ -53,14 +55,9 @@ function fmtValue(value: number, unit?: string): string { return unit ? formatValueByUnit(value, unit) : formatNumber(value) } -function fmtPct(fraction: number): string { - const pct = fraction * 100 - return `${pct.toFixed(pct < 10 && pct > 0 ? 1 : 0)}%` -} - /** Coarse, human durations: the reader wants "2d 3h", not "51.2h". */ function fmtSpan(ms: number): string { - if (!Number.isFinite(ms) || ms <= 0) return "—" + if (!Number.isFinite(ms) || ms <= 0) return EMPTY_VALUE const s = Math.round(ms / 1000) if (s < 60) return `${s}s` const m = Math.round(s / 60) @@ -194,7 +191,7 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q if (steps.length === 0 || (steps[0]?.value ?? 0) <= 0) { return (
    - No data + No data
    ) } @@ -222,7 +219,7 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q onPointerLeave={() => setHover(null)} data-slot="funnel-dropoff" > -
    +
    {isGrouped ? (
    {legend.map((entry) => ( @@ -232,7 +229,7 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q title={entry.name} > {entry.name} @@ -250,9 +247,9 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q {showPercent ? "Conversion rate" : "Converted"} - + {showPercent - ? fmtPct(last.ofFirst) + ? formatPercent(last.ofFirst) : `${fmtValue(last.value, unit)} of ${fmtValue(first.value, unit)}`} @@ -282,7 +279,7 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q
    - {showPercent ? fmtPct(step.ofFirst) : fmtValue(step.value, unit)} + {showPercent ? formatPercent(step.ofFirst) : fmtValue(step.value, unit)} - + {showPercent ? fmtValue(step.value, unit) : index === 0 @@ -363,7 +360,7 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q )} {prev && showPercent && step.ofPrev !== null && ( @@ -372,12 +369,12 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q ) : ( )} - {fmtPct(step.ofPrev)} + {formatPercent(step.ofPrev)} {pillLoss && lostShare > 0 && ( - - {fmtPct(lostShare)} + + {formatPercent(lostShare)} )} {pillTime && step.p50Ms !== undefined && ( @@ -396,13 +393,13 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q })}
    {hidden > 0 && ( -
    +
    +{hidden} more
    )} {hovered && hoveredPrev && !isGrouped && (
    @@ -417,21 +414,25 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q className="bg-severity-info" style={{ width: `${(hovered.ofPrev ?? 0) * 100}%` }} /> -
    +
    } label="converted" value={fmtValue(hovered.value, unit)} - share={fmtPct(hovered.ofPrev ?? 0)} + share={formatPercent(hovered.ofPrev ?? 0)} shareClass="text-success-foreground" /> } + icon={} label="dropped" value={fmtValue(hovered.dropped, unit)} - share={hoveredPrev.value > 0 ? fmtPct(hovered.dropped / hoveredPrev.value) : "—"} - shareClass="text-destructive" + share={ + hoveredPrev.value > 0 + ? formatPercent(hovered.dropped / hoveredPrev.value) + : EMPTY_VALUE + } + shareClass={TONE_TEXT.crit} /> {hovered.p50Ms !== undefined && ( 0 && hovered.dropped > 0 && ( <>
    -
    - + + Dropped here went to -
    + {leaverRows(hovered.leavers, hovered.dropped).map((entry) => (
    @@ -461,7 +462,7 @@ export function FunnelDropoffChart({ data, className, unit, showStepPercent }: Q {entry.name} - {fmtPct(entry.share)} + {formatPercent(entry.share)}
    @@ -504,7 +505,7 @@ function Row({ {value} - {share !== undefined && {share}} + {share !== undefined && {share}}
    ) diff --git a/packages/ui/src/components/charts/funnel/query-builder-funnel-chart.tsx b/packages/ui/src/components/charts/funnel/query-builder-funnel-chart.tsx index 525e554f84..c59a6e46f4 100644 --- a/packages/ui/src/components/charts/funnel/query-builder-funnel-chart.tsx +++ b/packages/ui/src/components/charts/funnel/query-builder-funnel-chart.tsx @@ -2,7 +2,7 @@ import * as React from "react" import type { QueryBuilderFunnelChartProps } from "../_shared/chart-types" import { cn } from "../../../lib/utils" -import { formatNumber, formatValueByUnit } from "../../../lib/format" +import { formatNumber, formatPercent, formatValueByUnit } from "../../../lib/format" import { asFiniteNumber, pickValueField, toBreakdownRows, type BreakdownRow } from "../_shared/breakdown-rows" import { resolveSeriesColors } from "../../../lib/semantic-series-colors" import { useContainerSize } from "../../../hooks/use-container-size" @@ -54,11 +54,6 @@ function fmtValue(value: number, unit?: string): string { return unit ? formatValueByUnit(value, unit) : formatNumber(value) } -function fmtPct(fraction: number): string { - const pct = fraction * 100 - return `${pct.toFixed(pct < 10 && pct > 0 ? 1 : 0)}%` -} - const ROW_GAP = 6 const ROW_MIN_H = 22 /** Label line (~11px) + gap + bar (10px). */ @@ -228,7 +223,7 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde if (stages.length === 0) { return (
    - No data + No data
    ) } @@ -249,7 +244,7 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde > {isGrouped && (
    @@ -266,7 +261,7 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde title={entry.name} > {entry.name} @@ -287,7 +282,7 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde onPointerEnter={() => setHover(i)} > {/* Label row */} -
    +
    @@ -312,7 +307,7 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde {showShareOfFirst && ( <> · - {fmtPct(hoveredGroup.pctOfFirst)} + {formatPercent(hoveredGroup.pctOfFirst)} )} @@ -324,13 +319,13 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde {showShareOfFirst && ( <> · - {fmtPct(stage.pctOfFirst)} + {formatPercent(stage.pctOfFirst)} )} {showStepConversion && stage.pctOfPrev != null && ( <> ↓ - {fmtPct(stage.pctOfPrev)} + {formatPercent(stage.pctOfPrev)} )} @@ -343,13 +338,13 @@ function FunnelBarsChart({ data, className, unit, showStepPercent }: QueryBuilde {stage.groups.map((group) => (
    setHoverGroup(group.name)} onPointerLeave={() => setHoverGroup(null)} title={`${group.name} · ${fmtValue(group.value, unit)}`} >
    0 && ( -
    +
    +{hiddenCount} more
    )} diff --git a/packages/ui/src/components/charts/hbar/query-builder-hbar-chart.tsx b/packages/ui/src/components/charts/hbar/query-builder-hbar-chart.tsx index 565ad3140d..2fefe0377d 100644 --- a/packages/ui/src/components/charts/hbar/query-builder-hbar-chart.tsx +++ b/packages/ui/src/components/charts/hbar/query-builder-hbar-chart.tsx @@ -2,7 +2,7 @@ import * as React from "react" import type { QueryBuilderHbarChartProps } from "../_shared/chart-types" import { cn } from "../../../lib/utils" -import { formatNumber, formatValueByUnit } from "../../../lib/format" +import { formatNumber, formatPercent, formatValueByUnit } from "../../../lib/format" import { pickValueField, toBreakdownRows, type BreakdownRow } from "../_shared/breakdown-rows" import { resolveSeriesColors } from "../../../lib/semantic-series-colors" import { useContainerSize } from "../../../hooks/use-container-size" @@ -28,11 +28,8 @@ function fmtValue(value: number, unit?: string): string { return unit ? formatValueByUnit(value, unit) : formatNumber(value) } -function fmtPct(fraction: number): string { - const pct = fraction * 100 - if (pct > 0 && pct < 0.1) return "<0.1%" - return `${pct.toFixed(pct < 10 && pct > 0 ? 1 : 0)}%` -} +// A sliver of the total reads "<0.1%", never a misleading "0%". +const PCT_FLOOR = { floor: 0.001 } const ROW_GAP = 6 const ROW_MIN_H = 18 @@ -108,7 +105,7 @@ export function QueryBuilderHbarChart({ data, className, unit }: QueryBuilderHba if (bars.length === 0) { return (
    - No data + No data
    ) } @@ -139,7 +136,7 @@ export function QueryBuilderHbarChart({ data, className, unit }: QueryBuilderHba // Label / track / value: the value column is sized by its content // and right-aligned, so the numbers line up down the panel however // long the category names are. - className="grid min-h-0 shrink-0 items-center gap-2 text-[11px] leading-none" + className="grid min-h-0 shrink-0 items-center gap-2 text-2xs leading-none" style={{ gridTemplateColumns: "minmax(0, 38%) 1fr max-content", height: rowH, @@ -172,7 +169,7 @@ export function QueryBuilderHbarChart({ data, className, unit }: QueryBuilderHba {fmtValue(bar.value, unit)} · - {fmtPct(bar.pctOfTotal)} + {formatPercent(bar.pctOfTotal, PCT_FLOOR)}
    ) @@ -180,7 +177,7 @@ export function QueryBuilderHbarChart({ data, className, unit }: QueryBuilderHba
    {hiddenCount > 0 && (
    diff --git a/packages/ui/src/components/charts/heatmap/query-builder-heatmap-chart.tsx b/packages/ui/src/components/charts/heatmap/query-builder-heatmap-chart.tsx index 9e653c04d3..96b42b9fff 100644 --- a/packages/ui/src/components/charts/heatmap/query-builder-heatmap-chart.tsx +++ b/packages/ui/src/components/charts/heatmap/query-builder-heatmap-chart.tsx @@ -10,7 +10,7 @@ import { type HeatmapScaleType, } from "@maple/domain/http" import { useContainerSize } from "../../../hooks/use-container-size" -import { formatNumber, formatValueByUnit } from "../../../lib/format" +import { countLabel, formatNumber, formatValueByUnit } from "../../../lib/format" import { cn } from "../../../lib/utils" import { PlotFrame, @@ -18,7 +18,7 @@ import { resolveSequentialDomain, usePlotColors, usePlotTimeZone, - type PlotColorToken, + type PlotColorSource, } from "../../plot" import type { QueryBuilderHeatmapChartProps } from "../_shared/chart-types" @@ -131,7 +131,7 @@ const RAMP_STOP_TOKENS = { cividis2: ["--heatmap-cividis-2", "oklch(0.58 0.03 130)"], cividis3: ["--heatmap-cividis-3", "oklch(0.71 0.075 95)"], cividis4: ["--heatmap-cividis-4", "oklch(0.85 0.13 95)"], -} as const satisfies Record<`${HeatmapColorScale}${0 | 1 | 2 | 3 | 4}`, readonly [PlotColorToken, string]> +} as const satisfies Record<`${HeatmapColorScale}${0 | 1 | 2 | 3 | 4}`, PlotColorSource> type RampColors = Readonly> @@ -143,10 +143,10 @@ const RAMP_INDICES = [0, 1, 2, 3, 4] as const * literal per render would re-read computed style on every frame. */ const HEATMAP_CHROME_TOKENS = { - foreground: ["--foreground", "#fafafa"], + foreground: "--foreground", /** The recessed surface a hole is a hole *in*. */ grout: ["--heatmap-grout", "oklch(0.175 0.008 62)"], -} as const satisfies Record +} as const satisfies Record /** * Whether a persisted `colorScale` still names a ramp we ship. @@ -641,7 +641,7 @@ function HeatmapLegend({ {Array.from({ length: LEGEND_STEPS }).map((_, index) => (
    > = [] -const pluralize = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}` - export function QueryBuilderHeatmapChart({ data, className, @@ -741,8 +739,8 @@ export function QueryBuilderHeatmapChart({ const footnote = model.hiddenX > 0 || model.hiddenY > 0 ? [ - model.hiddenX > 0 ? pluralize(model.hiddenX, "empty column") : null, - model.hiddenY > 0 ? pluralize(model.hiddenY, "empty row") : null, + model.hiddenX > 0 ? countLabel(model.hiddenX, "empty column") : null, + model.hiddenY > 0 ? countLabel(model.hiddenY, "empty row") : null, ] .filter(Boolean) .join(" · ") + " hidden" @@ -959,7 +957,7 @@ export function QueryBuilderHeatmapChart({ /> ))}
    -
    No data
    +
    No data
    @@ -996,7 +994,7 @@ export function QueryBuilderHeatmapChart({ footer={ footnote ? (

    diff --git a/packages/ui/src/components/charts/histogram/query-builder-histogram-chart.tsx b/packages/ui/src/components/charts/histogram/query-builder-histogram-chart.tsx index 986ddc51b5..58fcce0844 100644 --- a/packages/ui/src/components/charts/histogram/query-builder-histogram-chart.tsx +++ b/packages/ui/src/components/charts/histogram/query-builder-histogram-chart.tsx @@ -3,7 +3,7 @@ import { scaleBand } from "@tanstack/charts-scales/band" import { scaleLinear } from "@tanstack/charts-scales/linear" import * as React from "react" -import { formatNumber, formatValueByUnit } from "../../../lib/format" +import { EMPTY_VALUE, formatNumber, formatValueByUnit } from "../../../lib/format" import { cn } from "../../../lib/utils" import { PlotFrame, @@ -15,13 +15,13 @@ import { minBarLength, niceLinearDomain, usePlotColors, - type PlotColorToken, + type PlotColorSource, } from "../../plot" import type { QueryBuilderHistogramChartProps } from "../_shared/chart-types" const HISTOGRAM_TOKENS = { - bar: ["--chart-1", "#6366f1"], -} as const satisfies Record + bar: "--chart-1", +} as const satisfies Record /** * Stable identity for the no-data case: `data ?? []` would allocate a fresh array @@ -212,7 +212,7 @@ function CountTooltipBody({ heading, count, color }: { heading: string; count: n {heading}

    - +
    Count @@ -460,7 +460,10 @@ export function QueryBuilderHistogramChart({ const prebucketed = React.useMemo(() => { if (!isPrebucketed(source)) return null - return source.map((row) => ({ name: String(row.name ?? "—"), value: asFiniteNumber(row.value) })) + return source.map((row) => ({ + name: String(row.name ?? EMPTY_VALUE), + value: asFiniteNumber(row.value), + })) }, [source]) const numeric = React.useMemo(() => { @@ -517,7 +520,7 @@ export function QueryBuilderHistogramChart({ return (
    - No data + No data
    ) } diff --git a/packages/ui/src/components/charts/index.ts b/packages/ui/src/components/charts/index.ts index 7c9f6213af..86ef58432d 100644 --- a/packages/ui/src/components/charts/index.ts +++ b/packages/ui/src/components/charts/index.ts @@ -30,6 +30,9 @@ export type { } from "./_shared/chart-types" export * from "./_shared/sample-data" +// The pooled long tail's label and colour, one definition for every chart. +export { OTHER_COLOR, OTHER_LABEL } from "./_shared/bucket-series" + // Bar Charts export { QueryBuilderBarChart } from "./bar/query-builder-bar-chart" diff --git a/packages/ui/src/components/charts/line/latency-line-chart.tsx b/packages/ui/src/components/charts/line/latency-line-chart.tsx index c7e4d21b91..c739f57184 100644 --- a/packages/ui/src/components/charts/line/latency-line-chart.tsx +++ b/packages/ui/src/components/charts/line/latency-line-chart.tsx @@ -20,7 +20,7 @@ import { usePlotColors, usePlotLegendSlot, type FixedMetricSeries, - type PlotColorToken, + type PlotColorSource, type PlotTooltipSeries, type TimeseriesRow, } from "../../plot" @@ -39,10 +39,10 @@ const STROKE_WIDTH = 2 * literal per render would re-read computed style every frame. */ const LATENCY_TOKENS = { - p99LatencyMs: ["--chart-p99", "#e0484a"], - p95LatencyMs: ["--chart-p95", "#e0a23a"], - p50LatencyMs: ["--chart-p50", "#4f8ef7"], -} as const satisfies Record + p99LatencyMs: "--chart-p99", + p95LatencyMs: "--chart-p95", + p50LatencyMs: "--chart-p50", +} as const satisfies Record const LATENCY_LABELS = { p99LatencyMs: "P99", diff --git a/packages/ui/src/components/charts/paths/paths-chart.tsx b/packages/ui/src/components/charts/paths/paths-chart.tsx index 16a4dce2ac..3dbafd6b61 100644 --- a/packages/ui/src/components/charts/paths/paths-chart.tsx +++ b/packages/ui/src/components/charts/paths/paths-chart.tsx @@ -2,7 +2,8 @@ import * as React from "react" import type { PathsChartProps } from "../_shared/chart-types" import { cn } from "../../../lib/utils" -import { formatNumber } from "../../../lib/format" +import { Eyebrow } from "../../ui/eyebrow" +import { formatNumber, formatPercent } from "../../../lib/format" import { asFiniteNumber } from "../_shared/breakdown-rows" import { useContainerSize } from "../../../hooks/use-container-size" import { ArrowLeftIcon, UserIcon } from "../../icons" @@ -205,7 +206,7 @@ export function PathsChart({ data, className, direction = "after" }: PathsChartP if (!anchor || anchor.count <= 0) { return (
    - No data + No data
    ) } @@ -251,7 +252,7 @@ export function PathsChart({ data, className, direction = "after" }: PathsChartP x={atRightEdge ? x + NODE_W : x} y={10} textAnchor={atRightEdge ? "end" : "start"} - className="fill-muted-foreground text-[10px] font-medium uppercase tracking-wider" + className="fill-muted-foreground text-3xs font-medium uppercase tracking-wider" > {label} @@ -345,7 +346,7 @@ export function PathsChart({ data, className, direction = "after" }: PathsChartP y={node.y + Math.min(node.h, 12) / 2 + 3.5} textAnchor={labelLeft ? "start" : "end"} className={cn( - "text-[11px] font-medium", + "text-2xs font-medium", node.kind === "event" ? "fill-foreground" : "fill-muted-foreground", @@ -359,7 +360,7 @@ export function PathsChart({ data, className, direction = "after" }: PathsChartP {formatNumber(node.count)} @@ -374,7 +375,7 @@ export function PathsChart({ data, className, direction = "after" }: PathsChartP )} {hoveredNode && graph && (
    {labelOf(hoveredNode)} - + {hoveredNode.column === 0 ? "anchor" : reverse @@ -415,20 +416,17 @@ export function PathsChart({ data, className, direction = "after" }: PathsChartP {hoveredNode.count.toLocaleString("en-US")} - - {((hoveredNode.count / anchor.count) * 100).toFixed( - hoveredNode.count / anchor.count < 0.1 ? 1 : 0, - )} - % of anchor + + {formatPercent(hoveredNode.count / anchor.count)} of anchor
    {hoveredNode.column > 0 && ( <>
    -
    + Came from -
    + {links .filter((link) => link.target === hoveredNode.id) .sort((a, b) => b.count - a.count) diff --git a/packages/ui/src/components/charts/pie/query-builder-pie-chart.tsx b/packages/ui/src/components/charts/pie/query-builder-pie-chart.tsx index 5dae804a44..d1c099f567 100644 --- a/packages/ui/src/components/charts/pie/query-builder-pie-chart.tsx +++ b/packages/ui/src/components/charts/pie/query-builder-pie-chart.tsx @@ -12,7 +12,7 @@ import { scaleLinear } from "@tanstack/charts-scales/linear" import * as React from "react" import { useContainerSize } from "../../../hooks/use-container-size" -import { formatNumber, formatValueByUnit } from "../../../lib/format" +import { EMPTY_VALUE, formatNumber, formatValueByUnit } from "../../../lib/format" import { resolveSeriesColors } from "../../../lib/semantic-series-colors" import { cn } from "../../../lib/utils" import { @@ -442,7 +442,7 @@ export function QueryBuilderPieChart({ // this column is a span count or a latency. if (tableLegend) { entry.value = fmtValue(row.value, unit) - entry.secondary = total > 0 ? fmtPercent(row.value / total, 1) : "—" + entry.secondary = total > 0 ? fmtPercent(row.value / total, 1) : EMPTY_VALUE } return entry }), @@ -585,7 +585,7 @@ export function QueryBuilderPieChart({ ref={containerRef} className={cn("relative grid h-full w-full place-items-center", className)} > - No data + No data
    ) } @@ -668,10 +668,10 @@ export function QueryBuilderPieChart({ const slice = points[0]?.datum if (!slice) return null return ( -
    +
    {slice.name} diff --git a/packages/ui/src/components/detail-rail.tsx b/packages/ui/src/components/detail-rail.tsx index e68b587d2b..c1e8d2d97a 100644 --- a/packages/ui/src/components/detail-rail.tsx +++ b/packages/ui/src/components/detail-rail.tsx @@ -1,6 +1,7 @@ import type * as React from "react" import { cn } from "../lib/utils" +import { Eyebrow } from "./ui/eyebrow" import type { IconComponent } from "./icons" import { CopyButton } from "./ui/copy-button" import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip" @@ -35,9 +36,7 @@ function Group({ className, )} > -

    - {label} -

    + {label}
    {children}
    ) @@ -84,9 +83,7 @@ function Row({ {Icon ? : null} {label} - {hint ? ( - {hint} - ) : null} + {hint ? {hint} : null}
    {children}
    @@ -115,7 +112,7 @@ function Field({
    {label} - {hint ? {hint} : null} + {hint ? {hint} : null}
    {children}
    @@ -144,7 +141,7 @@ function MetaRow({ }) { if (!value) return null const valueNode = ( - + {value} ) @@ -153,8 +150,8 @@ function MetaRow({ data-slot="detail-rail-meta-row" className="group flex items-baseline justify-between gap-3 border-b border-border/60 py-1.5 last:border-0" > - {label} -
    + {label} +
    {tooltip ? ( } className="cursor-default"> diff --git a/packages/ui/src/components/error-section.tsx b/packages/ui/src/components/error-section.tsx index ba9bc9c298..39920afea1 100644 --- a/packages/ui/src/components/error-section.tsx +++ b/packages/ui/src/components/error-section.tsx @@ -58,7 +58,7 @@ function TextBody({ source }: { source: string }) { return ( <> {lines.map((line, index) => ( - + {line.text || " "} ))} @@ -89,26 +89,26 @@ export function ErrorSection({ message, title = "Error", badge, prompt, classNam return (
    - + - + {title} {badge && ( - + {badge} )} {body.format === "json" && ( @@ -124,7 +124,7 @@ export function ErrorSection({ message, title = "Error", badge, prompt, classNam tooltip idleIcon={SparkleIcon} iconSize={12} - className="ml-auto size-5 shrink-0 rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive sm:size-5" + className="ml-auto size-5 shrink-0 rounded text-severity-error/70 hover:bg-severity-error/10 hover:text-severity-error sm:size-5" /> )}
    @@ -139,7 +139,7 @@ export function ErrorSection({ message, title = "Error", badge, prompt, classNam data-copy-status={status} onClick={() => void copy(body.full)} className={cn( - "mt-1.5 block w-full cursor-copy rounded text-left font-mono text-[11px] text-foreground/90 leading-relaxed", + "mt-1.5 block w-full cursor-copy rounded text-left font-mono text-2xs text-foreground/90 leading-relaxed", "transition-colors duration-150 hover:text-foreground motion-reduce:transition-none", "data-[copy-status=copied]:text-severity-info", )} @@ -172,7 +172,7 @@ export function ErrorSection({ message, title = "Error", badge, prompt, classNam type="button" aria-expanded={expanded} onClick={() => setExpanded((open) => !open)} - className="mt-1 rounded text-[11px] text-destructive/70 hover:text-destructive" + className="mt-1 rounded text-2xs text-severity-error/70 hover:text-severity-error" > {expanded ? "Show less" : "Show more"} diff --git a/packages/ui/src/components/filters/filter-section.tsx b/packages/ui/src/components/filters/filter-section.tsx index bc1b4f9ba5..bcf1eeabfc 100644 --- a/packages/ui/src/components/filters/filter-section.tsx +++ b/packages/ui/src/components/filters/filter-section.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { ChevronDownIcon, CircleInfoIcon, type IconComponent, MagnifierIcon, XmarkIcon } from "../icons" +import { Badge } from "../ui/badge" import { Checkbox } from "../ui/checkbox" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible" import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip" @@ -8,9 +9,9 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from " import { Label } from "../ui/label" import { useSectionCollapse } from "../../hooks/use-section-collapse" import { getServiceColor } from "../../lib/colors" -import { formatNumber } from "../../lib/format" +import { countLabel, formatNumber } from "../../lib/format" import { cn } from "../../lib/utils" -import { FILTER_SECTION_LABEL } from "./filter-styles" +import { eyebrowVariants } from "../ui/eyebrow" export interface FilterOption { name: string @@ -250,7 +251,7 @@ function FilterSectionBase({ @@ -261,14 +262,14 @@ function FilterSectionBase({ {!isOpen && selected.length > 0 && ( - + {selected.length} )} {/* Its own badge, not folded into the count above: a collapsed section hiding an exclusion is exactly the state that reads as "my data is missing". */} {!isOpen && excluded.length > 0 && ( - + −{excluded.length} )} @@ -451,8 +452,8 @@ function FilterSectionBase({ )} {overflowingMatches > 0 && (

    - {overflowingMatches.toLocaleString()} more match - {overflowingMatches === 1 ? "" : "es"} — keep typing to narrow + {countLabel(overflowingMatches, "more match", "more matches")}, keep typing to + narrow

    )} {hasMore > 0 && ( @@ -517,21 +518,22 @@ interface FilterRowActionProps { */ function FilterRowAction({ onClick, label, pressed, children }: FilterRowActionProps) { return ( - + ) } @@ -582,7 +584,7 @@ export function SingleCheckboxFilter({ )} {count !== undefined && ( - {count.toLocaleString()} + {formatNumber(count)} )}
    ) diff --git a/packages/ui/src/components/filters/filter-sidebar.tsx b/packages/ui/src/components/filters/filter-sidebar.tsx index 382f2000a8..56df617070 100644 --- a/packages/ui/src/components/filters/filter-sidebar.tsx +++ b/packages/ui/src/components/filters/filter-sidebar.tsx @@ -4,7 +4,8 @@ import { ScrollArea } from "../ui/scroll-area" import { Separator } from "../ui/separator" import { Skeleton } from "../ui/skeleton" import { cn } from "../../lib/utils" -import { FILTER_SECTION_LABEL } from "./filter-styles" +import { refreshingClass } from "../../lib/refreshing" +import { Eyebrow } from "../ui/eyebrow" interface FilterSidebarFrameProps { children: ReactNode @@ -17,7 +18,10 @@ export function FilterSidebarFrame({ children, waiting = false, className }: Fil // on desktop, a sheet below lg). Setting one here would fight it — callers that own their own // layout (local mode) pass a width via className instead. return ( -
    +
    {children}
    ) @@ -37,7 +41,9 @@ export function FilterSidebarHeader({ return (
    {/* Same size as the section labels below; distinguished by weight and full-strength color. */} -

    {title}

    + + {title} + {canClear && onClear && ( @@ -409,15 +410,15 @@ function RangeHistogram({
    {/* Fixed-height readout instead of a floating tooltip — a popover would clip inside a sidebar this narrow, and the total is worth showing anyway. */} -
    +
    {hovered ? ( <> {describe(hovered)} - {hovered.count.toLocaleString()} + {formatNumber(hovered.count)} ) : ( - {total.toLocaleString()} {unitLabel} + {formatNumber(total)} {unitLabel} )}
    @@ -457,7 +458,7 @@ function RangeHistogram({ ) })}
    -
    +
    {formatValue(first.from, unit)} {formatValue((buckets[Math.floor(buckets.length / 2)] ?? first).from, unit)} @@ -520,7 +521,7 @@ export function parseRange(text: string, unit: RangeUnit): number | undefined { export function formatValue(value: number, unit: RangeUnit): string { switch (unit) { case "ms": - return formatMs(value) + return formatDuration(value) case "s": return formatSeconds(value) case "count": @@ -546,12 +547,6 @@ export function formatCompact(value: number | undefined, unit: RangeUnit): strin return parseRange(human, unit) === value ? human : String(value) } -function formatMs(ms: number): string { - if (ms < 1) return `${(ms * 1000).toFixed(0)}us` - if (ms < 1000) return `${ms.toFixed(1)}ms` - return `${(ms / 1000).toFixed(2)}s` -} - /** "120k", "1.5M", "2B" — and the bare number under a thousand. */ export function formatCount(value: number): string { const scaled = (divisor: number, suffix: string) => { diff --git a/packages/ui/src/components/http-status-code.tsx b/packages/ui/src/components/http-status-code.tsx index c359a34f2a..57109484b1 100644 --- a/packages/ui/src/components/http-status-code.tsx +++ b/packages/ui/src/components/http-status-code.tsx @@ -19,7 +19,7 @@ export function HttpStatusCode({ className={cn( "font-mono tabular-nums", variant === "badge" - ? cn("rounded-sm px-1 py-px text-[10px]", TONE_SOFT[tone]) + ? cn("rounded-sm px-1 py-px text-3xs", TONE_SOFT[tone]) : tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone], diff --git a/packages/ui/src/components/icons/dots-vertical.tsx b/packages/ui/src/components/icons/dots-vertical.tsx new file mode 100644 index 0000000000..03de420d36 --- /dev/null +++ b/packages/ui/src/components/icons/dots-vertical.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from "./icon" + +const paths: ReadonlyArray = ["M12 5L12 5.01", "M12 12L12 12.01", "M12 19L12 19.01"] + +function DotsVerticalIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} +export { DotsVerticalIcon } diff --git a/packages/ui/src/components/icons/index.ts b/packages/ui/src/components/icons/index.ts index 240a37d053..265dca6203 100644 --- a/packages/ui/src/components/icons/index.ts +++ b/packages/ui/src/components/icons/index.ts @@ -44,6 +44,7 @@ export { CubeIcon } from "./cube" export { DatabaseIcon } from "./database" export { DenoIcon } from "./deno" export { DotsIcon } from "./dots" +export { DotsVerticalIcon } from "./dots-vertical" export { EarthIcon } from "./earth" export { EnvelopeOpenIcon } from "./envelope-open" export { EyeClosedIcon } from "./eye-closed" diff --git a/packages/ui/src/components/logs/log-attribute-chip.tsx b/packages/ui/src/components/logs/log-attribute-chip.tsx index 1d1ff15c0b..abbf9a5d9a 100644 --- a/packages/ui/src/components/logs/log-attribute-chip.tsx +++ b/packages/ui/src/components/logs/log-attribute-chip.tsx @@ -5,6 +5,7 @@ import { useCallback, useRef, useState } from "react" import { cn } from "../../lib/utils" import { useCopy } from "../../hooks/use-copy" import { useMountEffect } from "../../hooks/use-mount-effect" +import { Eyebrow } from "../ui/eyebrow" import { HoverCard, HoverCardContent } from "../ui/hover-card" import { tryParseJson, CopyableValue, CollapsibleJsonValue } from "../attributes" import type { ChipTone } from "../../lib/log-attributes" @@ -150,7 +151,7 @@ export function LogAttributeChip({ attrKey, value, tone }: LogAttributeChipProps } }} className={cn( - "inline-flex items-center gap-1 h-[18px] px-1.5 rounded border text-[10px] font-mono leading-none whitespace-nowrap shrink-0 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "inline-flex items-center gap-1 h-[18px] px-1.5 rounded border text-3xs font-mono leading-none whitespace-nowrap shrink-0 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", TONE_CLASSES[tone], )} > @@ -168,17 +169,17 @@ export function LogAttributeChip({ attrKey, value, tone }: LogAttributeChipProps onPointerLeave={scheduleClose} >
    -
    + Attribute -
    +
    {attrKey}
    -
    + Value -
    +
    {parsed !== null ? ( diff --git a/packages/ui/src/components/logs/severity-badge.tsx b/packages/ui/src/components/logs/severity-badge.tsx index 63dfff8e6e..53ae18fe34 100644 --- a/packages/ui/src/components/logs/severity-badge.tsx +++ b/packages/ui/src/components/logs/severity-badge.tsx @@ -13,7 +13,7 @@ export function SeverityBadge({ severity, className }: SeverityBadgeProps) { return ( , timeZone?: string) { + const epochs = bucketIsos + .map((iso) => toEpochMs(iso)) + .filter((ms) => Number.isFinite(ms)) + .sort((a, b) => a - b) + const first = epochs[0] + const last = epochs[epochs.length - 1] + const domainMs: readonly [number, number] | undefined = + first !== undefined && last !== undefined ? [first, last] : undefined + + // The bucket width is the smallest positive gap, so an irregular union of two + // cadences still reports the finer one rather than whatever came first. + let stepMs: number | undefined + let previous: number | undefined + for (const epoch of epochs) { + const gap = previous === undefined ? 0 : epoch - previous + if (gap > 0 && (stepMs === undefined || gap < stepMs)) stepMs = gap + previous = epoch + } + + const context: TimeseriesAxisContext = { + rangeMs: domainMs ? domainMs[1] - domainMs[0] : 0, + bucketSeconds: stepMs === undefined ? undefined : stepMs / 1000, + domainMs, + timeZone, + } + const axis = timeseriesXAxis(context) + + return { + /** Feed to `defineChart({ scales: { x } })`. */ + x: + domainMs && domainMs[0] < domainMs[1] + ? { + ...axis, + scale: bucketTimeScale([new Date(domainMs[0]), new Date(domainMs[1])], timeZone), + } + : axis, + /** + * `x` padded by half a bucket each way, for marks with WIDTH (bars): they + * are centred on the bucket, so the unpadded extent cuts the end bars in + * half (see `timeseriesBandXAxis`). + */ + xBand: + domainMs && stepMs !== undefined + ? { + ...axis, + scale: bucketTimeScale( + [new Date(domainMs[0] - stepMs / 2), new Date(domainMs[1] + stepMs / 2)], + timeZone, + ), + } + : axis, + /** `[first, last]` epoch ms, absent when there is nothing to plot. */ + domainMs, + /** The bucket width in ms (the smallest positive gap), absent under two buckets. */ + stepMs, + /** The context the tick labels print with, for a caller formatting its own labels. */ + context, + /** The tooltip heading for a bucket: the full date, since the ticks stay terse. */ + heading: (bucketIso: string) => formatBucketLabel(bucketIso, context, "tooltip"), + } +} + +export type BucketAxis = ReturnType diff --git a/packages/ui/src/components/plot/cursor-plot.tsx b/packages/ui/src/components/plot/cursor-plot.tsx new file mode 100644 index 0000000000..91e702e363 --- /dev/null +++ b/packages/ui/src/components/plot/cursor-plot.tsx @@ -0,0 +1,137 @@ +import type { ChartValue, DomChartDefinition } from "@tanstack/charts" +import * as React from "react" + +import { cn } from "../../lib/utils" +import { PlotFrame, type PlotFrameProps } from "./plot-frame" +import { + PlotTooltipBody, + createTooltipFocusStore, + cursorTooltip, + type PlotTooltipSeries, + type TooltipFocusStore, +} from "./plot-tooltip" +import { usePlotChromeColors, useResolvedSeriesColors, type PlotChromeColors } from "./theme" + +/** One series of a cursor-tooltip chart: what it is called, its colour, and how to read it. */ +export interface CursorPlotSeries { + key: string + label: string + /** A token (`--chart-2`), a wrapped `var(--chart-2)`, or a literal. */ + color: string + dashed?: boolean + value: (datum: TDatum) => number | null | undefined + format: (value: number) => string + /** Where the series is PLOTTED when that is not its raw value (a stacked band). */ + position?: (datum: TDatum) => number | null | undefined + /** `false` keeps a series (a threshold, a band) out of the tooltip card. */ + tooltip?: boolean +} + +/** What a chart needs to build its definition: resolved colours, chrome, and the tooltip spec. */ +export interface CursorPlotState { + /** Each series' colour resolved to a literal, keyed by `key`. */ + colors: ReadonlyMap + /** `colors.get(key)`, falling back to the border colour. */ + color: (key: string) => string + chrome: PlotChromeColors + focusStore: TooltipFocusStore + /** The cursor-anchored tooltip spec, for `defineChart({ tooltip })`. */ + tooltip: ReturnType +} + +/** + * The state every cursor-tooltip chart builds before its definition. + * + * Colour resolution lives here once: the canvas 2D context cannot read + * `var(--chart-N)`, so every token is resolved to a literal (re-resolved on a + * theme flip). Pass a memoised `series`: its identity keys the resolution. + */ +export function useCursorPlot(series: ReadonlyArray<{ key: string; color: string }>): CursorPlotState { + const chrome = usePlotChromeColors() + const focusStore = React.useMemo(() => createTooltipFocusStore(), []) + const tokens = React.useMemo(() => new Map(series.map((entry) => [entry.key, entry.color])), [series]) + const colors = useResolvedSeriesColors(tokens) + + return React.useMemo( + () => ({ + colors, + color: (key: string) => colors.get(key) ?? chrome.border, + chrome, + focusStore, + tooltip: cursorTooltip(focusStore.anchor), + }), + [colors, chrome, focusStore], + ) +} + +export interface CursorPlotProps extends Pick< + PlotFrameProps, + "legend" | "legendPlacement" | "onFocusChange" | "footer" | "overlay" | "renderer" +> { + plot: CursorPlotState + definition: DomChartDefinition + series: ReadonlyArray> + /** The tooltip card's heading for the hovered datum. */ + heading: (datum: TDatum) => string + /** Which tooltip row to emphasise, when the chart knows without measuring. */ + highlight?: (datum: TDatum) => string | undefined + ariaLabel: string + /** Wraps the plot in a box of this height; omit when `className` sizes it. */ + height?: number + className?: string +} + +/** + * A `PlotFrame` with the cursor tooltip wired: the series list builds the card's + * rows (in resolved colours) and `heading` titles it. + */ +export function CursorPlot({ + plot, + definition, + series, + heading, + highlight, + ariaLabel, + height, + className, + ...frame +}: CursorPlotProps) { + const tooltipSeries = React.useMemo[]>( + () => + series + .filter((entry) => entry.tooltip !== false) + .map((entry) => ({ + label: entry.label, + color: plot.color(entry.key), + dashed: entry.dashed, + value: entry.value, + format: entry.format, + position: entry.position, + })), + [series, plot], + ) + + const chart = ( + ( + + )} + /> + ) + if (height === undefined) return chart + return ( +
    + {chart} +
    + ) +} diff --git a/packages/ui/src/components/plot/fixed-metrics.tsx b/packages/ui/src/components/plot/fixed-metrics.tsx index e12632efe4..a2cb30fda7 100644 --- a/packages/ui/src/components/plot/fixed-metrics.tsx +++ b/packages/ui/src/components/plot/fixed-metrics.tsx @@ -246,7 +246,7 @@ export function FixedMetricLegend({ series }: { series: readonly FixedMetricSeri ) diff --git a/packages/ui/src/components/plot/plot-tooltip.tsx b/packages/ui/src/components/plot/plot-tooltip.tsx index 40c6e29187..7f576a2951 100644 --- a/packages/ui/src/components/plot/plot-tooltip.tsx +++ b/packages/ui/src/components/plot/plot-tooltip.tsx @@ -271,8 +271,8 @@ export function PlotTooltipBody({ resolvePlotColor(color, FALLBACK_COLOR), [color, theme]) + const stroke = useMemo(() => resolvePlotColor(color), [color, theme]) const points = useMemo(() => values.map((value, index) => ({ index, value })), [values]) diff --git a/packages/ui/src/components/plot/theme.ts b/packages/ui/src/components/plot/theme.ts index a87aec2e1c..78a4b34ac5 100644 --- a/packages/ui/src/components/plot/theme.ts +++ b/packages/ui/src/components/plot/theme.ts @@ -3,6 +3,7 @@ import { useMemo } from "react" import { useTheme } from "../../hooks/use-theme" +import { tokenFallback } from "../../lib/colors" /** * A design token (`--chart-p99`) or a literal colour. Tokens are resolved to @@ -10,6 +11,9 @@ import { useTheme } from "../../hooks/use-theme" */ export type PlotColorToken = `--${string}` | (string & {}) +/** A token alone (its fallback comes from `TOKEN_FALLBACK`), or a token with an explicit fallback. */ +export type PlotColorSource = PlotColorToken | readonly [token: PlotColorToken, fallback: string] + /** * Resolve one token against the live document. * @@ -18,7 +22,7 @@ export type PlotColorToken = `--${string}` | (string & {}) * would accept the `var()` directly, but both renderers share one definition, so * both take the resolved literal. */ -export function resolvePlotColor(token: PlotColorToken, fallback: string): string { +export function resolvePlotColor(token: PlotColorToken, fallback: string = tokenFallback(token)): string { if (typeof document === "undefined") return fallback // `resolveSeriesColors` hands back WRAPPED tokens — `var(--chart-p95)`, // `var(--severity-error)` — for every semantically named series, and a bare @@ -52,7 +56,7 @@ export function resolvePlotColor(token: PlotColorToken, fallback: string): strin * literal each render defeats the memo and re-reads computed style per frame. */ export function usePlotColors( - tokens: Readonly>, + tokens: Readonly>, ): Readonly> { const { theme } = useTheme() @@ -60,8 +64,11 @@ export function usePlotColors( () => { const resolved = {} as Record for (const key of Object.keys(tokens) as TKey[]) { - const [token, fallback] = tokens[key] - resolved[key] = resolvePlotColor(token, fallback) + const source = tokens[key] + resolved[key] = + typeof source === "string" + ? resolvePlotColor(source) + : resolvePlotColor(source[0], source[1]) } return resolved }, @@ -81,9 +88,9 @@ export function usePlotColors( * frame. */ export const PLOT_CHROME_TOKENS = { - border: ["--border", "#3f3f46"], - background: ["--background", "#0c0a09"], -} as const satisfies Record + border: "--border", + background: "--background", +} as const satisfies Record export type PlotChromeColors = Readonly> @@ -109,7 +116,8 @@ export function usePlotChromeColors(): PlotChromeColors { */ export function useResolvedSeriesColors( tokensByKey: ReadonlyMap, - fallback: string, + /** Omit to fall back per token (`TOKEN_FALLBACK`). */ + fallback?: string, ): ReadonlyMap { const { theme } = useTheme() @@ -117,7 +125,7 @@ export function useResolvedSeriesColors( () => { const resolved = new Map() for (const [key, token] of tokensByKey) { - resolved.set(key, resolvePlotColor(token, fallback)) + resolved.set(key, resolvePlotColor(token, fallback ?? tokenFallback(token))) } return resolved }, diff --git a/packages/ui/src/components/plot/threshold-rules.ts b/packages/ui/src/components/plot/threshold-rules.ts index ee017bd526..9b8e0ca7be 100644 --- a/packages/ui/src/components/plot/threshold-rules.ts +++ b/packages/ui/src/components/plot/threshold-rules.ts @@ -35,11 +35,10 @@ export interface ThresholdRulesOptions { * Used when a threshold carries no colour, or when one cannot reach the canvas. * * `--destructive` is what the Recharts predecessor fell back to, and it is the - * token that follows the theme; the literal is only the value it resolves to - * when there is no document to read (SSR, tests) or the token is unset. + * token that follows the theme; `TOKEN_FALLBACK` supplies the literal when there + * is no document to read (SSR, tests) or the token is unset. */ const DEFAULT_THRESHOLD_TOKEN = "--destructive" -const DEFAULT_THRESHOLD_COLOR = "#ef4444" const THRESHOLD_STROKE_WIDTH = 1.5 const THRESHOLD_LABEL_FONT_SIZE = 10 /** Keeps the label off the right edge and just above its own rule. */ @@ -61,7 +60,7 @@ const THRESHOLD_LABEL_DY = -6 * and falls back. */ export function canvasSafeThresholdColor(color: string | undefined): string { - const fallback = resolvePlotColor(DEFAULT_THRESHOLD_TOKEN, DEFAULT_THRESHOLD_COLOR) + const fallback = resolvePlotColor(DEFAULT_THRESHOLD_TOKEN) const validated = validateCssColor(color) if (validated == null) return fallback const resolved = resolvePlotColor(validated, fallback) diff --git a/packages/ui/src/components/plot/timeseries.tsx b/packages/ui/src/components/plot/timeseries.tsx index 84b5c3eada..cc2f9199ae 100644 --- a/packages/ui/src/components/plot/timeseries.tsx +++ b/packages/ui/src/components/plot/timeseries.tsx @@ -10,6 +10,7 @@ import { inferRangeMs, parseBucketMs, } from "../../lib/format" +import { DEFAULT_TOKEN_FALLBACK } from "../../lib/colors" import { resolveSeriesColors } from "../../lib/semantic-series-colors" import { cn } from "../../lib/utils" import type { ChartLegendMode } from "../charts/_shared/chart-types" @@ -67,7 +68,7 @@ const EMPTY_ROWS: ReadonlyArray> = [] export const HARD_SERIES_LIMIT = 60 /** Used when a series colour token resolves to nothing. */ -export const SERIES_FALLBACK_COLOR = "#6366f1" +export const SERIES_FALLBACK_COLOR = DEFAULT_TOKEN_FALLBACK /** One normalised row: the bucket, its parsed date, and every series value. */ export interface TimeseriesRow extends Record { diff --git a/packages/ui/src/components/service-map/service-map-canvas.tsx b/packages/ui/src/components/service-map/service-map-canvas.tsx index 8f72c6552a..f532f0bf75 100644 --- a/packages/ui/src/components/service-map/service-map-canvas.tsx +++ b/packages/ui/src/components/service-map/service-map-canvas.tsx @@ -24,6 +24,7 @@ import "@xyflow/react/dist/style.css" import { getValueHue } from "../../lib/colors" import { Button } from "../ui/button" +import { Eyebrow } from "../ui/eyebrow" import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "../ui/resizable" import { ServiceMapBackground } from "./service-map-background" @@ -168,20 +169,18 @@ function LayoutDebugPanel({ {open && (
    - - Layout Config - + Layout Config @@ -189,8 +188,8 @@ function LayoutDebugPanel({ {SLIDER_DEFS.map(({ key, label, min, max, step }) => (
    - - + + {config[key]}
    @@ -206,7 +205,7 @@ function LayoutDebugPanel({
    ))}
    -
    +						
     							{JSON.stringify(config, null, 2)}
     						
    @@ -985,7 +984,7 @@ const serviceSwatchColor = (service: string) => /** Pointer hints, the service or platform color key, and the health dot key under the map. */ function ServiceMapLegend({ colorMode, services }: { colorMode: ServiceMapColorMode; services: string[] }) { return ( -
    +
    {/* Pointer hints only: on touch the gestures are different, and the two lines they cost are the whole legend's height on a phone. */} Drag nodes to arrange @@ -1009,7 +1008,7 @@ function ServiceMapLegend({ colorMode, services }: { colorMode: ServiceMapColorM +{services.length - 3} more -
    +
    {services.map((service) => (
    - + {hasSampling ? "~" : ""} {formatCallCount(hasSampling ? estimatedCallCount : callCount)} {errorRate > 0 && ( - 0.05 - ? " text-severity-error" - : errorRate > 0.01 - ? " text-severity-warn" - : "" - } - > + {" "} - {(errorRate * 100).toFixed(1)}% + {formatErrorRate(errorRate)} )} diff --git a/packages/ui/src/components/service-map/service-map-namespace-group.tsx b/packages/ui/src/components/service-map/service-map-namespace-group.tsx index 2e8b7ca264..1e26845117 100644 --- a/packages/ui/src/components/service-map/service-map-namespace-group.tsx +++ b/packages/ui/src/components/service-map/service-map-namespace-group.tsx @@ -36,7 +36,7 @@ export const NamespaceGroupNode = memo(function NamespaceGroupNode({ data }: Nam style={{ borderColor, backgroundColor: bgColor }} > {label} diff --git a/packages/ui/src/components/service-map/service-map-node.tsx b/packages/ui/src/components/service-map/service-map-node.tsx index f9899e8eb0..f59cbec223 100644 --- a/packages/ui/src/components/service-map/service-map-node.tsx +++ b/packages/ui/src/components/service-map/service-map-node.tsx @@ -1,9 +1,10 @@ -import { formatLatency } from "../../lib/format" +import { formatErrorRate, formatLatency, formatRate } from "../../lib/format" import { memo } from "react" import { Handle, Position } from "@xyflow/react" import { cn } from "../../lib/utils" import { ERROR_RATE_TEXT, type ErrorRateLevel, errorRateLevel } from "../../lib/error-rate" -import { TONE_FILL } from "../../lib/tone" +import { TONE_FILL, TONE_TEXT } from "../../lib/tone" +import { type UtilizationLevel, utilizationLevel } from "../../lib/utilization" import { latencyToneClass } from "../../lib/latency-tone" import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip" import { @@ -39,11 +40,12 @@ function getPlatformIcon(platform: ServicePlatform | undefined): { } } -export function formatRate(value: number): string { - if (value >= 1000) return `${(value / 1000).toFixed(1)}k` - if (value >= 1) return value.toFixed(1) - return value.toFixed(2) -} +// "ok" keeps the cell's default colour, as before. +const UTILIZATION_TEXT = { + crit: TONE_TEXT.crit, + warn: TONE_TEXT.warn, + ok: undefined, +} satisfies Record const HEALTH_DOT_CLASS = { crit: TONE_FILL.crit, @@ -76,12 +78,12 @@ function MetricCell({ }) { return (
    - + {label} @@ -174,7 +176,7 @@ function DatabaseNode({ data }: { data: ServiceNodeData }) { {title} {badge} @@ -189,7 +191,7 @@ function DatabaseNode({ data }: { data: ServiceNodeData }) { /> 80 - ? "text-severity-error" - : planetscale.stats.cpuMaxPercent > 60 - ? "text-severity-warn" - : undefined + UTILIZATION_TEXT[utilizationLevel(planetscale.stats.cpuMaxPercent / 100)] } /> ) : ( - + {runtimeInfo.short} ))} @@ -345,7 +343,7 @@ function ServiceNode({ data }: { data: ServiceNodeData }) { @@ -357,13 +355,13 @@ function ServiceNode({ data }: { data: ServiceNodeData }) { {/* Pods badge — empty placeholder when no infra so widths stay stable */}
    - + pods {infra ? ( - + {infra.podCount} @@ -379,7 +377,7 @@ function ServiceNode({ data }: { data: ServiceNodeData }) { ) : ( - + – )} @@ -423,7 +421,7 @@ function NamespaceAggregateNode({ data }: { data: ServiceNodeData }) { {label} {nsMemberCount ?? 0} services @@ -434,7 +432,7 @@ function NamespaceAggregateNode({ data }: { data: ServiceNodeData }) { + export function getHealthColor(errorRate: number): string { - if (errorRate > 0.05) return "var(--severity-error)" - if (errorRate > 0.01) return "var(--severity-warn)" - return "var(--severity-info)" + return HEALTH_COLOR[errorRateLevel(errorRate)] } /** diff --git a/packages/ui/src/components/toolbar.tsx b/packages/ui/src/components/toolbar.tsx index 29729041f5..06ea900fe9 100644 --- a/packages/ui/src/components/toolbar.tsx +++ b/packages/ui/src/components/toolbar.tsx @@ -10,6 +10,8 @@ import { NativeSelect, NativeSelectOption } from "./ui/native-select" import { SearchInput } from "./ui/search-input" import { useDebouncedCallback } from "../hooks/use-debounced-callback" import { cn } from "../lib/utils" +import { TONE_TEXT } from "../lib/tone" +import { formatNumber } from "../lib/format" export function Toolbar({ children, className }: { children: ReactNode; className?: string }) { return ( @@ -125,8 +127,8 @@ export function ToolbarStat({ return ( {dot ? : null} - 0 && "text-destructive")}> - {value.toLocaleString()} + 0 && TONE_TEXT.crit)}> + {formatNumber(value)} {label} diff --git a/packages/ui/src/components/traces/color-by-picker.tsx b/packages/ui/src/components/traces/color-by-picker.tsx index 3c1aa62895..c10737e84f 100644 --- a/packages/ui/src/components/traces/color-by-picker.tsx +++ b/packages/ui/src/components/traces/color-by-picker.tsx @@ -1,3 +1,4 @@ +import { countLabel } from "../../lib/format" import type { SpanNode } from "../../lib/types" import { Select, @@ -38,7 +39,7 @@ export function ColorByPicker({ value, onChange, rootSpans }: ColorByPickerProps if (next) onChange(next) }} > - + Color: {colorByLabel(value)} @@ -62,7 +63,7 @@ export function ColorByPicker({ value, onChange, rootSpans }: ColorByPickerProps {a.key} @@ -82,7 +83,7 @@ export function ColorByPicker({ value, onChange, rootSpans }: ColorByPickerProps R: {a.key} diff --git a/packages/ui/src/components/traces/flamegraph-minimap.tsx b/packages/ui/src/components/traces/flamegraph-minimap.tsx index 9abf95e604..8350320257 100644 --- a/packages/ui/src/components/traces/flamegraph-minimap.tsx +++ b/packages/ui/src/components/traces/flamegraph-minimap.tsx @@ -148,7 +148,7 @@ export function FlamegraphMinimap({ /> )} -
    +
    Minimap
    diff --git a/packages/ui/src/components/traces/flamegraph.tsx b/packages/ui/src/components/traces/flamegraph.tsx index d4c6913f1f..a4eaaf290e 100644 --- a/packages/ui/src/components/traces/flamegraph.tsx +++ b/packages/ui/src/components/traces/flamegraph.tsx @@ -9,8 +9,9 @@ import { FlamegraphMinimap } from "./flamegraph-minimap" import { spanStartMs as spanStartMsOf } from "../../lib/span-tree" import { cn } from "../../lib/utils" import { formatDuration } from "../../lib/format" -import { getSpanColorStyle, getServiceColor } from "../../lib/colors" +import { getSpanColorStyle } from "../../lib/colors" import type { SpanNode } from "../../lib/types" +import { ServiceDot } from "../service-dot" interface FlamegraphProps { rootSpans: SpanNode[] @@ -196,7 +197,7 @@ export function Flamegraph({ {[0, 25, 50, 75, 100].map((pct) => (
    {formatDuration(((focusedSpan?.durationMs ?? totalDurationMs) * pct) / 100)} @@ -226,10 +227,9 @@ export function Flamegraph({ {bar.span.spanName} {bar.widthPercent > 12 && ( - + {bar.span.serviceName} )} - + {formatDuration(bar.span.durationMs)} @@ -275,16 +275,16 @@ export function Flamegraph({
    -
    +
    - + Click {" "} select - + Shift+Click {" "} zoom @@ -293,15 +293,15 @@ export function Flamegraph({
    {services.map((service) => (
    -
    + {service}
    ))}
    -
    + Error
    diff --git a/packages/ui/src/components/traces/flow-edge.tsx b/packages/ui/src/components/traces/flow-edge.tsx index 3875814aa8..ff50bbd9ea 100644 --- a/packages/ui/src/components/traces/flow-edge.tsx +++ b/packages/ui/src/components/traces/flow-edge.tsx @@ -116,7 +116,7 @@ export const TraceFlowEdge = memo(function TraceFlowEdge({ className="pointer-events-none overflow-visible" >
    - + {label}
    diff --git a/packages/ui/src/components/traces/flow-node.tsx b/packages/ui/src/components/traces/flow-node.tsx index c553cfbe5c..5e2d9a06ee 100644 --- a/packages/ui/src/components/traces/flow-node.tsx +++ b/packages/ui/src/components/traces/flow-node.tsx @@ -100,7 +100,7 @@ function StatusBadge({ statusCode: string }) { const { platform, cacheInfo, httpInfo } = desc - const base = "shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold" + const base = "shrink-0 rounded px-1.5 py-0.5 text-3xs font-semibold" if (platform?.outcome?.bad) { return ( @@ -166,12 +166,12 @@ export const FlowSpanNode = memo(function FlowSpanNode({ data }: FlowSpanNodePro isSelected && "ring-2 ring-primary ring-offset-2 ring-offset-background", )} > -
    +
    Missing Span
    -
    Not ingested or dropped
    +
    Not ingested or dropped
    {isCombined && ( - + ×{count} )} @@ -254,7 +254,7 @@ export const FlowSpanNode = memo(function FlowSpanNode({ data }: FlowSpanNodePro {/* Row 1: operation + status */}
    {primaryText} @@ -263,7 +263,7 @@ export const FlowSpanNode = memo(function FlowSpanNode({ data }: FlowSpanNodePro
    {/* Row 2: service + category label + duration */} -
    +
    {span.serviceName} · diff --git a/packages/ui/src/components/traces/flow-view.tsx b/packages/ui/src/components/traces/flow-view.tsx index 2cb7c265a8..7b14cf14b3 100644 --- a/packages/ui/src/components/traces/flow-view.tsx +++ b/packages/ui/src/components/traces/flow-view.tsx @@ -205,7 +205,7 @@ export function TraceFlowView({
    -
    +
    Click to select | Scroll to zoom diff --git a/packages/ui/src/components/traces/http-span-label.tsx b/packages/ui/src/components/traces/http-span-label.tsx index 1136551ccc..9cc26bb6b9 100644 --- a/packages/ui/src/components/traces/http-span-label.tsx +++ b/packages/ui/src/components/traces/http-span-label.tsx @@ -35,7 +35,7 @@ export function HttpSpanLabel({ > diff --git a/packages/ui/src/components/traces/span-hierarchy.tsx b/packages/ui/src/components/traces/span-hierarchy.tsx index b30861af18..12bf5db771 100644 --- a/packages/ui/src/components/traces/span-hierarchy.tsx +++ b/packages/ui/src/components/traces/span-hierarchy.tsx @@ -196,7 +196,7 @@ export function SpanHierarchy() { onExpandAll={() => setExpandedSpans(collectAllCollapsibleIds(rootSpans))} /> {flat.length < totalSpanCount && ( - + {flat.length} of {totalSpanCount} spans diff --git a/packages/ui/src/components/traces/span-row.tsx b/packages/ui/src/components/traces/span-row.tsx index 0bd9af8abe..394c73f116 100644 --- a/packages/ui/src/components/traces/span-row.tsx +++ b/packages/ui/src/components/traces/span-row.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { ChevronRightIcon, ChevronDownIcon, GlobeIcon } from "../icons" import { Badge } from "../ui/badge" -import { Button } from "../ui/button" +import { IconButton } from "../ui/icon-button" import { TruncatedId } from "../ui/truncated-id" import { cn } from "../../lib/utils" import { formatDuration } from "../../lib/format" @@ -10,7 +10,7 @@ import { spanStartMs as spanStartMsOf } from "../../lib/span-tree" import { getServiceColor } from "../../lib/colors" import { getCacheInfo, cacheResultStyles } from "../../lib/cache" import { getHttpInfo, HTTP_METHOD_COLORS, httpStatusTone } from "../../lib/http" -import { TONE_TEXT } from "../../lib/tone" +import { TONE_FILL, TONE_TEXT } from "../../lib/tone" import { getCloudPlatform, outcomeBadgeStyle } from "../../lib/cloud-platforms" import { getSpanKindLabel, getSpanStatusBadgeClass } from "../../lib/span-kind" import { PixelDurationBar } from "./pixel-duration-bar" @@ -51,25 +51,24 @@ function SpanRowImpl({ {span.depth > 0 &&
    } {hasChildren ? ( - + ) : (
    )} missing @@ -87,7 +86,7 @@ function SpanRowImpl({
    @@ -112,18 +111,18 @@ function SpanRowImpl({ const barColor = httpTone === "crit" - ? "bg-destructive" + ? TONE_FILL.crit : httpTone === "warn" - ? "bg-severity-warn" + ? TONE_FILL.warn : span.statusCode === "Error" - ? "bg-destructive" + ? TONE_FILL.crit : "bg-primary" return (
    0 &&
    } {hasChildren ? ( - + ) : (
    )} - + {platform && ( @@ -189,7 +187,7 @@ function SpanRowImpl({ >
    @@ -227,7 +223,7 @@ function SpanRowImpl({ @@ -262,7 +258,7 @@ function SpanRowImpl({ ) : ( {span.statusCode || "Unset"} diff --git a/packages/ui/src/components/traces/span-tooltip.tsx b/packages/ui/src/components/traces/span-tooltip.tsx index 0a3bc9146e..523cb34178 100644 --- a/packages/ui/src/components/traces/span-tooltip.tsx +++ b/packages/ui/src/components/traces/span-tooltip.tsx @@ -35,7 +35,7 @@ export function SpanTooltipContent({ span, totalDurationMs, traceStartTime }: Sp {durationPercent !== null && (
    -
    +
    Duration {formatDuration(span.durationMs)} ({durationPercent.toFixed(1)}%) @@ -51,7 +51,7 @@ export function SpanTooltipContent({ span, totalDurationMs, traceStartTime }: Sp )} {span.children.length > 0 && ( -
    +
    Self time {formatDuration(selfTime)} ({selfTimePercent.toFixed(0)}%) @@ -59,7 +59,7 @@ export function SpanTooltipContent({ span, totalDurationMs, traceStartTime }: Sp
    )} -
    +
    Service {span.serviceName} Kind @@ -91,7 +91,7 @@ export function SpanTooltipContent({ span, totalDurationMs, traceStartTime }: Sp
    {httpInfo && ( -
    +
    Method {httpInfo.method} {httpInfo.statusCode != null && ( @@ -118,7 +118,7 @@ export function SpanTooltipContent({ span, totalDurationMs, traceStartTime }: Sp )} {span.statusMessage && ( -
    +
    Message: {span.statusMessage}
    diff --git a/packages/ui/src/components/traces/trace-timeline-minimap.tsx b/packages/ui/src/components/traces/trace-timeline-minimap.tsx index 1fb4385f8a..a5a3839953 100644 --- a/packages/ui/src/components/traces/trace-timeline-minimap.tsx +++ b/packages/ui/src/components/traces/trace-timeline-minimap.tsx @@ -393,7 +393,7 @@ export function TraceTimelineMinimap({ rootSpans, colorBy, controller }: TraceTi className="pointer-events-none absolute inset-y-0 left-0 w-px bg-foreground/40" style={{ display: "none" }} > - +
    {/* Viewport rect. Pointer-transparent: hit-testing happens against the container so the diff --git a/packages/ui/src/components/traces/trace-timeline-row.tsx b/packages/ui/src/components/traces/trace-timeline-row.tsx index d2b84e8a07..bb5b5b711d 100644 --- a/packages/ui/src/components/traces/trace-timeline-row.tsx +++ b/packages/ui/src/components/traces/trace-timeline-row.tsx @@ -3,10 +3,11 @@ import * as React from "react" import { ChevronDownIcon, ChevronRightIcon, LayersIcon } from "../icons" import { cn } from "../../lib/utils" import { getServiceColor } from "../../lib/colors" -import { formatDuration } from "../../lib/format" +import { countLabel, formatDuration } from "../../lib/format" import { describeSpan } from "../../lib/span-category" import type { TimelineBar } from "./trace-timeline-types" import { DEPTH_INDENT, ROW_HEIGHT } from "./trace-timeline-types" +import { TONE_TEXT } from "../../lib/tone" interface TraceTimelineRowProps { bar: TimelineBar @@ -85,7 +86,7 @@ function TraceTimelineRowImpl({
    {/* Ancestor indent guides */} @@ -122,15 +123,12 @@ function TraceTimelineRowImpl({ {/* Category glyph. The timeline row has no room to spell the category out, so the title carries it; `describeSpan` already ran here for `cacheInfo`. */} - + @@ -141,7 +139,7 @@ function TraceTimelineRowImpl({ // column is narrow: the span name must not be crushed to three characters for a // service the row's colour stripe and the legend already name. {bar.span.serviceName} @@ -150,7 +148,7 @@ function TraceTimelineRowImpl({ {cacheInfo?.result && ( @@ -159,14 +157,14 @@ function TraceTimelineRowImpl({ )} {bar.isCollapsed && bar.childCount > 0 && ( )} - + {durationLabel}
    @@ -178,7 +176,7 @@ function TraceTimelineRowImpl({ className={cn( "@container/bar absolute top-1/2 -translate-y-1/2 flex items-center", // Not overflow-hidden: the outside label is a child and has to escape the box. - "whitespace-nowrap font-mono text-[11px]", + "whitespace-nowrap font-mono text-2xs", )} style={{ left: BAR_LEFT, @@ -210,7 +208,7 @@ function TraceTimelineRowImpl({ data-outside-label="" className={cn( "pointer-events-none absolute top-1/2 hidden -translate-y-1/2 whitespace-nowrap", - "font-mono text-[10px] text-muted-foreground/80 @max-[56px]/bar:block", + "font-mono text-3xs text-muted-foreground/80 @max-[56px]/bar:block", "left-full ml-[5px] data-[side=left]:left-auto data-[side=left]:right-full", "data-[side=left]:ml-0 data-[side=left]:mr-[5px]", )} @@ -223,7 +221,7 @@ function TraceTimelineRowImpl({ pass — CSS can't tell whether the clamp above actually bit. */} @@ -231,7 +229,7 @@ function TraceTimelineRowImpl({ diff --git a/packages/ui/src/components/traces/trace-timeline-search.tsx b/packages/ui/src/components/traces/trace-timeline-search.tsx index 9e8ee7217b..96a5cd1330 100644 --- a/packages/ui/src/components/traces/trace-timeline-search.tsx +++ b/packages/ui/src/components/traces/trace-timeline-search.tsx @@ -43,7 +43,7 @@ export function TraceTimelineSearch({ /> {query && ( <> - + {currentMatch > 0 ? `${currentMatch}/${matchCount}` : `${matchCount} of ${totalCount}`} diff --git a/packages/ui/src/components/traces/trace-timeline-time-axis.tsx b/packages/ui/src/components/traces/trace-timeline-time-axis.tsx index 1c606f73fd..cb3044e586 100644 --- a/packages/ui/src/components/traces/trace-timeline-time-axis.tsx +++ b/packages/ui/src/components/traces/trace-timeline-time-axis.tsx @@ -41,7 +41,7 @@ export function TraceTimelineTimeAxis({ controller, columnWidthPx, gridRef }: Tr syncChildren(axis, ticks.length, () => { const el = document.createElement("span") el.className = - "pointer-events-none absolute bottom-1 -translate-x-1/2 whitespace-nowrap font-mono text-[10px] font-medium text-muted-foreground" + "pointer-events-none absolute bottom-1 -translate-x-1/2 whitespace-nowrap font-mono text-3xs font-medium text-muted-foreground" return el }) const grid = gridRef?.current ?? null diff --git a/packages/ui/src/components/traces/trace-timeline.tsx b/packages/ui/src/components/traces/trace-timeline.tsx index 08b2707eb1..dd105813d4 100644 --- a/packages/ui/src/components/traces/trace-timeline.tsx +++ b/packages/ui/src/components/traces/trace-timeline.tsx @@ -8,7 +8,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" import { formatDuration } from "../../lib/format" import { summarizeClockSkew } from "../../lib/span-tree" import type { SpanNode } from "../../lib/types" -import { getServiceColor } from "../../lib/colors" import { isEditableTarget } from "../../lib/keyboard" import { useContainerSize } from "../../hooks/use-container-size" import { useTraceView } from "./trace-view-context" @@ -40,6 +39,7 @@ import { SIDEBAR_WIDTH_NARROW_MIN, SIDEBAR_WIDTH_STORAGE_KEY, } from "./trace-timeline-types" +import { ServiceDot } from "../service-dot" function readSidebarWidth(): number { if (typeof window === "undefined") return SIDEBAR_WIDTH_DEFAULT @@ -517,7 +517,7 @@ export function TraceTimeline() { -
    +
    {/* Collapsing hides rows, so this count and the trace header's disagree. Say so explicitly instead of quietly showing a second, smaller "N spans". */} {/* Only while rows are hidden. Uncollapsed this just repeats the trace header's @@ -653,7 +653,7 @@ export function TraceTimeline() { className="pointer-events-none absolute top-0 bottom-0 left-0 z-20 w-px bg-foreground/40" style={{ display: "none" }} > - +
    {interactions.marquee && (
    -
    +
    {/* Pointer/keyboard hints are meaningless on touch — the legend takes the whole bar there. */}
    - + Drag {" "} zoom - + W A S D {" "} navigate - + / {" "} search @@ -689,7 +689,7 @@ export function TraceTimeline() { onClick={() => setShowShortcuts((v) => !v)} className="hover:text-foreground/70" > - + ? {" "} all shortcuts @@ -699,15 +699,15 @@ export function TraceTimeline() {
    {services.map((service) => (
    -
    + {service}
    ))}
    -
    + Error
    @@ -732,7 +732,7 @@ export function TraceTimeline() { Esc
    -
    +
    {( [ ["W / S", "Zoom in / out at cursor (⇧ faster)"], @@ -752,7 +752,7 @@ export function TraceTimeline() { ] as const ).map(([keys, desc]) => ( - + {keys} {desc} diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx index ad6536fe3c..f8b96570e9 100644 --- a/packages/ui/src/components/ui/badge.tsx +++ b/packages/ui/src/components/ui/badge.tsx @@ -27,7 +27,7 @@ export const badgeVariants = cva( lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm", sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs sm:h-4 sm:min-w-4 sm:text-[.625rem]", // Dense tables and meta rows: fixed 10px on every breakpoint. - xs: "h-4 min-w-4 rounded-[.25rem] px-1 text-[10px] sm:h-4 sm:min-w-4 sm:text-[10px] [&_svg:not([class*='size-'])]:size-2.5 sm:[&_svg:not([class*='size-'])]:size-2.5", + xs: "h-4 min-w-4 rounded-[.25rem] px-1 text-3xs sm:h-4 sm:min-w-4 sm:text-3xs [&_svg:not([class*='size-'])]:size-2.5 sm:[&_svg:not([class*='size-'])]:size-2.5", }, pill: { false: "", @@ -51,6 +51,9 @@ export const badgeVariants = cva( secondary: "bg-secondary text-secondary-foreground [button&,a&]:hover:bg-secondary/90", ok: TONE_SOFT.ok, warn: TONE_SOFT.warn, + // Tiny uppercase label chip (System, Current, Pinned). Sizes itself, so it + // resets the size variant; recolour with className. + tag: "h-auto min-w-0 rounded-xs bg-muted px-1 py-px text-3xs text-muted-foreground uppercase tracking-wide sm:h-auto sm:min-w-0 sm:text-3xs [button&,a&]:hover:bg-muted/80 [button&,a&]:hover:text-foreground", }, }, }, diff --git a/packages/ui/src/components/ui/delta.tsx b/packages/ui/src/components/ui/delta.tsx index 8058285108..c1952360aa 100644 --- a/packages/ui/src/components/ui/delta.tsx +++ b/packages/ui/src/components/ui/delta.tsx @@ -21,6 +21,7 @@ export function Delta({ suffix, flatThreshold = 0.001, format = formatPercent, + muteWorse = false, className, }: { /** Relative change (0.12 = +12%). Or pass `current` + `previous`. */ @@ -34,6 +35,8 @@ export function Delta({ flatThreshold?: number /** Formats the absolute change: defaults to a percent; pass one for pp or durations. */ format?: (magnitude: number) => string + /** Leave a worsening move uncoloured, for volumes where a drop is not an alarm. */ + muteWorse?: boolean className?: string }): React.ReactElement { const change = @@ -56,7 +59,11 @@ export function Delta({ + +export interface FilterChipProps extends Omit< + TogglePrimitive.Props, + "pressed" | "onPressedChange" | "children" | "className" +> { + className?: string + pressed: boolean + onPressedChange: (pressed: boolean) => void + /** Status tone for the pressed border and the leading dot. Defaults to neutral. */ + tone?: Tone + size?: VariantProps["size"] + /** Leading dot in the tone colour. */ + dot?: boolean + /** Trailing count, drawn muted and tabular. */ + count?: React.ReactNode + children?: React.ReactNode +} + +export function FilterChip({ + pressed, + onPressedChange, + tone = "neutral", + size, + dot = false, + count, + className, + children, + ...props +}: FilterChipProps): React.ReactElement { + return ( + onPressedChange(next)} + className={cn(filterChipVariants({ size }), PRESSED_BORDER[tone], className)} + data-slot="filter-chip" + {...props} + > + {dot ? : null} + {children} + {count != null ? ( + {count} + ) : null} + + ) +} diff --git a/packages/ui/src/components/ui/icon-button.tsx b/packages/ui/src/components/ui/icon-button.tsx new file mode 100644 index 0000000000..08e13be955 --- /dev/null +++ b/packages/ui/src/components/ui/icon-button.tsx @@ -0,0 +1,42 @@ +"use client" + +import type React from "react" +import { Button, type ButtonProps } from "./button" +import { Kbd } from "./kbd" +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip" + +type IconSize = "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "icon-xl" + +export interface IconButtonProps extends Omit { + /** Accessible name, also shown as the tooltip. */ + label: string + size?: IconSize + /** Keyboard shortcut rendered in the tooltip. */ + shortcut?: React.ReactNode + /** `false` keeps the aria-label but drops the tooltip. */ + tooltip?: false +} + +/** An icon-only Button that always carries a name and, by default, a tooltip. */ +export function IconButton({ + label, + size = "icon-sm", + variant = "ghost", + shortcut, + tooltip, + ...props +}: IconButtonProps): React.ReactElement { + const button = ( +
    ) } + +/** + * Share-of-max bar painted behind a row's own text (a table cell, a list row). + * The parent must be `relative`, with the row content in a `relative` layer above it. + */ +export function BackdropBar({ + value, + max = 1, + align = "left", + className, +}: { + value: number + max?: number + /** The edge the bar grows from: `right` for right-aligned numeric cells. */ + align?: "left" | "right" + /** Fill colour and opacity, e.g. `bg-severity-info/10`. */ + className?: string +}): React.ReactElement | null { + const width = widthPercent(max > 0 ? value / max : 0, 0) + if (width === 0) return null + return ( +
    + ) +} diff --git a/packages/ui/src/components/ui/mini-bars.tsx b/packages/ui/src/components/ui/mini-bars.tsx new file mode 100644 index 0000000000..307149aa79 --- /dev/null +++ b/packages/ui/src/components/ui/mini-bars.tsx @@ -0,0 +1,94 @@ +import type * as React from "react" +import { cn } from "../../lib/utils" + +const finite = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0) + +/** + * Static bar sparkline in one , no chart runtime. Sized by `className`; + * all lengths (`floor`, `minHeight`, `gap`) are percent of the box. + */ +export function MiniBars({ + values, + color = "currentColor", + floor = 0, + floorColor, + minHeight = 5, + gap = 2, + opacityRamp = false, + tail, + className, + style, + label, +}: { + /** Bucket values, oldest first. Negative and non-finite values draw as zero. */ + values: ReadonlyArray + /** Raw CSS colour; defaults to `currentColor` so a text-* class can tint it. */ + color?: string + /** Height of the tick drawn for a zero bucket; 0 leaves it blank. */ + floor?: number + /** Fill for the zero tick; defaults to `color` at low opacity. */ + floorColor?: string + /** Smallest height for any non-zero bucket. */ + minHeight?: number + gap?: number + /** Fade short bars (0.3 to 1 by share of the peak). */ + opacityRamp?: boolean + /** Fraction of trailing bars drawn at full strength; earlier ones dim. */ + tail?: number + className?: string + style?: React.CSSProperties + /** Accessible name; the bars are decorative without one. */ + label?: string +}): React.ReactElement { + const count = values.length + const max = count > 0 ? Math.max(...values.map(finite)) : 0 + const barWidth = Math.max((100 - gap * (count - 1)) / Math.max(count, 1), 0.5) + const tailStart = tail === undefined ? 0 : count - Math.max(1, Math.round(count * tail)) + return ( + + {label ? {label} : null} + {values.map((raw, index) => { + const value = finite(raw) + const x = index * (barWidth + gap) + const dim = index < tailStart + if (value === 0) { + if (floor <= 0) return null + return ( + + ) + } + const ratio = max > 0 ? value / max : 0 + const height = Math.max(ratio * 100, minHeight) + const opacity = (opacityRamp ? 0.3 + ratio * 0.7 : 1) * (dim ? 0.5 : 1) + return ( + + ) + })} + + ) +} diff --git a/packages/ui/src/components/ui/panel.tsx b/packages/ui/src/components/ui/panel.tsx index 2f6f1f0cf1..0ebf692d8f 100644 --- a/packages/ui/src/components/ui/panel.tsx +++ b/packages/ui/src/components/ui/panel.tsx @@ -1,15 +1,43 @@ import type * as React from "react" import { cn } from "../../lib/utils" +const PANEL_TONE = { + default: "bg-card", + muted: "bg-muted/40", + background: "bg-background", +} as const + +const PANEL_PADDING = { + sm: "p-3", + md: "p-4", +} as const + +export type PanelTone = keyof typeof PANEL_TONE + /** * Flat bordered section frame (charts, side lists, detail sections). `Card` is * the raised, rounded-2xl surface for standalone content; a panel sits inside - * a page grid and stays quiet. + * a page grid and stays quiet. `rounded-md` is the one panel radius. */ -export function Panel({ className, ...props }: React.ComponentProps<"section">): React.ReactElement { +export function Panel({ + className, + padded = false, + tone = "default", + ...props +}: React.ComponentProps<"section"> & { + /** Inner padding: `true` is `p-4`, `"sm"` is `p-3`. Off for panels with a `PanelHeader`. */ + padded?: boolean | "sm" + /** Surface fill: `muted` for a recessed well, `background` for a frame on a card. */ + tone?: PanelTone +}): React.ReactElement { return (
    @@ -57,7 +85,7 @@ export function PanelHeader({ export function PanelTitle({ className, ...props }: React.ComponentProps<"h3">): React.ReactElement { return (

    diff --git a/packages/ui/src/components/ui/refresh-button.tsx b/packages/ui/src/components/ui/refresh-button.tsx new file mode 100644 index 0000000000..ee0df0dc1c --- /dev/null +++ b/packages/ui/src/components/ui/refresh-button.tsx @@ -0,0 +1,69 @@ +"use client" + +import type React from "react" +import { cn } from "../../lib/utils" +import { ArrowRotateAnticlockwiseIcon } from "../icons" +import { Button, type ButtonProps } from "./button" +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip" + +export interface RefreshButtonProps extends Omit { + onRefresh: () => void + /** Spins the icon and disables the button while a refresh is in flight. */ + pending?: boolean + /** Icon only, with the label as aria-label and tooltip. */ + iconOnly?: boolean + label?: string + size?: "xs" | "sm" | "default" +} + +/** + * Reload-now button. The icon spins while pending (the label stays readable), which is the + * one refresh affordance; do not hand-roll `animate-spin` on refresh icons elsewhere. + */ +export function RefreshButton({ + onRefresh, + pending = false, + iconOnly = false, + label = "Refresh", + size = "sm", + variant = "outline", + disabled, + ...props +}: RefreshButtonProps): React.ReactElement { + const icon = + if (iconOnly) { + return ( + + + } + > + {icon} + + {label} + + ) + } + return ( + + ) +} diff --git a/packages/ui/src/components/ui/row-actions-menu.tsx b/packages/ui/src/components/ui/row-actions-menu.tsx new file mode 100644 index 0000000000..d93cc82f18 --- /dev/null +++ b/packages/ui/src/components/ui/row-actions-menu.tsx @@ -0,0 +1,47 @@ +"use client" + +import type React from "react" +import { DotsVerticalIcon } from "../icons" +import { Button } from "./button" +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "./dropdown-menu" + +export interface RowActionsMenuProps { + /** Trigger aria-label. */ + label?: string + align?: "start" | "center" | "end" + disabled?: boolean + className?: string + /** DropdownMenuItem, DropdownMenuSeparator, etc. */ + children: React.ReactNode +} + +/** The kebab menu at the end of a row: one trigger size, one icon size. */ +export function RowActionsMenu({ + label = "More actions", + align = "end", + disabled, + className, + children, +}: RowActionsMenuProps): React.ReactElement { + return ( + + + } + > + + + + {children} + + + ) +} diff --git a/packages/ui/src/components/ui/sidebar.tsx b/packages/ui/src/components/ui/sidebar.tsx index e7c8c537b6..4957bc254a 100644 --- a/packages/ui/src/components/ui/sidebar.tsx +++ b/packages/ui/src/components/ui/sidebar.tsx @@ -410,7 +410,7 @@ export function SidebarGroupLabel({ }: useRender.ComponentProps<"div">): React.ReactElement { const defaultProps = { className: cn( - "flex h-8 shrink-0 items-center rounded-lg px-2 font-semibold text-[11px] text-sidebar-foreground uppercase tracking-widest outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "flex h-8 shrink-0 items-center rounded-lg px-2 font-semibold text-2xs text-sidebar-foreground uppercase tracking-widest outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0", className, ), diff --git a/packages/ui/src/components/ui/table.tsx b/packages/ui/src/components/ui/table.tsx index eeed6f0ce1..a7f67cf18f 100644 --- a/packages/ui/src/components/ui/table.tsx +++ b/packages/ui/src/components/ui/table.tsx @@ -50,7 +50,8 @@ export function TableHeader({ return ( ): return ( ): return ( { it("shares the duration ladder and has a placeholder for missing values", () => { expect(formatLatency(90_000)).toBe("1.5min") expect(formatLatency(3_600_000)).toBe("1.0h") - expect(formatLatency(Number.NaN)).toBe("-") + expect(formatLatency(Number.NaN)).toBe(EMPTY_VALUE) }) }) @@ -105,7 +106,13 @@ describe("percent formatters stay separate", () => { expect(formatPercent(0.0001)).toBe("0%") expect(formatPercent(0.052)).toBe("5.2%") expect(formatPercent(0.87)).toBe("87%") - expect(formatPercent(Number.NaN)).toBe("—") + expect(formatPercent(Number.NaN)).toBe(EMPTY_VALUE) + }) + + it("formatPercent with a floor marks a tiny non-zero share", () => { + expect(formatPercent(0.0004, { floor: 0.001 })).toBe("<0.1%") + expect(formatPercent(0, { floor: 0.001 })).toBe("0%") + expect(formatPercent(0.052, { floor: 0.001 })).toBe("5.2%") }) it("formatErrorRate surfaces a rare failure instead of showing 0%", () => { @@ -119,8 +126,8 @@ describe("percent formatters stay separate", () => { describe("formatLoad / formatUptime", () => { it("renders load and uptime", () => { expect(formatLoad(1.234)).toBe("1.23") - expect(formatLoad(Number.NaN)).toBe("—") - expect(formatUptime(0)).toBe("—") + expect(formatLoad(Number.NaN)).toBe(EMPTY_VALUE) + expect(formatUptime(0)).toBe(EMPTY_VALUE) expect(formatUptime(30 * 60)).toBe("30m") expect(formatUptime(5 * 3600)).toBe("5h") expect(formatUptime(3 * 86_400 + 4 * 3600)).toBe("3d 4h") diff --git a/packages/ui/src/lib/colors.ts b/packages/ui/src/lib/colors.ts index 7944810adb..d6a6b572f4 100644 --- a/packages/ui/src/lib/colors.ts +++ b/packages/ui/src/lib/colors.ts @@ -215,3 +215,47 @@ export function calculateSelfTime( const childrenTime = merged.reduce((sum, i) => sum + (i.end - i.start), 0) return Math.max(0, span.durationMs - childrenTime) } + +/** Last-resort colour for a token that is neither on the document nor in {@link TOKEN_FALLBACK}. */ +export const DEFAULT_TOKEN_FALLBACK = "#6366f1" + +/** + * The one hex fallback per design token, used where a token cannot be read off + * the document (SSR, tests, a canvas painted before styles load). Callers name + * the token only; nothing else should carry its own `["--token", "#hex"]` pair. + */ +export const TOKEN_FALLBACK: Readonly> = { + "--background": "#0c0a09", + "--foreground": "#fafafa", + "--border": "#3f3f46", + "--muted-foreground": "#a1a1aa", + "--primary": "#6366f1", + "--destructive": "#ef4444", + "--chart-1": "#6366f1", + "--chart-2": "#22d3ee", + "--chart-3": "#f59e0b", + "--chart-4": "#fbbf24", + "--chart-5": "#f472b6", + "--chart-p99": "#e0484a", + "--chart-p95": "#e0a23a", + "--chart-p50": "#4f8ef7", + "--chart-error": "#e5484d", + "--chart-throughput": "#8b7cf6", + "--chart-apdex": "#3fb27f", + "--severity-trace": "#71717a", + "--severity-debug": "#3b82f6", + "--severity-info": "#10b981", + "--severity-warn": "#f59e0b", + "--severity-error": "#ef4444", + "--severity-fatal": "#b91c1c", +} satisfies Record + +/** + * The hex fallback for `--token`, `var(--token)` or Tailwind's `--color-token` alias; + * {@link DEFAULT_TOKEN_FALLBACK} when unmapped. + */ +export function tokenFallback(token: string): string { + const inner = token.startsWith("var(") && token.endsWith(")") ? token.slice(4, -1).trim() : token + const name = inner.startsWith("--color-") ? `--${inner.slice("--color-".length)}` : inner + return Object.hasOwn(TOKEN_FALLBACK, name) ? TOKEN_FALLBACK[name] : DEFAULT_TOKEN_FALLBACK +} diff --git a/packages/ui/src/lib/format.ts b/packages/ui/src/lib/format.ts index 2cfceb1ade..3c265a5522 100644 --- a/packages/ui/src/lib/format.ts +++ b/packages/ui/src/lib/format.ts @@ -8,6 +8,9 @@ import { formatDuration, formatNumber } from "@maple/domain/format" export { formatDuration, formatNumber } +/** The one placeholder for a missing or unrenderable value. Reference this, never the glyph. */ +export const EMPTY_VALUE = "—" + /** * Format a duration for an axis tick, at a precision derived from the tick spacing. * @@ -19,7 +22,7 @@ export { formatDuration, formatNumber } * follows the *step*, so a 0.001 ms step still reads "1234μs" rather than "1.234000ms". */ export function formatDurationAtStep(ms: number, stepMs: number): string { - if (!Number.isFinite(ms)) return "—" + if (!Number.isFinite(ms)) return EMPTY_VALUE const step = Number.isFinite(stepMs) && stepMs > 0 ? stepMs : 1 // Decimals needed to separate two ticks one step apart. The epsilon absorbs the float noise // in log10(0.001) = -3.0000000000000004, which would otherwise ask for one extra digit. @@ -88,8 +91,11 @@ export function formatBytesPerSecond(bytes: number): string { * past 10%, which suits gauges and resource bars. Error rates want the opposite * (a visible "<0.01%" so a rare failure never reads as zero). */ -export function formatPercent(fraction: number): string { - if (!Number.isFinite(fraction)) return "—" +export function formatPercent(fraction: number, options?: { readonly floor?: number }): string { + if (!Number.isFinite(fraction)) return EMPTY_VALUE + // `floor` (a fraction) renders a non-zero value under it as "<0.1%" instead of "0%". + const floor = options?.floor + if (floor !== undefined && fraction > 0 && fraction < floor) return `<${+(floor * 100).toFixed(4)}%` const pct = fraction * 100 if (pct < 0.05) return "0%" if (pct < 10) return `${pct.toFixed(1)}%` @@ -98,13 +104,13 @@ export function formatPercent(fraction: number): string { /** Two-decimal load average. */ export function formatLoad(load: number): string { - if (!Number.isFinite(load)) return "—" + if (!Number.isFinite(load)) return EMPTY_VALUE return load.toFixed(2) } /** Coarse uptime: minutes, then hours, then `"3d 4h"`. */ export function formatUptime(seconds: number): string { - if (!Number.isFinite(seconds) || seconds <= 0) return "—" + if (!Number.isFinite(seconds) || seconds <= 0) return EMPTY_VALUE const m = Math.floor(seconds / 60) if (m < 60) return `${m}m` const h = Math.floor(m / 60) @@ -118,7 +124,7 @@ export function formatUptime(seconds: number): string { */ export function formatLatency(ms: number): string { if (ms == null || Number.isNaN(ms)) { - return "-" + return EMPTY_VALUE } if (ms < 1) { return `${(ms * 1000).toFixed(0)}μs` @@ -282,7 +288,7 @@ export function formatThroughput(value: number, suffix: string): string { * turns a request rate into "12.346". */ export function formatRate(value: number): string { - if (!Number.isFinite(value)) return "—" + if (!Number.isFinite(value)) return EMPTY_VALUE const abs = Math.abs(value) if (abs >= 1_000) return formatNumber(value) if (abs >= 100) return Math.round(value).toLocaleString() diff --git a/packages/ui/src/lib/tone.ts b/packages/ui/src/lib/tone.ts index 2b63f1a303..0d42a693a2 100644 --- a/packages/ui/src/lib/tone.ts +++ b/packages/ui/src/lib/tone.ts @@ -75,3 +75,17 @@ export function severityTone(level: string | null | undefined): Tone { return "neutral" } } + +/** + * Identity accent for automation (agents, AI triage, Maple's own subsystems): not + * a severity, so it lives beside `Tone` rather than in it. One hue, from the + * `chart-ai-agent` theme colour, so "not a human" reads the same everywhere. + */ +export const AGENT_ACCENT = { + text: "text-chart-ai-agent", + fill: "bg-chart-ai-agent", + soft: "bg-chart-ai-agent/10 text-chart-ai-agent", + border: "border-chart-ai-agent/25", + ring: "ring-1 ring-chart-ai-agent/25", + surface: "border-chart-ai-agent/20 bg-chart-ai-agent/5", +} as const satisfies Record diff --git a/packages/ui/src/lib/utilization.ts b/packages/ui/src/lib/utilization.ts new file mode 100644 index 0000000000..91d9ea0b19 --- /dev/null +++ b/packages/ui/src/lib/utilization.ts @@ -0,0 +1,17 @@ +// Utilization (0..1 fraction of a capacity) → tone. One threshold pair for CPU, +// memory, disk and connection gauges: >= 90% is critical, >= 60% is a warning. + +import type { Tone } from "./tone" + +export const UTILIZATION_CRIT = 0.9 +export const UTILIZATION_WARN = 0.6 + +export type UtilizationLevel = Extract + +/** Non-finite input reads as "ok" so a missing gauge recedes instead of alarming. */ +export function utilizationLevel(fraction: number): UtilizationLevel { + if (!Number.isFinite(fraction)) return "ok" + if (fraction >= UTILIZATION_CRIT) return "crit" + if (fraction >= UTILIZATION_WARN) return "warn" + return "ok" +} diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 8febc65686..30e4b0c029 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -380,6 +380,10 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); + /* Dense sizes below text-xs. No line-height: they inherit, like the text-[Npx] they replaced. */ + --text-2xs: 0.6875rem; + --text-3xs: 0.625rem; + --text-4xs: 0.5625rem; --animate-skeleton: skeleton 2s -1s infinite linear; --animate-caret-blink: 1s ease-out infinite caret-blink; --animate-tile-in: tile-in 0.35s ease both;