Processor UI snags: fat CTAs, real Infrastructure tabs, one surface style (#7497)

Five unrelated snags in the processor (portal) UI, plus fixes they
turned up. No backend changes.

`84 files changed, +892 / −3364`

## Fat CTA buttons

- New `fat` prop on the SUI `Button`: 2.75rem tall, 1.25rem side
padding, 0.75rem corners, semibold. Composes with all four
variants/accents.
- Applied to the page-header CTA on Sources, Documents, Pipelines, Users
(both), Usage, Integrations, Infrastructure — 8 buttons, all in line
with a page title. Nothing else.
- `LandingActions` migrated onto the prop; `.landing-btn-primary` /
`.landing-btn-secondary` and their four `!important`s deleted. The
editor landing CTAs come down 4px with everything else.
- Infrastructure's header CTA is now primary; its "Create key" dropped
to secondary so they stop competing.

<!--IMG:buttons-->

## Documents empty state

- "Connect a source" opened the Sources *page*; it now opens the
`SourceModal` connect flow in place, no route change.
- No extra cache wiring: `SourceModal` already invalidates the sources
query.

<!--IMG:documents-->

## Infrastructure tabs

- Only API Keys and Audit Logs hit real endpoints. Deployments,
Security, Models and Storage read mock-only `/v1/infrastructure/*` that
no backend serves.
- Those four are now disabled: native `disabled`, out of the keyboard
tab order, `aria-disabled`, with the view refusing non-enabled keys as a
second guard.
- Real tabs moved leftmost; API Keys is the default; `?tab=` deep links
validated against the enabled set (the home flow's audit link still
works).
- Deleted: 4 tab components, their fetch fns and ~25 dead types, MSW
handlers, fixtures (908 → 253 lines), dead CSS, unused formatters, 240
lines of `en-US` strings. Most of the −3364.
- Page subtitle no longer advertises the disabled tabs.

<!--IMG:infrastructure-->

## Surface consolidation

- New `Surface` primitive (`sui-surface`): fill, hairline, radius, no
shadow. Kept separate from `sui-nav-surface` so nav chrome can diverge
later.
- `Card` composes it and no longer draws its own shadow — this changes
editor Card usages too, by design.
- SUI primitives that are surfaces adopt it: `MetricCard`,
`MetricStrip`, `NodeCard`, `Table`, `Collapsible`, `CodeBlock`.
- The portal gets its own `.portal-surface` with the same three
declarations, applied to 19 elements. A `sui-` class belongs to the
component that emits it, so feature markup doesn't wear one.
- `raised` variant = one subtle shadow for a surface in front of another
surface (the flow diagram's tiles). Same fill as its parent, so nesting
never shifts a region's colour. Dark has its own value.
- Floating chrome (modals, drawers, dropdowns, assistant, sidebar) keeps
its elevation; sunken wells stay sunken.

<!--IMG:surfaces-->

## Sources list

- Centred "No sources connected yet" empty state removed — it duplicated
the header CTA and pushed the table down the page. The header's "Connect
source" is the single way in.

## Drive-by fixes

- The connect flow rendered unstyled outside the Sources view:
`.portal-conn-picker__*` / `.portal-sources__connection-*` lived in
`views/Sources.css`, which none of the five components rendering them
imported. Moved to `components/sources/connections.css`.
- Three inert custom properties (`--surface-input`, `--color-border-2`,
`--text-default`) are defined nowhere in the codebase —
`.portal-conn-picker__card` had no fill at all as a result.
- Dead CSS removed from `Sources.css` (grep-verified unused): old
expanded-row panel + its keyframes, type-card block.

## Testing

- `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
+ stylelint), format, 238 files / 2063 tests.
- `frontend:typecheck:all` across all 9 tsconfigs.
- `frontend:storybook:a11y:changed` — 119 stories, light and dark, zero
violations, no regressions vs baseline.
- New tests: `Infrastructure.test.tsx` (tab order, default, disabled
behaviour, deep-link filtering) and a Documents test that the
empty-state CTA opens the modal without navigating.
- Merged `origin/main` (#7438 replaced `PipelineHeader` with the new
Create/Edit headers); full suite green at 240 files / 2072 tests after
the merge.
This commit is contained in:
EthanHealy01
2026-08-14 10:55:15 +00:00
committed by GitHub
parent 4b26797ad8
commit 55087313b7
82 changed files with 702 additions and 3361 deletions
@@ -7444,7 +7444,7 @@ morning = "Good morning"
[portal.infrastructure]
manageEditorDeployment = "Manage Editor deployment"
sectionsAriaLabel = "Infrastructure sections"
subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
subtitle = "API credentials and the audit trail for your Stirling workspace."
title = "Infrastructure"
[portal.infrastructure.apiKeys]
@@ -7472,11 +7472,6 @@ cancel = "Cancel"
confirm = "Revoke key"
title = "Revoke API key"
[portal.infrastructure.attestationLabel]
attested = "Attested"
inScope = "In scope"
notApplicable = "N/A"
[portal.infrastructure.audit]
filterAriaLabel = "Filter audit events by category"
heading = "Audit logs"
@@ -7548,11 +7543,6 @@ info = "Info"
success = "Success"
warning = "Warning"
[portal.infrastructure.certLabel]
certified = "Certified"
inProgress = "In progress"
notStarted = "Not started"
[portal.infrastructure.createKey]
cancel = "Cancel"
createKey = "Create key"
@@ -7566,240 +7556,10 @@ subtitleCreated = "Copy this secret now — it won't be shown again."
title = "Create API key"
titleCreated = "Key created"
[portal.infrastructure.deployLabel]
live = "Live"
queued = "Queued"
rolledBack = "Rolled back"
rolling = "Rolling out"
[portal.infrastructure.deployments]
loadAria = "Load for {{name}}"
msValue = "{{value}} ms"
throughputValue = "{{value}}/min"
[portal.infrastructure.deployments.deployColumns]
deployedBy = "Deployed by"
environment = "Environment"
product = "Product"
status = "Status"
version = "Version"
when = "When"
[portal.infrastructure.deployments.recent]
heading = "Recent deployments"
subheading = "The latest rollouts across products and environments."
[portal.infrastructure.deployments.regionColumns]
instances = "Instances"
latency = "Latency"
load = "Load"
p99 = "P99"
region = "Region"
status = "Status"
throughput = "Throughput"
uptime = "Uptime"
version = "Version"
[portal.infrastructure.deployments.regions]
heading = "Regions"
subheading = "Live health for every deployed Stirling region — latency, load, and rollout version."
[portal.infrastructure.deployments.regions.empty]
description = "Deployed regions appear here once your workspace is provisioned."
title = "No regions deployed"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
[portal.infrastructure.modelLabel]
active = "Active"
degraded = "Degraded"
disabled = "Disabled"
[portal.infrastructure.models]
heading = "Models"
loadAria = "Load for {{name}}"
msValue = "{{value}} ms"
subheading = "The model catalogue and routing that powers document processing across your workspace."
[portal.infrastructure.models.byom]
description = "Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing."
title = "Bring your own model"
[portal.infrastructure.models.catalogue]
heading = "Catalogue"
sub = "Managed models available to your workspace, with live latency and cost."
subEnterprise = "Managed, bring-your-own, and on-prem models — with per-region pinning available."
[portal.infrastructure.models.catalogue.empty]
description = "Models in your workspace's catalogue appear here."
title = "No models available"
[portal.infrastructure.models.columns]
cost = "Cost"
latency = "Latency"
load = "Load"
model = "Model"
status = "Status"
type = "Type"
version = "Version"
[portal.infrastructure.models.cost]
perCall = "{{price}}/call"
perThousand = "{{price}}/1k"
[portal.infrastructure.models.metrics]
activeModels = "Active models"
avgLatency = "Avg latency"
included = "Included"
monthlySpend = "Monthly model spend"
[portal.infrastructure.models.routing]
empty = "No routing rules configured."
heading = "Routing rules"
sub = "Which model handles each operation. The default applies when no narrower rule matches."
subLocked = "Route operations to specific models — available on paid plans."
[portal.infrastructure.models.routing.lockedBanner]
description = "Upgrade to Pro to control which model handles each operation and document type."
title = "Model routing is a paid feature"
[portal.infrastructure.models.routingColumns]
default = "Default"
docType = "Document type"
modelForAria = "Model for {{operation}}"
operation = "Operation"
routedTo = "Routed to"
[portal.infrastructure.modelTypeLabel]
classification = "Classification"
extraction = "Extraction"
llm = "LLM"
ocr = "OCR"
[portal.infrastructure.regionLabel]
degraded = "Degraded"
down = "Down"
healthy = "Healthy"
[portal.infrastructure.security.access.byok]
description = "Supply a key from your own KMS. Stirling encrypts with it but can still read."
label = "Bring your own key (BYOK)"
[portal.infrastructure.security.access.hyok]
description = "Keys never leave your KMS. Stirling holds only ciphertext."
label = "Hold your own key (HYOK)"
[portal.infrastructure.security.access.stirling]
description = "Stirling manages encryption keys. Simplest — zero key ops on your side."
label = "Stirling-held keys"
[portal.infrastructure.security.accessPolicy]
heading = "Document access policy"
subheading = "Controls who can decrypt processed documents at rest."
[portal.infrastructure.security.attestations]
heading = "Compliance attestations"
noReport = "No report available"
subheading = "Framework-by-framework audit posture, with reports available on attested controls."
viewReport = "View report →"
[portal.infrastructure.security.compliance]
heading = "Compliance"
subheading = "Attestations and certifications covering the Stirling platform."
[portal.infrastructure.security.empty]
description = "Your workspace's security configuration will appear here."
title = "Security posture unavailable"
[portal.infrastructure.security.hyokBanner]
description = "With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time."
title = "Stirling cannot decrypt your documents"
[portal.infrastructure.security.ipAllowlist]
empty = "No IP ranges configured — all IPs allowed."
heading = "IP allowlist"
sub = "API access is restricted to these CIDR ranges."
subLocked = "Restrict API access to known IP ranges — available on paid plans."
[portal.infrastructure.security.ipAllowlist.lockedBanner]
description = "Upgrade to Pro to restrict API access to specific networks."
title = "IP allowlisting is a paid feature"
[portal.infrastructure.security.ipColumns]
added = "Added"
addedBy = "Added by"
cidr = "CIDR"
label = "Label"
[portal.infrastructure.security.keyManagement]
algorithm = "Algorithm"
heading = "Encryption key management"
keyId = "Key identifier"
lastRotated = "Last rotated"
rotateKey = "Rotate key"
rotationPolicy = "Rotation policy"
subheading = "Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate."
[portal.infrastructure.security.managedBanner]
description = "Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS."
title = "Keys are managed by Stirling on your plan"
[portal.infrastructure.security.residency.apac]
description = "ap-southeast-1"
label = "Asia Pacific"
[portal.infrastructure.security.residency.eu]
description = "eu-west-1 · GDPR data boundary"
label = "European Union"
[portal.infrastructure.security.residency.us]
description = "us-east-1 · us-west-2"
label = "United States"
[portal.infrastructure.security.residencyHeader]
heading = "Data residency"
subheading = "Where documents are stored and processed."
[portal.infrastructure.storage]
gbValue = "{{value}} GB"
percentUsed = "{{value}} used"
[portal.infrastructure.storage.empty]
description = "Connected storage and usage appear here."
title = "No storage configured"
[portal.infrastructure.storage.lifecycle]
active = "Active"
activeRange = "0{{value}}d"
archived = "Archived"
coldStorage = "cold storage"
deleted = "Deleted"
never = "never"
purged = "purged"
[portal.infrastructure.storage.providers]
connect = "Connect"
connected = "Connected"
heading = "Connected providers"
subheading = "Where processed artifacts are written."
[portal.infrastructure.storage.retention]
heading = "Retention"
subheading = "How long artifacts are kept before lifecycle deletion."
windowLabel = "Default retention window"
[portal.infrastructure.storage.retentionOption]
days_one = "{{count}} day"
days_other = "{{count}} days"
never = "Never delete"
[portal.infrastructure.storage.totalUsage]
heading = "Total usage"
progressLabel = "Storage used"
subheading = "Storage consumed across all connected providers."
[portal.infrastructure.tabs]
apiKeys = "API Keys"
audit = "Audit Logs"
@@ -8875,10 +8635,6 @@ cancel = "Cancel"
confirm = "Delete"
title = "Delete source?"
[portal.sources.empty]
description = "Connect a storage location so your policies have somewhere to pull data from."
title = "No sources connected yet"
[portal.sources.kpi]
inUse = "In use"
total = "Connections"
@@ -32,8 +32,7 @@ export function LandingActions({
<>
<Group gap="sm" justify="center" wrap="wrap" mb="xs">
<Button
className="landing-btn-primary"
px="xl"
fat
leftSection={
<LocalIcon icon={icons.uploadIconName} width="1rem" height="1rem" />
}
@@ -47,8 +46,7 @@ export function LandingActions({
<Button
variant="secondary"
className="landing-btn-secondary"
px="xl"
fat
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={(e) => {
e.stopPropagation();
@@ -64,7 +62,7 @@ export function LandingActions({
size="lg"
variant="secondary"
aria-label={terminology.mobileUpload}
className="landing-btn-secondary landing-btn-icon"
className="landing-btn-icon"
onClick={(e) => {
e.stopPropagation();
onMobileUploadClick();
@@ -136,18 +136,8 @@
}
/* ── Action buttons ──────────────────────────────────────── */
.landing-btn-primary,
.landing-btn-secondary {
border-radius: 0.75rem !important;
font-weight: 600 !important;
}
.landing-btn-primary,
.landing-btn-secondary,
.landing-btn-icon {
min-height: 3rem !important;
}
.landing-btn-icon {
width: 3rem !important;
min-width: 3rem !important;
border-radius: 0.75rem !important;
+5
View File
@@ -15,6 +15,11 @@
display: none;
}
/* ---- fat ---- */
.sui-btn--fat.mantine-Button-root {
--button-radius: 0.75rem;
}
/* ---- shape ---- */
.sui-btn--circle.mantine-Button-root {
--button-radius: 50%;
@@ -89,6 +89,7 @@ const meta: Meta<typeof Button> = {
options: ["center", "start", "end", "between"],
},
shape: { control: "inline-radio", options: ["default", "circle", "pill"] },
fat: { control: "boolean" },
text: { control: "text" },
},
};
@@ -203,6 +204,35 @@ export const Padding: Story = {
),
};
/** `fat` is the chunky CTA treatment: 2.75rem tall, 1.25rem side padding, 0.75rem corners. */
export const Fat: Story = {
render: () => (
<div
style={{ display: "flex", flexDirection: "column", gap: 16, width: 460 }}
>
<Wrap>
<Button fat leftSection={<Plus />} text="Connect source" />
<Button fat variant="secondary" text="Export CSV" />
</Wrap>
<Wrap>
<Button fat variant="tertiary" text="Tertiary" />
<Button fat variant="quiet" text="Quiet" />
<Button fat accent="premium" leftSection={<Sparkle />} text="Upgrade" />
<Button fat accent="danger" text="Delete" />
</Wrap>
<Wrap>
<Button fat loading text="Saving" />
<Button fat disabled text="Disabled" />
<Button fat leftSection={<Plus />} aria-label="Add" />
</Wrap>
<Wrap>
<Button text="Default" />
<Button fat text="Fat" />
</Wrap>
</div>
),
};
/** Icons are optional and positional: `leftSection`, `rightSection`, or both. */
export const WithIcons: Story = {
render: (args) => (
+14 -2
View File
@@ -42,6 +42,7 @@ type ButtonOwnProps = {
variant?: ButtonVariant;
accent?: ButtonAccent;
size?: ButtonSize;
fat?: boolean;
/** Label size relative to `size`. Defaults to the `size`-derived value. */
fontSize?: ButtonFontSize;
/** Padding override for both axes */
@@ -103,6 +104,9 @@ function ButtonGroup({
);
}
const FAT_HEIGHT = "2.75rem";
const FAT_PADDING_X = "lg" satisfies ControlPadding;
const MANTINE_VARIANT: Record<ButtonVariant, string> = {
primary: "filled",
secondary: "outline",
@@ -123,6 +127,7 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
variant = "primary",
accent = "default",
size = "sm",
fat = false,
fontSize,
p,
px,
@@ -161,7 +166,7 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
: undefined;
// px/py override p for their axis; each stays undefined (= size default) if unset.
const padX = px ?? p;
const padX = px ?? p ?? (fat ? FAT_PADDING_X : undefined);
const padY = py ?? p;
// Sections flank a label → spread them without requiring justify="between".
@@ -175,6 +180,7 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
`sui-acc-${accent}`,
`sui-btn--${variant}`,
iconOnly ? "sui-btn--icon" : "",
fat ? "sui-btn--fat" : "",
shape !== "default" ? `sui-btn--${shape}` : "",
overflow === "wrap" ? "sui-btn--wrap" : "",
!hover ? "sui-btn--no-hover" : "",
@@ -238,7 +244,9 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
className={classes}
style={{
...(accentVars as CSSProperties),
...({ "--button-height": CONTROL_HEIGHT[size] } as CSSProperties),
...({
"--button-height": fat ? FAT_HEIGHT : CONTROL_HEIGHT[size],
} as CSSProperties),
// Relative label size, scaled off the `size` base (unset → Mantine default).
...(fontSize
? ({
@@ -253,6 +261,10 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
...(padY
? ({ "--sui-btn-py": CONTROL_PADDING[padY] } as CSSProperties)
: {}),
// mantineTheme writes font-weight inline on every button root, so this must be inline too.
...(fat
? ({ fontWeight: "var(--font-weight-semibold)" } as CSSProperties)
: {}),
// Icon-only: zero the size padding inline so the lone icon centres.
...(iconOnly ? ({ "--button-padding-x": "0" } as CSSProperties) : {}),
...style,
+1 -7
View File
@@ -1,11 +1,6 @@
.sui-card {
position: relative;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transition:
box-shadow var(--motion-fast),
border-color var(--motion-fast),
transform var(--motion-fast);
}
@@ -28,7 +23,6 @@
}
.sui-card--interactive:hover {
border-color: var(--c-border-strong);
box-shadow: var(--shadow-lg);
transform: translateY(-0.0625rem);
}
@@ -43,7 +37,7 @@
left: 0;
bottom: 0;
width: 0.25rem;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
border-radius: var(--radius-nav) 0 0 var(--radius-nav);
}
.sui-card--accent-default::before {
background: var(--c-primary);
+3 -1
View File
@@ -1,4 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/Card.css";
/** Subset of the shared accent dial that has a styled strip (see Card.css). */
@@ -18,7 +19,7 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
* (e.g. a list with row dividers).
*/
padding?: "none" | "tight" | "default" | "loose";
/** Use the lifted surface treatment (taller shadow, hover affordance). */
/** Adds the clickable affordance (pointer cursor, hover lift). */
interactive?: boolean;
children?: ReactNode;
}
@@ -40,6 +41,7 @@ export function Card({
<div
{...rest}
className={[
"sui-surface",
"sui-card",
`sui-card--pad-${padding}`,
accent ? `sui-card--accent-${accent}` : "",
+1 -2
View File
@@ -1,10 +1,9 @@
.sui-code {
background: var(--code-bg);
color: var(--code-text);
border-radius: var(--radius-lg);
border-radius: var(--radius-nav);
overflow: hidden;
border: 1px solid var(--code-border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.sui-code__chrome {
display: flex;
@@ -1,6 +1,4 @@
.sui-collapsible {
border: 1px solid var(--c-border-subtle);
border-radius: 0.5625rem;
overflow: hidden;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/Collapsible.css";
export interface CollapsibleProps {
@@ -29,7 +30,9 @@ export function Collapsible({
}: CollapsibleProps) {
return (
<div
className={["sui-collapsible", className ?? ""].filter(Boolean).join(" ")}
className={["sui-surface", "sui-collapsible", className ?? ""]
.filter(Boolean)
.join(" ")}
>
<button
type="button"
@@ -3,12 +3,7 @@
flex-direction: column;
gap: 0.5rem;
padding: 1.125rem 1.25rem;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transition:
box-shadow var(--motion-fast),
transform var(--motion-fast),
border-color var(--motion-fast);
min-width: 10rem;
@@ -37,7 +32,6 @@
}
.sui-metric--interactive:hover {
border-color: var(--c-border-strong);
box-shadow: var(--shadow-lg);
transform: translateY(-0.0625rem);
}
.sui-metric--interactive:focus-visible {
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/MetricCard.css";
export type DeltaDirection = "up" | "down" | "flat";
@@ -52,6 +53,7 @@ export function MetricCard({
const interactive = !!onClick;
const classes = [
"sui-surface",
"sui-metric",
`sui-metric--${size}`,
emphasis === "primary" ? "sui-metric--primary" : "",
@@ -21,10 +21,6 @@
align-items: center;
gap: 1.75rem;
padding: 0.625rem 1.25rem;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.sui-metric-strip__leading {
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/MetricStrip.css";
export interface MetricStripProps {
@@ -21,6 +22,7 @@ export function MetricStrip({
className,
}: MetricStripProps) {
const classes = [
layout === "row" ? "sui-surface" : "",
"sui-metric-strip",
`sui-metric-strip--${layout}`,
className ?? "",
-4
View File
@@ -8,10 +8,6 @@
display: flex;
align-items: stretch;
box-sizing: border-box;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition:
border-color var(--motion-fast),
box-shadow var(--motion-fast),
+2
View File
@@ -1,6 +1,7 @@
import type { HTMLAttributes, MouseEvent, ReactNode, Ref } from "react";
import { Button } from "@app/ui/Button";
import { IconBadge, type IconBadgeAccent } from "@app/ui/IconBadge";
import "@app/ui/Surface.css";
import "@app/ui/NodeCard.css";
/** Border tone. `selected` (a separate prop) overrides this with the primary ring. */
@@ -53,6 +54,7 @@ export function NodeCard({
{...rest}
ref={ref}
className={[
"sui-surface",
"sui-node-card",
tone === "warning" ? "sui-node-card--warning" : "",
selected ? "is-selected" : "",
+20
View File
@@ -0,0 +1,20 @@
/* Duplicates `.sui-nav-surface` on purpose: nav chrome may diverge from in-page surfaces. */
.sui-surface {
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
}
:root {
--sui-elevation-raised:
0 1px 2px rgba(0, 0, 0, 0.04), 0 2px 6px rgba(0, 0, 0, 0.06);
}
[data-theme="dark"] {
--sui-elevation-raised:
0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.35);
}
.sui-surface--raised {
box-shadow: var(--sui-elevation-raised);
}
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Surface } from "@app/ui/Surface";
const meta: Meta<typeof Surface> = {
title: "Primitives/Surface",
component: Surface,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ width: "22rem", background: "var(--c-bg)", padding: 12 }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Surface>;
/** The box any in-page panel, tile or table container sits in. */
export const Default: Story = {
args: {
children: (
<div style={{ padding: "1rem" }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>Panel title</div>
<div style={{ fontSize: 13, color: "var(--c-text-muted)" }}>
Same fill, border and radius on every surface.
</div>
</div>
),
},
};
/** `as` renders a landmark element instead of a div. */
export const AsSection: Story = {
args: {
as: "section",
"aria-label": "Usage",
children: (
<div style={{ padding: "0.75rem" }}>
<span style={{ fontSize: 13, color: "var(--c-text-muted)" }}>
Any content, not just cards.
</span>
</div>
),
},
};
+33
View File
@@ -0,0 +1,33 @@
import { forwardRef, type HTMLAttributes } from "react";
import "@app/ui/Surface.css";
export interface SurfaceProps extends HTMLAttributes<HTMLDivElement> {
/** Element to render; `section`/`article`/`aside` when the box is a landmark. */
as?: "div" | "section" | "article" | "aside";
/** For a surface in front of another surface: same fill, one step of elevation. */
raised?: boolean;
}
/** The standard in-page surface: fill, hairline border, shared radius, no shadow. */
export const Surface = forwardRef<HTMLDivElement, SurfaceProps>(
function Surface(
{ as: Component = "div", raised = false, className, children, ...rest },
ref,
) {
return (
<Component
ref={ref}
className={[
"sui-surface",
raised ? "sui-surface--raised" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</Component>
);
},
);
-3
View File
@@ -1,9 +1,6 @@
.sui-table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
}
.sui-table {
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/Table.css";
export interface TableColumn<T> {
@@ -62,7 +63,9 @@ export function Table<T>({
const interactive = Boolean(onRowClick);
return (
<div
className={["sui-table-wrap", className ?? ""].filter(Boolean).join(" ")}
className={["sui-surface", "sui-table-wrap", className ?? ""]
.filter(Boolean)
.join(" ")}
>
<table className="sui-table">
<thead>
+6 -2
View File
@@ -50,7 +50,7 @@ export function Tabs<K extends string = string>({
aria-label={ariaLabel}
>
{items.map((item) => {
const isActive = item.key === activeKey;
const isActive = item.key === activeKey && !item.disabled;
const styleVars =
isActive && item.accentColor
? ({ "--sui-tab-accent": item.accentColor } as React.CSSProperties)
@@ -61,13 +61,17 @@ export function Tabs<K extends string = string>({
type="button"
aria-pressed={isActive}
disabled={item.disabled}
aria-disabled={item.disabled || undefined}
className={
"sui-tabs__tab" +
(isActive ? " is-active" : "") +
(item.disabled ? " is-disabled" : "")
}
style={styleVars}
onClick={() => onChange(item.key)}
onClick={() => {
if (item.disabled) return;
onChange(item.key);
}}
>
{item.dotColor && (
<span
+1
View File
@@ -11,6 +11,7 @@ export * from "@app/ui/MetricCard";
export * from "@app/ui/NodeCard";
export * from "@app/ui/NavItem";
export * from "@app/ui/NavSurface";
export * from "@app/ui/Surface";
export * from "@app/ui/PanelHeader";
export * from "@app/ui/CodeBlock";
export * from "@app/ui/SectionDivider";
+3 -6
View File
@@ -64,12 +64,9 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
| Policies | `GET/POST /api/v1/policies` · `GET/DELETE /api/v1/policies/{id}` · `POST /api/v1/policies/{id}/run` | — | `fetchPolicies` · `savePolicy` · `deletePolicy` · `runPolicy` | `PoliciesResponse` · `Policy` |
| Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` |
| Components | `GET /v1/components` | `tier` | `fetchComponents` | `ComponentsResponse` |
| Infrastructure | `GET /v1/infrastructure/deployments` | `tier` | `fetchDeployments` | `DeploymentsResponse` |
| Infrastructure | `GET /v1/infrastructure/api-keys` | `tier` | `fetchApiKeys` | `ApiKey[]` |
| Infrastructure | `GET /v1/infrastructure/security` | `tier` | `fetchSecurity` | `SecurityConfig` |
| Infrastructure | `GET /v1/infrastructure/models` | `tier` | `fetchModels` | `ModelsResponse` |
| Infrastructure | `GET /v1/infrastructure/storage` | `tier` | `fetchStorage` | `StorageConfig` |
| Infrastructure | `GET /v1/infrastructure/audit-log` | `tier` | `fetchAuditLog` | `AuditLogResponse` |
| Infrastructure | `GET/POST /api/v1/proprietary/ui-data/infrastructure/api-keys` · `DELETE …/api-keys/{id}` | — | `fetchApiKeys` · `createApiKey` · `revokeApiKey` | `ApiKeysResponse` · `CreatedApiKey` |
| Infrastructure | `GET /api/v1/proprietary/ui-data/infrastructure/audit-log` | `tier` | `fetchAuditLog` | `AuditLogResponse` |
| Infrastructure | `GET /api/v1/proprietary/ui-data/audit-export` | `format`, `fields` | `exportAuditLog` | CSV/JSON blob |
| Usage & Billing | `GET /v1/billing/usage` | — | `fetchBillingUsage` | `UsageSeriesResponse` |
| Usage & Billing | `GET /v1/billing/summary` | `tier` | `fetchBillingSummary` | `BillingSummary` |
| Usage & Billing | `GET /v1/billing/plans` | — | `fetchPlanOptions` | `PlanOption[]` |
@@ -1,44 +1,6 @@
import { apiClient } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
/* ──────────────────────────────────────────────────────────────────────── */
/* Deployments */
/* ──────────────────────────────────────────────────────────────────────── */
export type RegionStatus = "healthy" | "degraded" | "down";
export interface DeploymentRegion {
name: string;
code: string;
/** Median request latency, ms. */
latencyMs: number;
/** Current load as a fraction of provisioned capacity (01). */
load: number;
status: RegionStatus;
/** Deployed Stirling engine version. */
version: string;
/** 30-day uptime as a fraction (01). */
uptime: number;
/** Running instance count. */
instances: number;
/** Sustained throughput, docs/min. */
throughput: number;
/** P99 latency, ms. */
p99Ms: number;
}
export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued";
export interface RecentDeployment {
id: string;
version: string;
environment: "production" | "staging" | "canary";
product: string;
status: DeploymentStatus;
deployedBy: string;
timestamp: string;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* API Keys */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -72,105 +34,6 @@ export interface CreatedApiKey {
secret: string;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Security */
/* ──────────────────────────────────────────────────────────────────────── */
export type AccessPolicy = "stirling" | "byok" | "hyok";
export type DataResidency = "us" | "eu" | "apac";
export type CertStatus = "certified" | "in-progress" | "not-started";
export interface ComplianceCert {
id: string;
name: string;
status: CertStatus;
detail: string;
}
export interface IpAllowEntry {
id: string;
label: string;
cidr: string;
addedBy: string;
added: string;
}
/**
* Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is
* surfaced separately because the key *custody model* (who can decrypt) is the
* detail security teams scrutinise:
* - `managed` — Stirling-owned KMS keys; zero key ops on the customer side.
* - `byok` — customer key, but Stirling can use it to decrypt while processing.
* - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext.
*/
export type KeyMode = "managed" | "byok" | "hyok";
export interface KeyManagement {
mode: KeyMode;
/** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */
provider: string;
/** ARN-style identifier for the active key. */
keyId: string;
/** Encryption algorithm in force. */
algorithm: string;
/** Relative last-rotation time, e.g. "32 days ago". */
lastRotated: string;
/** Rotation cadence summary, e.g. "Automatic · every 90 days". */
rotationPolicy: string;
/**
* Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed
* tiers see the posture but cannot change provider — only enterprise can.
*/
customerManaged: boolean;
}
export type AttestationStatus = "attested" | "in-scope" | "not-applicable";
export interface ComplianceAttestation {
id: string;
name: string;
/** Framework family / short descriptor shown under the name. */
framework: string;
status: AttestationStatus;
/** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */
detail: string;
/** Stub link to the downloadable report; null when none is available. */
reportUrl: string | null;
}
export interface SecurityConfig {
accessPolicy: AccessPolicy;
dataResidency: DataResidency;
certs: ComplianceCert[];
ipAllowlist: IpAllowEntry[];
keyManagement: KeyManagement;
attestations: ComplianceAttestation[];
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Storage */
/* ──────────────────────────────────────────────────────────────────────── */
export type RetentionWindow = "30" | "60" | "90" | "180" | "never";
export interface StorageProvider {
id: string;
name: string;
kind: "stirling" | "s3" | "azure";
connected: boolean;
detail: string;
usedGb: number;
}
export interface StorageConfig {
/** Total used storage, GB. */
usedGb: number;
/** Quota ceiling, GB. */
quotaGb: number;
retention: RetentionWindow;
providers: StorageProvider[];
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Audit Logs */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -211,83 +74,12 @@ export interface AuditLogResponse {
fullServer: boolean;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Models */
/* ──────────────────────────────────────────────────────────────────────── */
export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem";
export type ModelType = "extraction" | "classification" | "ocr" | "llm";
export type ModelStatus = "active" | "degraded" | "disabled";
/** Whether a model's cost is billed per 1k documents or per individual call. */
export type ModelCostUnit = "per-1k-docs" | "per-call";
export interface ModelEntry {
id: string;
name: string;
provider: ModelProvider;
type: ModelType;
status: ModelStatus;
/** Median inference latency, ms. */
latencyMs: number;
/** Cost in USD for the model's billing unit (see {@link costUnit}). */
cost: number;
costUnit: ModelCostUnit;
version: string;
/** Share of capacity this model is currently absorbing (01). */
load: number;
/** True for customer-registered bring-your-own / on-prem models. */
managed: boolean;
}
/** A binding from a processing operation (optionally a doc-type) to a model. */
export interface RoutingRule {
id: string;
/** The operation or pipeline stage this rule governs. */
operation: string;
/** Doc-type scope, or "All document types" for a catch-all. */
docType: string;
/** id of the {@link ModelEntry} this operation routes to. */
modelId: string;
modelName: string;
/** Marks the fallback rule applied when no narrower rule matches. */
isDefault: boolean;
}
export interface ModelsSummary {
activeModels: number;
/** Capacity-weighted average latency across active models, ms. */
avgLatencyMs: number;
/** Projected monthly model spend, USD. */
monthlySpend: number;
}
export interface ModelsResponse {
summary: ModelsSummary;
models: ModelEntry[];
routing: RoutingRule[];
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Endpoints */
/* ──────────────────────────────────────────────────────────────────────── */
export interface DeploymentsResponse {
regions: DeploymentRegion[];
recent: RecentDeployment[];
}
const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`;
/** GET /v1/infrastructure/deployments?tier=… */
export async function fetchDeployments(
tier: Tier,
): Promise<DeploymentsResponse> {
return apiClient.local.json<DeploymentsResponse>(
`/v1/infrastructure/deployments${q(tier)}`,
);
}
const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys";
// Always this instance's own backend: a key authenticates the instance that issued it, so a
@@ -314,27 +106,6 @@ export async function revokeApiKey(id: string): Promise<void> {
await apiClient.local.json<void>(path, { method: "DELETE" });
}
/** GET /v1/infrastructure/security?tier=… */
export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
return apiClient.local.json<SecurityConfig>(
`/v1/infrastructure/security${q(tier)}`,
);
}
/** GET /v1/infrastructure/models?tier=… */
export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
return apiClient.local.json<ModelsResponse>(
`/v1/infrastructure/models${q(tier)}`,
);
}
/** GET /v1/infrastructure/storage?tier=… */
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
return apiClient.local.json<StorageConfig>(
`/v1/infrastructure/storage${q(tier)}`,
);
}
/** GET the audit log; SaaS or local, backend-scoped (admin → server, SaaS lead → team). */
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
const path = `/api/v1/proprietary/ui-data/infrastructure/audit-log${q(tier)}`;
@@ -3,10 +3,6 @@
/* ──────────────────────────────────────────────────────────────────────── */
.portal-editor-hero {
border-radius: var(--radius-xl);
border: 1px solid var(--c-border);
background: var(--c-surface);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
@@ -10,6 +10,7 @@ import {
} from "@portal/queries/infrastructure";
import { type EditorInstance } from "@portal/api/editorDeploy";
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
import "@portal/theme/surface.css";
import "@portal/components/EditorStatusCard.css";
/** The Stirling brand mark, drawn at the hero size. Decorative. */
@@ -120,7 +121,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) {
return (
<section
className="portal-editor-hero"
className="portal-surface portal-editor-hero"
aria-label={t("portal.home.editor.name")}
>
<div className="portal-editor-hero__row">
@@ -31,7 +31,7 @@ export function HomeHero() {
{procurement.loading ? (
// Hold the rail's shape rather than committing to a footer: branching before the snapshot
// lands paints the no-deal rail first, flashing on every refresh of an active deal.
<section className="portal-editor-hero" aria-busy>
<section className="portal-surface portal-editor-hero" aria-busy>
<div className="portal-editor-hero__row">
<Skeleton width="2rem" height="2rem" />
<Skeleton width="12rem" height="1rem" />
@@ -121,9 +121,10 @@
height: auto;
width: 100%;
text-align: left;
background: var(--c-bg);
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--sui-elevation-raised);
transition:
background var(--motion-fast),
border-color var(--motion-fast);
@@ -148,6 +149,7 @@
.portal-pf__node--soon {
background: transparent;
border-style: dashed;
box-shadow: none;
/* The dashed border and muted ink mark this as not-yet-available; fading the
whole node with opacity would drop its label below the contrast floor. */
color: var(--c-text-muted);
@@ -202,15 +204,16 @@
}
/* ── Policies card (core) ───────────────────────────────────────────────────── */
/* Same secondary-surface treatment as the nodes it sits between. */
.portal-pf__policies {
align-self: center;
flex: none;
width: 18rem;
padding: 0.75rem;
background: var(--c-bg);
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
box-shadow: var(--sui-elevation-raised);
}
.portal-pf__policies-head {
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Card, SegmentedControl, Skeleton, StatusBadge } from "@app/ui";
import "@app/ui/Surface.css";
import {
useView,
VIEW_PATHS,
@@ -34,6 +34,7 @@ import {
type LatestBundleQuote,
} from "@portal/billing/stripe";
import { PrepayModalHeader } from "@portal/components/billing/PrepayModalHeader";
import "@portal/theme/surface.css";
/**
* Prepaid-bundle purchase modal for the Processor billing page — "12 months for
@@ -1166,7 +1167,7 @@ function CalculatorStep({
</div>
{/* Finer settings as progressive-disclosure rows — a "Change" blooms the card picker. */}
<div className="portal-billing__bundle-rows">
<div className="portal-surface portal-billing__bundle-rows">
{rows.map((row) => {
const open = expanded === row.id;
return (
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { LockIcon } from "@portal/components/icons";
import "@portal/theme/surface.css";
/**
* Card-form stand-in shown on the checkout payment step when no Stripe publishable key is configured
@@ -9,7 +10,7 @@ import { LockIcon } from "@portal/components/icons";
export function CardPlaceholder() {
const { t } = useTranslation();
return (
<div className="portal-billing__card-placeholder">
<div className="portal-surface portal-billing__card-placeholder">
<div className="portal-billing__card-placeholder-head">
<span>{t("portal.billing.checkout.card.label", "Card details")}</span>
<span className="portal-billing__card-placeholder-badge">Stripe</span>
@@ -1198,9 +1198,6 @@
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
}
.portal-billing__card-placeholder-head {
display: flex;
@@ -1333,8 +1330,6 @@
/* Progressive-disclosure finer settings: summary rows that bloom a card picker. */
.portal-billing__bundle-rows {
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.portal-billing__bundle-row + .portal-billing__bundle-row {
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { MethodBadge, Tabs, type HttpMethod, type TabItem } from "@app/ui";
import { VERTICALS, ALL_ENDPOINTS } from "@portal/data/endpoints";
import { DocsSection } from "@portal/components/docs/DocsSection";
import "@portal/theme/surface.css";
type VerticalFilter = "all" | (typeof VERTICALS)[number]["key"];
@@ -46,7 +47,10 @@ export function EndpointReferenceSection() {
/>
<div className="portal-docs__endpoints">
{shown.map((v) => (
<div key={v.key} className="portal-docs__endpoint-group">
<div
key={v.key}
className="portal-surface portal-docs__endpoint-group"
>
<div className="portal-docs__endpoint-grouphead">
<span
className="portal-docs__endpoint-dot"
@@ -1,13 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
type RenderResult,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
import type { ReviewDocument } from "@portal/api/documents";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { ReviewQueue } from "@portal/components/documents/ReviewQueue";
// Deterministic i18n: keys returned verbatim.
@@ -18,6 +21,12 @@ vi.mock("react-i18next", () => ({
}),
}));
const navigate = vi.fn();
vi.mock("react-router-dom", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router-dom")>();
return { ...actual, useNavigate: () => navigate };
});
// Isolate ReviewQueue's own branching: stub the heavy children so the test
// doesn't need TierProvider (DocumentDrawer → useTier) or the real table body.
vi.mock("@portal/components/documents/DocumentDrawer", () => ({
@@ -30,7 +39,9 @@ vi.mock("@portal/components/documents/ReviewQueueTable", () => ({
const render = (ui: ReactElement): RenderResult =>
baseRender(
<MemoryRouter>
<MantineProvider>{ui}</MantineProvider>
<PortalTestProviders>
<UIProvider>{ui}</UIProvider>
</PortalTestProviders>
</MemoryRouter>,
);
@@ -56,6 +67,10 @@ const DOC: ReviewDocument = {
};
describe("ReviewQueue", () => {
beforeEach(() => {
navigate.mockReset();
});
it("hides the filter toolbar and shows CTAs when there are no documents", () => {
render(<ReviewQueue documents={[]} loading={false} />);
@@ -76,6 +91,31 @@ describe("ReviewQueue", () => {
).not.toBeInTheDocument();
});
it("opens the connect-a-source modal in place instead of navigating", async () => {
render(<ReviewQueue documents={[]} loading={false} />);
fireEvent.click(
screen.getByText("portal.documents.queue.empty.connectSource"),
);
expect(
await screen.findByText("portal.sources.types.folder.label"),
).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
it("still navigates to the pipeline builder from the other empty-state CTA", () => {
render(<ReviewQueue documents={[]} loading={false} />);
fireEvent.click(
screen.getByText("portal.documents.queue.empty.createPipeline"),
);
expect(navigate).toHaveBeenCalledWith(
`${toPortalPath(VIEW_PATHS.pipelines)}/new`,
);
});
it("shows the filter toolbar when documents exist", () => {
render(<ReviewQueue documents={[DOC]} loading={false} />);
@@ -15,6 +15,7 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { DocumentsIcon } from "@portal/components/icons";
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
import { SourceModal } from "@portal/components/sources/SourceModal";
type QueueFilter = "all" | "flagged" | "processed" | "in-review";
@@ -55,6 +56,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
const [filter, setFilter] = useState<QueueFilter>("all");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [connectSourceOpen, setConnectSourceOpen] = useState(false);
const searched = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -156,9 +158,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
</Button>
<Button
variant="secondary"
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`)
}
onClick={() => setConnectSourceOpen(true)}
>
{t("portal.documents.queue.empty.connectSource")}
</Button>
@@ -175,6 +175,12 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
)}
<DocumentDrawer doc={selected} onClose={() => setSelectedId(null)} />
<SourceModal
open={connectSourceOpen}
sourceId={null}
onClose={() => setConnectSourceOpen(false)}
/>
</div>
);
}
@@ -7,6 +7,7 @@ import {
useFileRunEventActions,
} from "@portal/queries/fileRunEvents";
import { FailureActionButtons } from "@portal/components/failures/FailureActionButtons";
import "@portal/theme/surface.css";
/**
* Recorded policy and pipeline failures, with the triage actions the server offered
@@ -137,7 +138,7 @@ function FailureBody({
return (
<ul className="portal-failures__list">
{events.map((event) => (
<li key={event.id} className="portal-failures__row">
<li key={event.id} className="portal-surface portal-failures__row">
<div className="portal-failures__meta">
<StatusBadge tone={SEVERITY_TONE[event.severity]}>
{t(
@@ -36,9 +36,6 @@
}
.portal-failures__row {
border: 1px solid var(--c-border);
border-radius: 8px;
background: var(--c-surface);
padding: 0.8rem 0.95rem;
}
@@ -52,6 +52,7 @@ export function ApiKeysTab() {
sub={t("portal.infrastructure.apiKeys.subheading")}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setModalOpen(true)}
leftSection={<span aria-hidden>+</span>}
@@ -1,46 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { DeploymentsTab } from "@portal/components/infrastructure/DeploymentsTab";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof DeploymentsTab> = {
title: "Portal/Infrastructure/DeploymentsTab",
component: DeploymentsTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DeploymentsTab>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/deployments", async () => {
await delay("infinite");
return HttpResponse.json({ regions: [], recent: [] });
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/deployments", () =>
HttpResponse.json({ regions: [], recent: [] }),
),
],
},
},
};
@@ -1,233 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Card,
Chip,
EmptyState,
ProgressBar,
StatusBadge,
Table,
type TableColumn,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchDeployments,
type DeploymentsResponse,
type DeploymentRegion,
type RecentDeployment,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import {
DEPLOY_LABEL,
DEPLOY_TONE,
pct,
REGION_LABEL,
REGION_TONE,
} from "@portal/components/infrastructure/infraFormat";
export function DeploymentsTab() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<DeploymentsResponse>(
() => fetchDeployments(tier),
[tier],
);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const regionCols: TableColumn<DeploymentRegion>[] = [
{
key: "name",
header: t("portal.infrastructure.deployments.regionColumns.region"),
render: (r) => (
<div className="portal-infra__cell-stack">
<span className="portal-infra__cell-strong">{r.name}</span>
<code className="portal-infra__cell-code">{r.code}</code>
</div>
),
},
{
key: "latency",
header: t("portal.infrastructure.deployments.regionColumns.latency"),
align: "right",
render: (r) => (
<span className="portal-infra__mono">
{t("portal.infrastructure.deployments.msValue", {
value: r.latencyMs,
})}
</span>
),
},
{
key: "load",
header: t("portal.infrastructure.deployments.regionColumns.load"),
width: "9rem",
render: (r) => (
<div className="portal-infra__load">
<ProgressBar
value={r.load}
thresholded
height={6}
label={t("portal.infrastructure.deployments.loadAria", {
name: r.name,
})}
/>
<span className="portal-infra__load-pct">{pct(r.load)}</span>
</div>
),
},
{
key: "status",
header: t("portal.infrastructure.deployments.regionColumns.status"),
render: (r) => (
<StatusBadge tone={REGION_TONE[r.status]} size="sm">
{t(REGION_LABEL[r.status])}
</StatusBadge>
),
},
{
key: "version",
header: t("portal.infrastructure.deployments.regionColumns.version"),
render: (r) => (
<code className="portal-infra__cell-code">{r.version}</code>
),
},
{
key: "uptime",
header: t("portal.infrastructure.deployments.regionColumns.uptime"),
align: "right",
render: (r) => (
<span className="portal-infra__mono">{pct(r.uptime, 3)}</span>
),
},
{
key: "instances",
header: t("portal.infrastructure.deployments.regionColumns.instances"),
align: "right",
render: (r) => <span className="portal-infra__mono">{r.instances}</span>,
},
{
key: "throughput",
header: t("portal.infrastructure.deployments.regionColumns.throughput"),
align: "right",
render: (r) => (
<span className="portal-infra__mono">
{t("portal.infrastructure.deployments.throughputValue", {
value: r.throughput.toLocaleString(),
})}
</span>
),
},
{
key: "p99",
header: t("portal.infrastructure.deployments.regionColumns.p99"),
align: "right",
render: (r) => (
<span className="portal-infra__mono">
{t("portal.infrastructure.deployments.msValue", { value: r.p99Ms })}
</span>
),
},
];
const deployCols: TableColumn<RecentDeployment>[] = [
{
key: "version",
header: t("portal.infrastructure.deployments.deployColumns.version"),
render: (d) => (
<code className="portal-infra__cell-code">{d.version}</code>
),
},
{
key: "environment",
header: t("portal.infrastructure.deployments.deployColumns.environment"),
render: (d) => (
<Chip
accent={
d.environment === "production"
? "default"
: d.environment === "canary"
? "premium"
: "neutral"
}
size="sm"
>
{d.environment}
</Chip>
),
},
{
key: "product",
header: t("portal.infrastructure.deployments.deployColumns.product"),
render: (d) => d.product,
},
{
key: "status",
header: t("portal.infrastructure.deployments.deployColumns.status"),
render: (d) => (
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
{t(DEPLOY_LABEL[d.status])}
</StatusBadge>
),
},
{
key: "deployedBy",
header: t("portal.infrastructure.deployments.deployColumns.deployedBy"),
render: (d) => <span className="portal-infra__mono">{d.deployedBy}</span>,
},
{
key: "timestamp",
header: t("portal.infrastructure.deployments.deployColumns.when"),
align: "right",
render: (d) => <span className="portal-infra__muted">{d.timestamp}</span>,
},
];
return (
<div className="portal-infra__stack">
<section>
<SectionHeader
title={t("portal.infrastructure.deployments.regions.heading")}
sub={t("portal.infrastructure.deployments.regions.subheading")}
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={3} cols={9} />}
{isEmpty && (
<EmptyState
size="compact"
title={t("portal.infrastructure.deployments.regions.empty.title")}
description={t(
"portal.infrastructure.deployments.regions.empty.description",
)}
/>
)}
{!isEmpty && data && data.regions.length > 0 && (
<Table
columns={regionCols}
rows={data.regions}
rowKey={(r) => r.code}
/>
)}
</Card>
</section>
<section>
<SectionHeader
title={t("portal.infrastructure.deployments.recent.heading")}
sub={t("portal.infrastructure.deployments.recent.subheading")}
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={4} cols={6} />}
{data && data.recent.length > 0 && (
<Table
columns={deployCols}
rows={data.recent}
rowKey={(d) => d.id}
/>
)}
</Card>
</section>
</div>
);
}
@@ -1,30 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ModelsTab } from "@portal/components/infrastructure/ModelsTab";
import "@portal/views/Infrastructure.css";
// Data is served by the registered MSW infrastructure handler; the tier global
// (toolbar) drives which catalogue + routing slice each story renders.
const meta = {
title: "Infrastructure/ModelsTab",
component: ModelsTab,
parameters: { layout: "padded" },
} satisfies Meta<typeof ModelsTab>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Pro: full managed catalogue plus routing control. */
export const Pro: Story = {
globals: { tier: "pro" },
};
/** Free: two managed models, no routing (upgrade nudge). */
export const Free: Story = {
globals: { tier: "free" },
};
/** Enterprise: adds bring-your-own / on-prem models and per-region pinning. */
export const Enterprise: Story = {
globals: { tier: "enterprise" },
};
@@ -1,272 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Banner,
Card,
Chip,
EmptyState,
MetricCard,
MetricStrip,
ProgressBar,
Select,
StatusBadge,
Table,
type SelectOption,
type TableColumn,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchModels,
type ModelEntry,
type ModelsResponse,
type RoutingRule,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import {
MODEL_LABEL,
MODEL_PROVIDER_LABEL,
MODEL_TONE,
MODEL_TYPE_LABEL,
MODEL_TYPE_TONE,
modelCost,
pct,
} from "@portal/components/infrastructure/infraFormat";
export function ModelsTab() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<ModelsResponse>(() => fetchModels(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const modelCols: TableColumn<ModelEntry>[] = [
{
key: "name",
header: t("portal.infrastructure.models.columns.model"),
render: (m) => (
<div className="portal-infra__cell-stack">
<span className="portal-infra__cell-strong">{m.name}</span>
<Chip accent="neutral" size="sm">
{MODEL_PROVIDER_LABEL[m.provider]}
</Chip>
</div>
),
},
{
key: "type",
header: t("portal.infrastructure.models.columns.type"),
render: (m) => (
<Chip accent={MODEL_TYPE_TONE[m.type]} size="sm">
{t(MODEL_TYPE_LABEL[m.type])}
</Chip>
),
},
{
key: "status",
header: t("portal.infrastructure.models.columns.status"),
render: (m) => (
<StatusBadge tone={MODEL_TONE[m.status]} size="sm">
{t(MODEL_LABEL[m.status])}
</StatusBadge>
),
},
{
key: "load",
header: t("portal.infrastructure.models.columns.load"),
width: "9rem",
render: (m) => (
<div className="portal-infra__load">
<ProgressBar
value={m.load}
thresholded
height={6}
label={t("portal.infrastructure.models.loadAria", { name: m.name })}
/>
<span className="portal-infra__load-pct">{pct(m.load)}</span>
</div>
),
},
{
key: "latency",
header: t("portal.infrastructure.models.columns.latency"),
align: "right",
render: (m) => (
<span className="portal-infra__mono">
{t("portal.infrastructure.models.msValue", { value: m.latencyMs })}
</span>
),
},
{
key: "cost",
header: t("portal.infrastructure.models.columns.cost"),
align: "right",
render: (m) => (
<span className="portal-infra__mono">
{modelCost(t, m.cost, m.costUnit)}
</span>
),
},
{
key: "version",
header: t("portal.infrastructure.models.columns.version"),
render: (m) => (
<code className="portal-infra__cell-code">{m.version}</code>
),
},
];
// Free has no routing control: the catalogue is read-only and the routing
// table is replaced by an upgrade nudge.
const canRoute = tier !== "free";
// Routing overrides are interactive but unbacked — assigning a model just
// moves local UI state until the routing endpoint exists.
// TODO(backend): PUT /v1/infrastructure/models/routing { rules }
const modelOptions: SelectOption[] =
data?.models
.filter((m) => m.status !== "disabled")
.map((m) => ({ value: m.id, label: m.name })) ?? [];
const routingCols: TableColumn<RoutingRule>[] = [
{
key: "operation",
header: t("portal.infrastructure.models.routingColumns.operation"),
render: (r) => (
<div className="portal-infra__cell-stack">
<span className="portal-infra__cell-strong">{r.operation}</span>
{r.isDefault && (
<Chip accent="default" size="sm">
{t("portal.infrastructure.models.routingColumns.default")}
</Chip>
)}
</div>
),
},
{
key: "docType",
header: t("portal.infrastructure.models.routingColumns.docType"),
render: (r) => r.docType,
},
{
key: "modelId",
header: t("portal.infrastructure.models.routingColumns.routedTo"),
width: "16rem",
render: (r) => (
<Select
inputSize="sm"
options={modelOptions}
defaultValue={r.modelId}
aria-label={t(
"portal.infrastructure.models.routingColumns.modelForAria",
{
operation: r.operation,
},
)}
/>
),
},
];
return (
<div className="portal-infra__stack">
<SectionHeader
title={t("portal.infrastructure.models.heading")}
sub={t("portal.infrastructure.models.subheading")}
/>
{data && (
<MetricStrip layout="row">
<MetricCard
label={t("portal.infrastructure.models.metrics.activeModels")}
value={data.summary.activeModels}
/>
<MetricCard
label={t("portal.infrastructure.models.metrics.avgLatency")}
value={t("portal.infrastructure.models.msValue", {
value: data.summary.avgLatencyMs,
})}
/>
<MetricCard
label={t("portal.infrastructure.models.metrics.monthlySpend")}
value={
data.summary.monthlySpend > 0
? `$${data.summary.monthlySpend.toLocaleString()}`
: t("portal.infrastructure.models.metrics.included")
}
/>
</MetricStrip>
)}
<section>
<SectionHeader
title={t("portal.infrastructure.models.catalogue.heading")}
sub={
tier === "enterprise"
? t("portal.infrastructure.models.catalogue.subEnterprise")
: t("portal.infrastructure.models.catalogue.sub")
}
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={4} cols={7} />}
{isEmpty && (
<EmptyState
size="compact"
title={t("portal.infrastructure.models.catalogue.empty.title")}
description={t(
"portal.infrastructure.models.catalogue.empty.description",
)}
/>
)}
{!isEmpty && data && data.models.length > 0 && (
<Table
columns={modelCols}
rows={data.models}
rowKey={(m) => m.id}
/>
)}
</Card>
</section>
{tier === "enterprise" && (
<Banner
tone="info"
title={t("portal.infrastructure.models.byom.title")}
description={t("portal.infrastructure.models.byom.description")}
/>
)}
<section>
<SectionHeader
title={t("portal.infrastructure.models.routing.heading")}
sub={
canRoute
? t("portal.infrastructure.models.routing.sub")
: t("portal.infrastructure.models.routing.subLocked")
}
/>
{canRoute ? (
<Card padding="none">
{isLoading && <TableSkeleton rows={4} cols={3} />}
{!isEmpty && data && (
<Table
columns={routingCols}
rows={data.routing}
rowKey={(r) => r.id}
empty={t("portal.infrastructure.models.routing.empty")}
/>
)}
</Card>
) : (
<Banner
tone="info"
title={t("portal.infrastructure.models.routing.lockedBanner.title")}
description={t(
"portal.infrastructure.models.routing.lockedBanner.description",
)}
/>
)}
</section>
</div>
);
}
@@ -1,58 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { SecurityTab } from "@portal/components/infrastructure/SecurityTab";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof SecurityTab> = {
title: "Portal/Infrastructure/SecurityTab",
component: SecurityTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SecurityTab>;
export const Default: Story = {};
// Enterprise unlocks HYOK key custody (with a live rotate affordance) and the
// full attested compliance set, including PCI in-scope.
export const Enterprise: Story = {
globals: { tier: "enterprise" },
};
// Free runs on Stirling-managed keys (rotate disabled, upgrade nudge) and a
// trimmed attestation set with HIPAA/PCI not-applicable.
export const Free: Story = {
globals: { tier: "free" },
};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/security", async () => {
await delay("infinite");
return HttpResponse.json(null);
}),
],
},
},
};
export const Unavailable: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/security", () =>
HttpResponse.json(null, { status: 503 }),
),
],
},
},
};
@@ -1,342 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
Card,
Chip,
EmptyState,
RadioGroup,
Skeleton,
StatusBadge,
Table,
type RadioOption,
type TableColumn,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchSecurity,
type AccessPolicy,
type DataResidency,
type SecurityConfig,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import {
ATTESTATION_LABEL,
ATTESTATION_TONE,
CERT_LABEL,
CERT_TONE,
KEY_MODE_LABEL,
KEY_MODE_TONE,
} from "@portal/components/infrastructure/infraFormat";
export function SecurityTab() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<SecurityConfig>(() => fetchSecurity(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const ACCESS_OPTS: RadioOption<AccessPolicy>[] = [
{
value: "stirling",
label: t("portal.infrastructure.security.access.stirling.label"),
description: t(
"portal.infrastructure.security.access.stirling.description",
),
},
{
value: "byok",
label: t("portal.infrastructure.security.access.byok.label"),
description: t("portal.infrastructure.security.access.byok.description"),
},
{
value: "hyok",
label: t("portal.infrastructure.security.access.hyok.label"),
description: t("portal.infrastructure.security.access.hyok.description"),
},
];
const RESIDENCY_OPTS: RadioOption<DataResidency>[] = [
{
value: "us",
label: t("portal.infrastructure.security.residency.us.label"),
description: t("portal.infrastructure.security.residency.us.description"),
},
{
value: "eu",
label: t("portal.infrastructure.security.residency.eu.label"),
description: t("portal.infrastructure.security.residency.eu.description"),
},
{
value: "apac",
label: t("portal.infrastructure.security.residency.apac.label"),
description: t(
"portal.infrastructure.security.residency.apac.description",
),
},
];
const ipCols: TableColumn<SecurityConfig["ipAllowlist"][number]>[] = [
{
key: "label",
header: t("portal.infrastructure.security.ipColumns.label"),
render: (e) => e.label,
},
{
key: "cidr",
header: t("portal.infrastructure.security.ipColumns.cidr"),
render: (e) => <code className="portal-infra__cell-code">{e.cidr}</code>,
},
{
key: "addedBy",
header: t("portal.infrastructure.security.ipColumns.addedBy"),
render: (e) => <span className="portal-infra__mono">{e.addedBy}</span>,
},
{
key: "added",
header: t("portal.infrastructure.security.ipColumns.added"),
align: "right",
render: (e) => <span className="portal-infra__muted">{e.added}</span>,
},
];
// Local mirrors so the radios are interactive without a backend round-trip,
// seeded from the fetched config once it lands.
// TODO(backend): PATCH /v1/infrastructure/security { accessPolicy, dataResidency }
const [access, setAccess] = useState<AccessPolicy | null>(null);
const [residency, setResidency] = useState<DataResidency | null>(null);
const accessValue = access ?? data?.accessPolicy ?? "stirling";
const residencyValue = residency ?? data?.dataResidency ?? "us";
if (isLoading) {
return (
<div className="portal-infra__stack" aria-hidden>
<Skeleton height="11rem" />
<Skeleton height="7rem" />
</div>
);
}
if (isEmpty || !data) {
return (
<EmptyState
size="compact"
title={t("portal.infrastructure.security.empty.title")}
description={t("portal.infrastructure.security.empty.description")}
/>
);
}
return (
<div className="portal-infra__stack">
<section className="portal-infra__split">
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.security.accessPolicy.heading")}
sub={t("portal.infrastructure.security.accessPolicy.subheading")}
/>
<RadioGroup
name="access-policy"
value={accessValue}
onChange={setAccess}
options={ACCESS_OPTS}
/>
{accessValue === "hyok" && (
<Banner
tone="success"
className="portal-infra__banner"
title={t("portal.infrastructure.security.hyokBanner.title")}
description={t(
"portal.infrastructure.security.hyokBanner.description",
)}
/>
)}
</Card>
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.security.residencyHeader.heading")}
sub={t("portal.infrastructure.security.residencyHeader.subheading")}
/>
<RadioGroup
name="data-residency"
value={residencyValue}
onChange={setResidency}
options={RESIDENCY_OPTS}
/>
</Card>
</section>
<section>
<SectionHeader
title={t("portal.infrastructure.security.keyManagement.heading")}
sub={t("portal.infrastructure.security.keyManagement.subheading")}
/>
<Card padding="loose" className="portal-infra__keymgmt">
<div className="portal-infra__keymgmt-head">
<div className="portal-infra__keymgmt-title">
<span className="portal-infra__cell-strong">
{data.keyManagement.provider}
</span>
<StatusBadge
tone={KEY_MODE_TONE[data.keyManagement.mode]}
size="sm"
>
{KEY_MODE_LABEL[data.keyManagement.mode]}
</StatusBadge>
</div>
{/* Rotation is a privileged backend action; disabled where Stirling
holds the keys (managed tiers can't rotate customer keys). */}
<Button
variant="secondary"
size="sm"
disabled={!data.keyManagement.customerManaged}
onClick={() => {
// TODO(backend): POST /v1/infrastructure/security/keys/rotate
}}
>
{t("portal.infrastructure.security.keyManagement.rotateKey")}
</Button>
</div>
<dl className="portal-infra__kv">
<div className="portal-infra__kv-wide">
<dt>{t("portal.infrastructure.security.keyManagement.keyId")}</dt>
<dd>
<code className="portal-infra__cell-code">
{data.keyManagement.keyId}
</code>
</dd>
</div>
<div>
<dt>
{t("portal.infrastructure.security.keyManagement.algorithm")}
</dt>
<dd className="portal-infra__mono">
{data.keyManagement.algorithm}
</dd>
</div>
<div>
<dt>
{t("portal.infrastructure.security.keyManagement.lastRotated")}
</dt>
<dd>{data.keyManagement.lastRotated}</dd>
</div>
<div>
<dt>
{t(
"portal.infrastructure.security.keyManagement.rotationPolicy",
)}
</dt>
<dd>{data.keyManagement.rotationPolicy}</dd>
</div>
</dl>
{!data.keyManagement.customerManaged && (
<Banner
tone="info"
className="portal-infra__banner"
title={t("portal.infrastructure.security.managedBanner.title")}
description={t(
"portal.infrastructure.security.managedBanner.description",
)}
/>
)}
</Card>
</section>
<section>
<SectionHeader
title={t("portal.infrastructure.security.compliance.heading")}
sub={t("portal.infrastructure.security.compliance.subheading")}
/>
<div className="portal-infra__certs">
{data.certs.map((c) => (
<Card key={c.id} padding="default" className="portal-infra__cert">
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{c.name}</span>
<StatusBadge tone={CERT_TONE[c.status]} size="sm">
{t(CERT_LABEL[c.status])}
</StatusBadge>
</div>
<p className="portal-infra__cert-detail">{c.detail}</p>
</Card>
))}
</div>
</section>
<section>
<SectionHeader
title={t("portal.infrastructure.security.attestations.heading")}
sub={t("portal.infrastructure.security.attestations.subheading")}
/>
<div className="portal-infra__attestations">
{data.attestations.map((a) => (
<Card
key={a.id}
padding="default"
className="portal-infra__attestation"
>
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{a.name}</span>
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
{t(ATTESTATION_LABEL[a.status])}
</StatusBadge>
</div>
<Chip size="sm">{a.framework}</Chip>
<p className="portal-infra__cert-detail">{a.detail}</p>
{a.reportUrl ? (
<a
className="portal-infra__attestation-link"
href={a.reportUrl}
// TODO(backend): GET /v1/infrastructure/security/reports/:id
onClick={(e) => e.preventDefault()}
>
{t("portal.infrastructure.security.attestations.viewReport")}
</a>
) : (
<span className="portal-infra__muted">
{t("portal.infrastructure.security.attestations.noReport")}
</span>
)}
</Card>
))}
</div>
</section>
<section>
<SectionHeader
title={t("portal.infrastructure.security.ipAllowlist.heading")}
sub={
tier === "free"
? t("portal.infrastructure.security.ipAllowlist.subLocked")
: t("portal.infrastructure.security.ipAllowlist.sub")
}
/>
{tier === "free" ? (
<Banner
tone="info"
title={t(
"portal.infrastructure.security.ipAllowlist.lockedBanner.title",
)}
description={t(
"portal.infrastructure.security.ipAllowlist.lockedBanner.description",
)}
/>
) : (
<Card padding="none">
<Table
columns={ipCols}
rows={data.ipAllowlist}
rowKey={(e) => e.id}
empty={t("portal.infrastructure.security.ipAllowlist.empty")}
/>
</Card>
)}
</section>
</div>
);
}
@@ -1,80 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { StorageTab } from "@portal/components/infrastructure/StorageTab";
import type { StorageConfig } from "@portal/api/infrastructure";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof StorageTab> = {
title: "Portal/Infrastructure/StorageTab",
component: StorageTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof StorageTab>;
export const Default: Story = {};
const OVER_CAP: StorageConfig = {
usedGb: 1920,
quotaGb: 2000,
retention: "180",
providers: [
{
id: "stirling",
name: "Stirling Cloud",
kind: "stirling",
connected: true,
detail: "Primary vault · us-east-1",
usedGb: 1532,
},
{
id: "s3",
name: "Amazon S3",
kind: "s3",
connected: true,
detail: "s3://acme-prod-archive · WORM",
usedGb: 388,
},
{
id: "azure",
name: "Azure Blob",
kind: "azure",
connected: false,
detail: "Not connected",
usedGb: 0,
},
],
};
// Quota nearly exhausted — exercises the danger threshold on the usage bar.
export const OverThreshold: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/storage", () =>
HttpResponse.json(OVER_CAP),
),
],
},
},
};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/storage", async () => {
await delay("infinite");
return HttpResponse.json(null);
}),
],
},
},
};
@@ -1,244 +0,0 @@
import { useState, type ComponentType, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import ArrowForwardRounded from "@mui/icons-material/ArrowForwardRounded";
import CloudRounded from "@mui/icons-material/CloudRounded";
import StorageRounded from "@mui/icons-material/StorageRounded";
import {
Button,
Card,
EmptyState,
FormField,
ProgressBar,
Select,
Skeleton,
StatusBadge,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchStorage,
type RetentionWindow,
type StorageConfig,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { pct } from "@portal/components/infrastructure/infraFormat";
const PROVIDER_ICON: Record<
StorageConfig["providers"][number]["kind"],
ComponentType<{ style?: CSSProperties }>
> = {
stirling: StorageRounded,
s3: CloudRounded,
azure: CloudRounded,
};
/** Storage fills past this fraction of quota are surfaced in red. */
const USAGE_DANGER_FRAC = 0.8;
export function StorageTab() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<StorageConfig>(() => fetchStorage(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const RETENTION_OPTS = [
{
value: "30",
label: t("portal.infrastructure.storage.retentionOption.days", {
count: 30,
}),
},
{
value: "60",
label: t("portal.infrastructure.storage.retentionOption.days", {
count: 60,
}),
},
{
value: "90",
label: t("portal.infrastructure.storage.retentionOption.days", {
count: 90,
}),
},
{
value: "180",
label: t("portal.infrastructure.storage.retentionOption.days", {
count: 180,
}),
},
{
value: "never",
label: t("portal.infrastructure.storage.retentionOption.never"),
},
];
// TODO(backend): PATCH /v1/infrastructure/storage { retention }
const [retention, setRetention] = useState<RetentionWindow | null>(null);
const retentionValue = retention ?? data?.retention ?? "90";
if (isLoading) {
return (
<div className="portal-infra__stack" aria-hidden>
<Skeleton height="6rem" />
<Skeleton height="9rem" />
</div>
);
}
if (isEmpty || !data) {
return (
<EmptyState
size="compact"
title={t("portal.infrastructure.storage.empty.title")}
description={t("portal.infrastructure.storage.empty.description")}
/>
);
}
const usedFrac = data.quotaGb > 0 ? data.usedGb / data.quotaGb : 0;
const overThreshold = usedFrac > USAGE_DANGER_FRAC;
return (
<div className="portal-infra__stack">
<section>
<SectionHeader
title={t("portal.infrastructure.storage.totalUsage.heading")}
sub={t("portal.infrastructure.storage.totalUsage.subheading")}
/>
<Card padding="loose">
<div className="portal-infra__usage-head">
<span className="portal-infra__usage-value">
{t("portal.infrastructure.storage.gbValue", {
value: data.usedGb.toLocaleString(),
})}
<span className="portal-infra__muted">
{" "}
/{" "}
{t("portal.infrastructure.storage.gbValue", {
value: data.quotaGb.toLocaleString(),
})}
</span>
</span>
<StatusBadge tone={overThreshold ? "danger" : "success"} size="sm">
{t("portal.infrastructure.storage.percentUsed", {
value: pct(usedFrac),
})}
</StatusBadge>
</div>
<ProgressBar
value={usedFrac}
height={10}
color={
overThreshold
? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))"
: "linear-gradient(90deg, var(--color-green), color-mix(in srgb, var(--color-green) 70%, white))"
}
label={t("portal.infrastructure.storage.totalUsage.progressLabel")}
/>
</Card>
</section>
<section className="portal-infra__split">
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.storage.providers.heading")}
sub={t("portal.infrastructure.storage.providers.subheading")}
/>
<ul className="portal-infra__providers">
{data.providers.map((p) => {
const ProviderIcon = PROVIDER_ICON[p.kind];
return (
<li key={p.id} className="portal-infra__provider">
<span className="portal-infra__provider-glyph" aria-hidden>
<ProviderIcon style={{ fontSize: "1.2rem" }} />
</span>
<span className="portal-infra__provider-text">
<span className="portal-infra__cell-strong">{p.name}</span>
<span className="portal-infra__muted">{p.detail}</span>
</span>
{p.connected ? (
<span className="portal-infra__provider-meta">
<span className="portal-infra__mono">
{t("portal.infrastructure.storage.gbValue", {
value: p.usedGb,
})}
</span>
<StatusBadge tone="success" size="sm">
{t("portal.infrastructure.storage.providers.connected")}
</StatusBadge>
</span>
) : (
// TODO(backend): launch the provider OAuth/credential flow,
// then POST /v1/infrastructure/storage/providers/{id}/connect
<Button variant="secondary" size="sm">
{t("portal.infrastructure.storage.providers.connect")}
</Button>
)}
</li>
);
})}
</ul>
</Card>
<Card padding="loose">
<SectionHeader
title={t("portal.infrastructure.storage.retention.heading")}
sub={t("portal.infrastructure.storage.retention.subheading")}
/>
<FormField
label={t("portal.infrastructure.storage.retention.windowLabel")}
>
<Select
options={RETENTION_OPTS}
value={retentionValue}
onChange={(value) =>
setRetention((value ?? "") as RetentionWindow)
}
/>
</FormField>
<div className="portal-infra__lifecycle">
<div className="portal-infra__lifecycle-stage is-active">
<span className="portal-infra__lifecycle-dot" />
<span className="portal-infra__lifecycle-label">
{t("portal.infrastructure.storage.lifecycle.active")}
</span>
<span className="portal-infra__muted">
{t("portal.infrastructure.storage.lifecycle.activeRange", {
value: retentionValue === "never" ? "∞" : retentionValue,
})}
</span>
</div>
<span className="portal-infra__lifecycle-arrow" aria-hidden>
<ArrowForwardRounded style={{ fontSize: "1.2rem" }} />
</span>
<div className="portal-infra__lifecycle-stage">
<span className="portal-infra__lifecycle-dot" />
<span className="portal-infra__lifecycle-label">
{t("portal.infrastructure.storage.lifecycle.archived")}
</span>
<span className="portal-infra__muted">
{t("portal.infrastructure.storage.lifecycle.coldStorage")}
</span>
</div>
<span className="portal-infra__lifecycle-arrow" aria-hidden>
<ArrowForwardRounded style={{ fontSize: "1.2rem" }} />
</span>
<div className="portal-infra__lifecycle-stage">
<span className="portal-infra__lifecycle-dot" />
<span className="portal-infra__lifecycle-label">
{t("portal.infrastructure.storage.lifecycle.deleted")}
</span>
<span className="portal-infra__muted">
{retentionValue === "never"
? t("portal.infrastructure.storage.lifecycle.never")
: t("portal.infrastructure.storage.lifecycle.purged")}
</span>
</div>
</div>
</Card>
</section>
</div>
);
}
@@ -1,105 +1,21 @@
import type { TFunction } from "i18next";
import type { ChipAccent, StatusTone } from "@app/ui";
import type { StatusTone } from "@app/ui";
import type {
ApiKeyStatus,
AttestationStatus,
AuditCategory,
AuditStatus,
CertStatus,
DeploymentStatus,
KeyMode,
ModelCostUnit,
ModelProvider,
ModelStatus,
ModelType,
RegionStatus,
} from "@portal/api/infrastructure";
/** Format a 01 fraction as a percentage string. */
export function pct(n: number, digits = 0): string {
return `${(n * 100).toFixed(digits)}%`;
}
/** Capitalise the first letter of a lower-case status word. */
export function titleCase(word: string): string {
return word[0].toUpperCase() + word.slice(1);
}
export const REGION_TONE: Record<RegionStatus, StatusTone> = {
healthy: "success",
degraded: "warning",
down: "danger",
};
// Label maps hold i18n keys, resolved via t(MAP[value]) at the render sites.
export const REGION_LABEL: Record<RegionStatus, string> = {
healthy: "portal.infrastructure.regionLabel.healthy",
degraded: "portal.infrastructure.regionLabel.degraded",
down: "portal.infrastructure.regionLabel.down",
};
export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
live: "success",
rolling: "info",
"rolled-back": "warning",
queued: "neutral",
};
export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
live: "portal.infrastructure.deployLabel.live",
rolling: "portal.infrastructure.deployLabel.rolling",
"rolled-back": "portal.infrastructure.deployLabel.rolledBack",
queued: "portal.infrastructure.deployLabel.queued",
};
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
active: "success",
revoked: "danger",
};
// Label maps hold i18n keys, resolved via t(MAP[value]) at the render sites.
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "portal.infrastructure.keyLabel.active",
revoked: "portal.infrastructure.keyLabel.revoked",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
certified: "success",
"in-progress": "warning",
"not-started": "neutral",
};
export const CERT_LABEL: Record<CertStatus, string> = {
certified: "portal.infrastructure.certLabel.certified",
"in-progress": "portal.infrastructure.certLabel.inProgress",
"not-started": "portal.infrastructure.certLabel.notStarted",
};
// Brand/acronym key-management modes are not localised.
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
managed: "Stirling-managed",
byok: "BYOK",
hyok: "HYOK",
};
export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
managed: "info",
byok: "purple",
// HYOK is the strongest posture (Stirling never sees plaintext) → success.
hyok: "success",
};
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
attested: "portal.infrastructure.attestationLabel.attested",
"in-scope": "portal.infrastructure.attestationLabel.inScope",
"not-applicable": "portal.infrastructure.attestationLabel.notApplicable",
};
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
attested: "success",
"in-scope": "warning",
"not-applicable": "neutral",
};
export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
success: "success",
warning: "warning",
@@ -132,50 +48,3 @@ export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
processing: "success",
security: "warning",
};
export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
active: "success",
degraded: "warning",
disabled: "neutral",
};
export const MODEL_LABEL: Record<ModelStatus, string> = {
active: "portal.infrastructure.modelLabel.active",
degraded: "portal.infrastructure.modelLabel.degraded",
disabled: "portal.infrastructure.modelLabel.disabled",
};
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
extraction: "portal.infrastructure.modelTypeLabel.extraction",
classification: "portal.infrastructure.modelTypeLabel.classification",
ocr: "portal.infrastructure.modelTypeLabel.ocr",
llm: "portal.infrastructure.modelTypeLabel.llm",
};
export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
extraction: "default",
classification: "premium",
ocr: "success",
llm: "warning",
};
// Provider names are proper nouns, not localised.
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
stirling: "Stirling",
openai: "OpenAI",
anthropic: "Anthropic",
"on-prem": "On-prem",
};
/** Render a model's cost with the unit it's billed against. */
export function modelCost(
t: TFunction,
cost: number,
unit: ModelCostUnit,
): string {
if (cost === 0) return t("portal.infrastructure.models.metrics.included");
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
return unit === "per-call"
? t("portal.infrastructure.models.cost.perCall", { price })
: t("portal.infrastructure.models.cost.perThousand", { price });
}
@@ -7,9 +7,6 @@
flex-direction: column;
gap: 0.875rem;
padding: 1.125rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
/* The builder caps its columns so the page itself does not scroll, which means a settings form
taller than the viewport has to scroll in here - otherwise its lower half is unreachable. */
max-height: 100%;
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
import { Banner, EmptyState } from "@app/ui";
import "@portal/theme/surface.css";
import "@portal/components/pipelines/PipelineInspector.css";
export interface PipelineInspectorProps {
@@ -40,7 +41,7 @@ export function PipelineInspector({
if (message) {
return (
<aside className="portal-inspector">
<aside className="portal-surface portal-inspector">
<EmptyState
size="compact"
title={message}
@@ -52,7 +53,7 @@ export function PipelineInspector({
if (!children) {
return (
<aside className="portal-inspector">
<aside className="portal-surface portal-inspector">
<EmptyState
size="compact"
title={t("portal.pipelines.inspector.noSelectionTitle")}
@@ -63,7 +64,7 @@ export function PipelineInspector({
}
return (
<aside className="portal-inspector">
<aside className="portal-surface portal-inspector">
{/* The title stays put while the settings scroll: it names the node being edited, which is
what makes a long form legible. */}
<div className="portal-inspector__title">
@@ -19,7 +19,7 @@
the cards in light. */
background: var(--c-surface-sunken);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
border-radius: var(--radius-nav);
}
/* Sits beside the wire it is describing. Absolute, so appearing mid-drag moves nothing. */
@@ -10,6 +10,8 @@ import {
type ConnectionCategory,
} from "@portal/components/sources/connectionTypes";
import { BrandMark } from "@portal/components/BrandMarks";
import "@portal/theme/surface.css";
import "@portal/components/sources/connections.css";
import {
STEP_OPERATIONS,
buildStepParameters,
@@ -258,7 +260,7 @@ function OperationGrid({
key={op.id}
type="button"
className={
"portal-conn-picker__card" +
"portal-surface portal-conn-picker__card" +
(op.custom ? " portal-conn-picker__card--advanced" : "")
}
onClick={() => onPick(op)}
@@ -15,6 +15,7 @@ import {
} from "@portal/components/icons";
import { warmCalendly } from "@portal/components/procurement/CalendlyInline";
import { openApiUrl } from "@portal/api/externalUrl";
import "@portal/theme/surface.css";
import "@portal/views/Procurement.css";
/** What each stage asks of the buyer, read out in the stage sentence. */
@@ -114,7 +115,7 @@ export function DealStatusHero({
const nextStage = FLOW_JOURNEY[currentIdx + 1];
return (
<div className="portal-hero">
<div className="portal-surface portal-hero">
<div className="portal-hero__top">
<div className="portal-hero__ident">
<span className="portal-hero__eyebrow">
@@ -12,6 +12,7 @@ import {
import { DownloadIcon } from "@portal/components/icons";
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
import { useAsync } from "@portal/hooks/useAsync";
import "@portal/theme/surface.css";
import "@portal/views/Procurement.css";
/**
@@ -154,7 +155,7 @@ export function ProcurementAgreement({
role="group"
aria-label={t("portal.procurement.agreement.docName")}
>
<div className="portal-agreement__doc">
<div className="portal-surface portal-agreement__doc">
{loading && <p>{t("portal.procurement.agreement.loading")}</p>}
{!loading && !doc && (
<p>{t("portal.procurement.agreement.loadError")}</p>
@@ -16,6 +16,7 @@ import {
} from "@portal/api/procurement";
import { LegalDocumentModal } from "@portal/components/procurement/ProcurementExtras";
import { StepModalHeader } from "@portal/components/shared/StepModalHeader";
import "@portal/theme/surface.css";
import "@portal/views/Procurement.css";
const STEPS = ["volume", "plan", "details", "review"] as const;
@@ -526,7 +527,7 @@ export function QuoteBuilder({
PDF and the Stripe quote exactly. */}
{step === REVIEW_STEP && issued && (
<div className="portal-qb__papertray">
<div className="portal-qb__paper">
<div className="portal-surface portal-qb__paper">
<div className="portal-qb__paper-head">
<div>
<div className="portal-qb__paper-brand">Stirling PDF</div>
@@ -5,6 +5,7 @@ import {
type ConnectionFieldDef,
type CreatableConnectionType,
} from "@portal/components/sources/connectionTypes";
import "@portal/components/sources/connections.css";
/**
* Renders whatever fields a connection type declares, rather than knowing any vendor's shape.
@@ -11,6 +11,7 @@ import {
} from "@portal/api/integrations";
import { ConnectionTypePicker } from "@portal/components/sources/ConnectionTypePicker";
import { ConnectionForm } from "@portal/components/sources/ConnectionForm";
import "@portal/components/sources/connections.css";
import {
CREATABLE_CONNECTION_TYPES,
buildConnectionConfig,
@@ -9,6 +9,7 @@ import {
} from "@portal/api/integrations";
import { ConnectionModal } from "@portal/components/sources/ConnectionModal";
import { connectionTypeOf } from "@portal/components/sources/connectionTypes";
import "@portal/components/sources/connections.css";
/**
* Selects a stored connection of one type by id — an S3 bucket for a source, a Purview tenant for a
@@ -8,6 +8,8 @@ import {
type CreatableConnectionType,
} from "@portal/components/sources/connectionTypes";
import { BrandMark } from "@portal/components/BrandMarks";
import "@portal/theme/surface.css";
import "@portal/components/sources/connections.css";
/**
* Choose what to connect to.
@@ -112,7 +114,7 @@ function Grid({
key={type.id}
type="button"
className={
"portal-conn-picker__card" +
"portal-surface portal-conn-picker__card" +
(type.kind === "custom"
? " portal-conn-picker__card--advanced"
: "")
@@ -57,3 +57,22 @@ export default meta;
type Story = StoryObj<typeof SourcesTable>;
export const Default: Story = {};
export const OnlyEditorSource: Story = {
args: {
sources: [
{
id: "src-editor",
name: "Editor",
type: "editor",
status: "active",
referenceCount: 1,
referencingPolicies: [],
config: [],
docsTotal: 0,
docs24h: 0,
docs30d: 0,
},
],
},
};
@@ -0,0 +1,241 @@
/* Connect flow, shared by the Sources view, the Integrations view and the policy external-API step. */
.portal-sources__connection-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.portal-sources__connection-picker .sui-select,
.portal-sources__connection-picker > div:first-child {
align-self: stretch;
}
.portal-sources__connection-create-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.portal-sources__connection-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Back to the grid from the form. Sits above the fields, where the type dropdown used to be. */
.portal-sources__connection-back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 1rem;
padding: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
color: var(--c-text-muted);
}
.portal-sources__connection-back:hover {
color: var(--c-text);
}
/* The "request an integration" / "advanced" affordances, set below the form so presets read as
the primary path and these as the way out of it. */
.portal-sources__connection-secondary {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--c-border-subtle);
}
.portal-sources__connection-link {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
color: var(--c-conn-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.portal-sources__connection-link:hover {
opacity: 0.8;
}
/* Free-text fields (result-host list, body template) that outgrow a single-line input. */
.portal-sources__connection-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 0.375rem);
background: var(--c-input-bg);
color: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8125rem;
line-height: 1.5;
resize: vertical;
}
.portal-sources__connection-textarea:focus-visible {
outline: 2px solid var(--c-conn-accent);
outline-offset: -1px;
}
/* ---- Integration picker ---------------------------------------------------------------------
A searchable grid of what we can connect to. Sized so a full category is visible without
scrolling on a laptop, and so the search box is the first thing focus lands on. */
.portal-conn-picker {
display: flex;
flex-direction: column;
gap: 1.25rem;
/* Bounded rather than free-growing: the modal should not become the page. */
max-height: min(60vh, 32rem);
overflow-y: auto;
}
.portal-conn-picker__search {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 0.375rem);
/* Real theme token with a transparent fallback so it never falls back to opaque white in dark;
the border alone is enough to read as an input. */
background: var(--color-bg-subtle, transparent);
}
.portal-conn-picker__search-icon {
flex: none;
font-size: 1.125rem;
color: var(--c-text-muted);
}
.portal-conn-picker__search input {
flex: 1;
min-width: 0;
border: none;
background: none;
outline: none;
font: inherit;
color: inherit;
}
.portal-conn-picker__section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.portal-conn-picker__section-title {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-text-muted);
}
.portal-conn-picker__section-desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--c-text-muted);
}
.portal-conn-picker__grid {
display: grid;
/* auto-fill so the grid reflows to the modal rather than to a fixed column count. */
grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
gap: 0.5rem;
}
.portal-conn-picker__card {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
}
.portal-conn-picker__card:hover {
border-color: var(--c-conn-accent);
background: color-mix(in srgb, var(--c-conn-neutral) 6%, transparent);
}
/* The custom-API card is an escape hatch, not a vendor: dashed, so it reads as "something else". */
.portal-conn-picker__card--advanced {
border-style: dashed;
}
/* The vendor's real mark: full colour, no tinted plate behind it. Neutral
(currentColor) marks pick up the muted text tone instead. */
.portal-conn-picker__mark {
flex: none;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
color: var(--c-text-subtle);
}
.portal-conn-picker__mark svg {
display: block;
}
.portal-conn-picker__card-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.portal-conn-picker__card-name {
font-size: 0.875rem;
font-weight: 500;
}
.portal-conn-picker__card-desc {
font-size: 0.75rem;
line-height: 1.35;
color: var(--c-text-muted);
/* Two lines keeps every card the same height without truncating most descriptions. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.portal-conn-picker__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
text-align: center;
}
.portal-conn-picker__empty-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.portal-conn-picker__empty-body {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--c-text-muted);
}
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Avatar, Button } from "@app/ui";
import type { PendingInvitation } from "@portal/api/users";
import "@portal/theme/surface.css";
import "@portal/views/Users.css";
interface PendingInvitationsProps {
@@ -34,7 +35,7 @@ export function PendingInvitations({
}: PendingInvitationsProps) {
const { t } = useTranslation();
return (
<section className="portal-users__group">
<section className="portal-surface portal-users__group">
<header className="portal-users__group-head">
<div className="portal-users__group-title">
<strong>{t("users.invites.title", "Pending invitations")}</strong>
@@ -11,6 +11,7 @@ import {
buildDirectory,
type TeamGroup,
} from "@portal/components/users/directory";
import "@portal/theme/surface.css";
import "@portal/views/Users.css";
/** Collapse a group's rows past this many, behind a "Show all" expander. */
@@ -350,7 +351,7 @@ export function UsersDirectory({
<div className="portal-users__directory">
{/* Organization (a single-org deployment only; SaaS has no org). */}
{capabilities.orgGroup && dir.organization.length > 0 && (
<section className="portal-users__group">
<section className="portal-surface portal-users__group">
<header className="portal-users__group-head">
<div className="portal-users__group-title">
<strong>{t("users.group.org", "Organization")}</strong>
@@ -373,7 +374,7 @@ export function UsersDirectory({
{/* Teams */}
{dir.teams.map((team) => (
<section className="portal-users__group" key={team.id}>
<section className="portal-surface portal-users__group" key={team.id}>
<header className="portal-users__group-head">
<div className="portal-users__group-title">
<strong>
@@ -461,7 +462,7 @@ export function UsersDirectory({
{/* Guests (parked in the live app; shown when showGuests is set). */}
{showGuests && dir.guests.length > 0 && (
<section className="portal-users__group">
<section className="portal-surface portal-users__group">
<header className="portal-users__group-head">
<div className="portal-users__group-title">
<strong>{t("users.group.guests", "Guests")}</strong>
@@ -1,14 +1,6 @@
import { http, HttpResponse, delay } from "msw";
import type { Tier } from "@portal/contexts/TierContext";
import {
apiKeysFor,
auditLogFor,
modelsResponseFor,
recentDeploymentsFor,
regionsFor,
securityFor,
storageFor,
} from "@portal/mocks/infrastructure";
import { apiKeysFor, auditLogFor } from "@portal/mocks/infrastructure";
function tierFrom(request: Request): Tier {
const url = new URL(request.url);
@@ -16,15 +8,6 @@ function tierFrom(request: Request): Tier {
}
export const infrastructureHandlers = [
http.get("/v1/infrastructure/deployments", async ({ request }) => {
await delay(120);
const tier = tierFrom(request);
return HttpResponse.json({
regions: regionsFor(tier),
recent: recentDeploymentsFor(tier),
});
}),
// Real backend route; wildcard prefix intercepts both local (same-origin) and
// SaaS (absolute) callers.
http.get(
@@ -68,21 +51,6 @@ export const infrastructureHandlers = [
},
),
http.get("/v1/infrastructure/security", async ({ request }) => {
await delay(120);
return HttpResponse.json(securityFor(tierFrom(request)));
}),
http.get("/v1/infrastructure/models", async ({ request }) => {
await delay(120);
return HttpResponse.json(modelsResponseFor(tierFrom(request)));
}),
http.get("/v1/infrastructure/storage", async ({ request }) => {
await delay(120);
return HttpResponse.json(storageFor(tierFrom(request)));
}),
// Mirrors the real backend route. Wildcard prefix so it intercepts both the
// self-hosted apiClient.local call (same-origin) and the SaaS apiClient.saas
// call (absolute VITE_SAAS_API_URL).
@@ -4,9 +4,7 @@
* mocks/handlers/ serve the fixture data over the intercepted apiClient.local.json() calls.
* Components never reach into this module directly.
*
* Everything here is tier-scaled deterministically: free sees a single region
* and a trimmed slice of every list, pro adds a second region and the IP
* allowlist, enterprise unlocks the full topology + compliance posture.
* Everything here is tier-scaled deterministically: free sees the least of every list, enterprise the lot.
*
* Once a real backend exists the MSW handlers stop being registered and these
* fixtures can be deleted (or kept as test seeds).
@@ -19,146 +17,8 @@ import type {
AuditEvent,
AuditLogResponse,
AuditSummary,
ComplianceAttestation,
ComplianceCert,
DeploymentRegion,
IpAllowEntry,
KeyManagement,
ModelEntry,
ModelsResponse,
RecentDeployment,
RoutingRule,
SecurityConfig,
StorageConfig,
StorageProvider,
} from "@portal/api/infrastructure";
/* ──────────────────────────────────────────────────────────────────────── */
/* Deployments */
/* ──────────────────────────────────────────────────────────────────────── */
const REGION_US_EAST: DeploymentRegion = {
name: "US East (N. Virginia)",
code: "us-east-1",
latencyMs: 41,
load: 0.62,
status: "healthy",
version: "v3.4.2",
uptime: 0.99987,
instances: 18,
throughput: 2140,
p99Ms: 287,
};
const REGION_US_WEST: DeploymentRegion = {
name: "US West (Oregon)",
code: "us-west-2",
latencyMs: 53,
load: 0.74,
status: "healthy",
version: "v3.4.2",
uptime: 0.99971,
instances: 12,
throughput: 1480,
p99Ms: 331,
};
const REGION_EU_WEST: DeploymentRegion = {
name: "EU West (Ireland)",
code: "eu-west-1",
latencyMs: 68,
load: 0.83,
status: "degraded",
version: "v3.4.1",
uptime: 0.99924,
instances: 9,
throughput: 1120,
p99Ms: 521,
};
// A region mid-incident: zero healthy instances, traffic drained away.
const REGION_AP_SOUTHEAST: DeploymentRegion = {
name: "Asia Pacific (Singapore)",
code: "ap-southeast-1",
latencyMs: 0,
load: 0,
status: "down",
version: "v3.4.1",
uptime: 0.98612,
instances: 0,
throughput: 0,
p99Ms: 0,
};
export function regionsFor(tier: Tier): DeploymentRegion[] {
if (tier === "free") return [REGION_US_EAST];
if (tier === "pro") return [REGION_US_EAST, REGION_US_WEST];
return [REGION_US_EAST, REGION_US_WEST, REGION_EU_WEST, REGION_AP_SOUTHEAST];
}
const RECENT_DEPLOYMENTS_ALL: RecentDeployment[] = [
{
id: "dep-1",
version: "v3.4.2",
environment: "production",
product: "Extraction engine",
status: "live",
deployedBy: "ci-bot",
timestamp: "8m ago",
},
{
id: "dep-2",
version: "v3.4.2-rc4",
environment: "canary",
product: "Redaction service",
status: "rolling",
deployedBy: "maria.chen",
timestamp: "34m ago",
},
{
id: "dep-3",
version: "v2.9.0",
environment: "production",
product: "API gateway",
status: "live",
deployedBy: "ci-bot",
timestamp: "2h ago",
},
{
id: "dep-4",
version: "v3.4.1",
environment: "staging",
product: "OCR worker pool",
status: "queued",
deployedBy: "devon.park",
timestamp: "3h ago",
},
{
id: "dep-5",
version: "v3.3.9",
environment: "production",
product: "Extraction engine",
status: "rolled-back",
deployedBy: "maria.chen",
timestamp: "yesterday",
},
{
id: "dep-6",
version: "v3.3.8",
environment: "production",
product: "Webhook dispatcher",
status: "live",
deployedBy: "ci-bot",
timestamp: "2d ago",
},
];
export function recentDeploymentsFor(tier: Tier): RecentDeployment[] {
if (tier === "free") return RECENT_DEPLOYMENTS_ALL.slice(0, 2);
if (tier === "pro") return RECENT_DEPLOYMENTS_ALL.slice(0, 4);
return RECENT_DEPLOYMENTS_ALL;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* API Keys */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -220,288 +80,6 @@ export function apiKeysFor(tier: Tier): ApiKeysResponse {
return { keys };
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Security */
/* ──────────────────────────────────────────────────────────────────────── */
const CERTS_FULL: ComplianceCert[] = [
{
id: "soc2",
name: "SOC 2 Type II",
status: "certified",
detail: "Audited Apr 2026 · Coalfire",
},
{
id: "iso",
name: "ISO 27001",
status: "certified",
detail: "Cert #IS-774201 · valid to 2027",
},
{
id: "hipaa",
name: "HIPAA",
status: "certified",
detail: "BAA available · PHI-eligible",
},
{
id: "gdpr",
name: "GDPR",
status: "certified",
detail: "EU SCCs · DPA on file",
},
];
const CERTS_FREE: ComplianceCert[] = [
{
id: "soc2",
name: "SOC 2 Type II",
status: "certified",
detail: "Inherited — Stirling platform",
},
{
id: "iso",
name: "ISO 27001",
status: "certified",
detail: "Inherited — Stirling platform",
},
{
id: "hipaa",
name: "HIPAA",
status: "not-started",
detail: "Requires a paid plan + BAA",
},
{
id: "gdpr",
name: "GDPR",
status: "in-progress",
detail: "Standard EU processing terms",
},
];
const IP_ALLOWLIST: IpAllowEntry[] = [
{
id: "ip-1",
label: "Corp VPN egress",
cidr: "52.14.0.0/16",
addedBy: "maria.chen",
added: "Feb 11, 2026",
},
{
id: "ip-2",
label: "Data centre — IAD",
cidr: "18.221.0.0/16",
addedBy: "devon.park",
added: "Jan 4, 2026",
},
{
id: "ip-3",
label: "On-call jump host",
cidr: "203.0.113.7/32",
addedBy: "maria.chen",
added: "Dec 19, 2025",
},
];
// Stirling-managed key custody — what free/pro tiers run on. No key ops on the
// customer side, so the provider/key fields describe Stirling's own KMS.
const KEY_MANAGED: KeyManagement = {
mode: "managed",
provider: "Stirling KMS",
keyId: "arn:stirling:kms:us-east-1:platform/cmk-default",
algorithm: "AES-256-GCM",
lastRotated: "32 days ago",
rotationPolicy: "Automatic · every 90 days",
customerManaged: false,
};
const KEY_HYOK: KeyManagement = {
mode: "hyok",
provider: "AWS CloudHSM (customer)",
keyId: "arn:aws:kms:eu-west-1:418xxxx:key/2a7e-hyok",
algorithm: "AES-256-GCM · external key store",
lastRotated: "5 days ago",
rotationPolicy: "Customer-controlled · keys never leave your HSM",
customerManaged: true,
};
// Full attestation set an enterprise contract is covered by.
const ATTESTATIONS_FULL: ComplianceAttestation[] = [
{
id: "soc2",
name: "SOC 2 Type II",
framework: "AICPA Trust Services",
status: "attested",
detail: "Type II · audited Apr 2026 · Coalfire",
reportUrl: "/v1/infrastructure/security/reports/soc2",
},
{
id: "iso27001",
name: "ISO 27001",
framework: "ISO/IEC 27001:2022",
status: "attested",
detail: "Cert #IS-774201 · valid to 2027",
reportUrl: "/v1/infrastructure/security/reports/iso27001",
},
{
id: "hipaa",
name: "HIPAA",
framework: "US healthcare · PHI",
status: "attested",
detail: "BAA signed · PHI-eligible workloads",
reportUrl: "/v1/infrastructure/security/reports/hipaa",
},
{
id: "gdpr",
name: "GDPR",
framework: "EU data protection",
status: "attested",
detail: "EU SCCs · DPA on file · EU residency",
reportUrl: "/v1/infrastructure/security/reports/gdpr",
},
{
id: "pci",
name: "PCI DSS",
framework: "Payment card data",
status: "in-scope",
detail: "SAQ-D in progress · QSA assessment Q3",
reportUrl: null,
},
];
// What lower tiers can claim: the platform-inherited attestations only. HIPAA
// and PCI require a paid contract + signed paperwork, so they read as
// not-applicable until the customer upgrades.
const ATTESTATIONS_FREE: ComplianceAttestation[] = [
{
id: "soc2",
name: "SOC 2 Type II",
framework: "AICPA Trust Services",
status: "attested",
detail: "Inherited — Stirling platform",
reportUrl: "/v1/infrastructure/security/reports/soc2",
},
{
id: "iso27001",
name: "ISO 27001",
framework: "ISO/IEC 27001:2022",
status: "attested",
detail: "Inherited — Stirling platform",
reportUrl: "/v1/infrastructure/security/reports/iso27001",
},
{
id: "gdpr",
name: "GDPR",
framework: "EU data protection",
status: "in-scope",
detail: "Standard EU processing terms",
reportUrl: null,
},
{
id: "hipaa",
name: "HIPAA",
framework: "US healthcare · PHI",
status: "not-applicable",
detail: "Requires a paid plan + signed BAA",
reportUrl: null,
},
{
id: "pci",
name: "PCI DSS",
framework: "Payment card data",
status: "not-applicable",
detail: "Requires an enterprise contract",
reportUrl: null,
},
];
export function securityFor(tier: Tier): SecurityConfig {
if (tier === "free") {
return {
accessPolicy: "stirling",
dataResidency: "us",
certs: CERTS_FREE,
ipAllowlist: [],
keyManagement: KEY_MANAGED,
attestations: ATTESTATIONS_FREE,
};
}
if (tier === "pro") {
return {
accessPolicy: "byok",
dataResidency: "us",
certs: CERTS_FULL,
ipAllowlist: IP_ALLOWLIST.slice(0, 2),
// Pro stays on Stirling-managed keys; BYOK/HYOK is an enterprise lever.
keyManagement: KEY_MANAGED,
attestations: ATTESTATIONS_FREE,
};
}
return {
accessPolicy: "hyok",
dataResidency: "eu",
certs: CERTS_FULL,
ipAllowlist: IP_ALLOWLIST,
keyManagement: KEY_HYOK,
attestations: ATTESTATIONS_FULL,
};
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Storage */
/* ──────────────────────────────────────────────────────────────────────── */
const PROVIDERS_FULL: StorageProvider[] = [
{
id: "stirling",
name: "Stirling Cloud",
kind: "stirling",
connected: true,
detail: "Primary vault · us-east-1",
usedGb: 612,
},
{
id: "s3",
name: "Amazon S3",
kind: "s3",
connected: true,
detail: "s3://acme-prod-archive · WORM",
usedGb: 388,
},
{
id: "azure",
name: "Azure Blob",
kind: "azure",
connected: false,
detail: "Not connected",
usedGb: 0,
},
];
export function storageFor(tier: Tier): StorageConfig {
if (tier === "free") {
return {
usedGb: 4.2,
quotaGb: 5,
retention: "30",
providers: [PROVIDERS_FULL[0]],
};
}
if (tier === "pro") {
return {
usedGb: 318,
quotaGb: 500,
retention: "90",
providers: [PROVIDERS_FULL[0], PROVIDERS_FULL[1]],
};
}
return {
usedGb: 1000,
quotaGb: 2000,
retention: "180",
providers: PROVIDERS_FULL,
};
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Audit Logs */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -693,199 +271,6 @@ const AUDIT_EVENTS_ALL: AuditEvent[] = [
},
];
/* ──────────────────────────────────────────────────────────────────────── */
/* Models */
/* ──────────────────────────────────────────────────────────────────────── */
const MODELS_ALL: ModelEntry[] = [
{
id: "m-extract-v3",
name: "Stirling Extract",
provider: "stirling",
type: "extraction",
status: "active",
latencyMs: 142,
cost: 0.9,
costUnit: "per-1k-docs",
version: "v3.4.2",
load: 0.71,
managed: true,
},
{
id: "m-classify-v2",
name: "Stirling Classify",
provider: "stirling",
type: "classification",
status: "active",
latencyMs: 61,
cost: 0.4,
costUnit: "per-1k-docs",
version: "v2.8.0",
load: 0.48,
managed: true,
},
{
id: "m-ocr-tess",
name: "Stirling OCR",
provider: "stirling",
type: "ocr",
status: "active",
latencyMs: 318,
cost: 1.2,
costUnit: "per-1k-docs",
version: "v3.1.0",
load: 0.55,
managed: true,
},
{
id: "m-gpt4o",
name: "GPT-4o",
provider: "openai",
type: "llm",
status: "active",
latencyMs: 880,
cost: 0.012,
costUnit: "per-call",
version: "2026-05",
load: 0.33,
managed: true,
},
{
id: "m-claude-sonnet",
name: "Claude Sonnet 4.5",
provider: "anthropic",
type: "llm",
status: "degraded",
latencyMs: 1240,
cost: 0.009,
costUnit: "per-call",
version: "2026-04",
load: 0.21,
managed: true,
},
{
id: "m-onprem-ocr",
name: "On-prem OCR (Tesseract)",
provider: "on-prem",
type: "ocr",
status: "active",
latencyMs: 502,
cost: 0,
costUnit: "per-1k-docs",
version: "byo-1.2",
load: 0.12,
managed: false,
},
// Registered but parked: a customer LLM weight kept warm without traffic.
{
id: "m-onprem-llm",
name: "On-prem Llama 3 70B",
provider: "on-prem",
type: "llm",
status: "disabled",
latencyMs: 0,
cost: 0,
costUnit: "per-call",
version: "byo-0.9",
load: 0,
managed: false,
},
];
export function modelsFor(tier: Tier): ModelEntry[] {
// Free sees only the two managed Stirling models it can actually use.
if (tier === "free")
return MODELS_ALL.filter(
(m) => m.id === "m-extract-v3" || m.id === "m-classify-v2",
);
// Pro gets the full managed catalogue but no bring-your-own / on-prem models.
if (tier === "pro") return MODELS_ALL.filter((m) => m.managed);
return MODELS_ALL;
}
const ROUTING_ALL: RoutingRule[] = [
{
id: "r-extract",
operation: "Field extraction",
docType: "Invoices",
modelId: "m-extract-v3",
modelName: "Stirling Extract",
isDefault: false,
},
{
id: "r-classify",
operation: "Document classification",
docType: "All document types",
modelId: "m-classify-v2",
modelName: "Stirling Classify",
isDefault: false,
},
{
id: "r-ocr",
operation: "Text recognition",
docType: "Scanned PDFs",
modelId: "m-ocr-tess",
modelName: "Stirling OCR",
isDefault: false,
},
{
id: "r-summarize",
operation: "Summarisation",
docType: "Contracts",
modelId: "m-gpt4o",
modelName: "GPT-4o",
isDefault: false,
},
// Catch-all: any operation without a narrower rule falls back here.
{
id: "r-default",
operation: "Default",
docType: "All document types",
modelId: "m-extract-v3",
modelName: "Stirling Extract",
isDefault: true,
},
];
export function routingFor(tier: Tier): RoutingRule[] {
// Free has no routing control — the table is locked behind an upgrade nudge,
// so there are no rules to surface.
if (tier === "free") return [];
if (tier === "pro") return ROUTING_ALL.filter((r) => r.modelId !== "m-gpt4o");
return ROUTING_ALL;
}
export function modelsResponseFor(tier: Tier): ModelsResponse {
const models = modelsFor(tier);
const active = models.filter((m) => m.status === "active");
// Average latency is capacity-weighted so a barely-used model doesn't skew
// the headline; falls back to a plain mean when nothing is taking load.
const totalLoad = active.reduce((sum, m) => sum + m.load, 0);
const avgLatencyMs =
totalLoad > 0
? Math.round(
active.reduce((sum, m) => sum + m.latencyMs * m.load, 0) / totalLoad,
)
: active.length > 0
? Math.round(
active.reduce((sum, m) => sum + m.latencyMs, 0) / active.length,
)
: 0;
const monthlySpend = tier === "free" ? 0 : tier === "pro" ? 1840 : 6120;
return {
summary: {
activeModels: active.length,
avgLatencyMs,
monthlySpend,
},
models,
routing: routingFor(tier),
};
}
export function auditLogFor(tier: Tier): AuditLogResponse {
const events =
tier === "free"
@@ -0,0 +1,7 @@
/* The portal's own in-page surface. Same look as SUI's `.sui-surface`, owned here because a
`sui-` class belongs to the component that emits it — feature markup doesn't apply one. */
.portal-surface {
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
}
@@ -438,8 +438,6 @@
.portal-docs__endpoint-group {
display: flex;
flex-direction: column;
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -96,7 +96,7 @@ export function Documents() {
</div>
<Button
variant="secondary"
size="sm"
fat
leftSection={<DownloadIcon />}
onClick={exportCsv}
disabled={documents.length === 0}
@@ -122,27 +122,7 @@
gap: 0.75rem;
}
/* Two-column split */
.portal-infra__split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
}
@media (max-width: 60rem) {
.portal-infra__split {
grid-template-columns: 1fr;
}
}
/* Shared cell styles */
.portal-infra__cell-stack {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.portal-infra__cell-strong {
font-size: 0.8125rem;
font-weight: 600;
@@ -165,26 +145,6 @@
color: var(--c-text-muted);
}
.portal-infra__muted {
font-size: 0.75rem;
color: var(--c-text-subtle);
}
/* Region load cell */
.portal-infra__load {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-infra__load-pct {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--c-text-subtle);
min-width: 2.25rem;
text-align: right;
}
/* ── API keys ───────────────────────────────────────────────────────────── */
.portal-infra__keys {
display: flex;
@@ -252,10 +212,6 @@
}
}
.portal-infra__kv-wide {
grid-column: 1 / -1;
}
.portal-infra__kv dt {
font-size: 0.6875rem;
text-transform: uppercase;
@@ -270,12 +226,6 @@
color: var(--c-text-muted);
}
.portal-infra__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Modal form */
.portal-infra__form {
display: flex;
@@ -283,217 +233,12 @@
gap: 1rem;
}
.portal-infra__perm-row {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.portal-infra__modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.625rem;
}
/* ── Security ───────────────────────────────────────────────────────────── */
.portal-infra__banner {
margin-top: 1rem;
}
.portal-infra__certs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
@media (max-width: 60rem) {
.portal-infra__certs {
grid-template-columns: repeat(2, 1fr);
}
}
.portal-infra__cert-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.portal-infra__cert-detail {
margin: 0;
font-size: 0.6875rem;
color: var(--c-text-subtle);
line-height: 1.4;
}
/* Encryption key management */
.portal-infra__keymgmt-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.portal-infra__keymgmt-title {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
/* Compliance attestations — same grid rhythm as certs, but each card stacks
a framework chip + report link, so it needs a little more vertical room. */
.portal-infra__attestations {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
@media (max-width: 60rem) {
.portal-infra__attestations {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 36rem) {
.portal-infra__attestations {
grid-template-columns: 1fr;
}
}
.portal-infra__attestation {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.portal-infra__attestation-link {
font-size: 0.75rem;
font-weight: 600;
color: var(--c-accent-text);
text-decoration: none;
}
.portal-infra__attestation-link:hover {
text-decoration: underline;
}
/* ── Storage ────────────────────────────────────────────────────────────── */
.portal-infra__usage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.625rem;
}
.portal-infra__usage-value {
font-size: 1.25rem;
font-weight: 650;
color: var(--c-text);
}
.portal-infra__usage-value .portal-infra__muted {
font-size: 0.875rem;
font-weight: 400;
}
.portal-infra__providers {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-infra__provider {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
}
.portal-infra__provider-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
background: var(--c-primary-tint);
color: var(--c-accent-text);
font-size: 0.875rem;
}
.portal-infra__provider-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.portal-infra__provider-meta {
display: flex;
align-items: center;
gap: 0.625rem;
}
/* Lifecycle timeline */
.portal-infra__lifecycle {
display: flex;
align-items: stretch;
gap: 0.5rem;
margin-top: 1rem;
}
.portal-infra__lifecycle-stage {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
}
.portal-infra__lifecycle-stage.is-active {
background: var(--color-green-light);
border-color: var(--color-green-border);
}
.portal-infra__lifecycle-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--c-text-subtle);
}
.portal-infra__lifecycle-stage.is-active .portal-infra__lifecycle-dot {
background: var(--color-green);
}
.portal-infra__lifecycle-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text);
}
.portal-infra__lifecycle-arrow {
display: flex;
align-items: center;
color: var(--c-text-subtle);
font-size: 0.875rem;
}
/* ── Audit ──────────────────────────────────────────────────────────────── */
.portal-infra__event {
display: flex;
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
vi.mock("@portal/contexts/ViewContext", () => ({
useView: () => ({ setActiveView: vi.fn() }),
}));
vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({
ApiKeysTab: () => <div data-testid="api-keys-tab" />,
}));
vi.mock("@portal/components/infrastructure/AuditTab", () => ({
AuditTab: () => <div data-testid="audit-tab" />,
}));
import { Infrastructure } from "@portal/views/Infrastructure";
const T = "portal.infrastructure.tabs";
function renderView(initialPath = "/infrastructure") {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[initialPath]}>
<Infrastructure />
</MemoryRouter>
</MantineProvider>,
);
}
function tabButtons() {
return screen
.getAllByRole("button")
.filter((b) => b.className.includes("sui-tabs__tab"));
}
describe("Infrastructure view", () => {
it("orders the working tabs first and defaults to API keys", () => {
renderView();
expect(tabButtons().map((b) => b.textContent)).toEqual([
`${T}.apiKeys`,
`${T}.audit`,
`${T}.deployments`,
`${T}.security`,
`${T}.models`,
`${T}.storage`,
]);
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
});
it("renders the backend-less tabs as inert: disabled, aria-disabled, never active", () => {
renderView();
const byLabel = new Map(tabButtons().map((b) => [b.textContent, b]));
for (const key of ["deployments", "security", "models", "storage"]) {
const btn = byLabel.get(`${T}.${key}`) as HTMLButtonElement;
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute("aria-disabled", "true");
expect(btn).toHaveAttribute("aria-pressed", "false");
fireEvent.click(btn);
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
}
});
it("switches to the audit tab when clicked", () => {
renderView();
fireEvent.click(screen.getByRole("button", { name: `${T}.audit` }));
expect(screen.getByTestId("audit-tab")).toBeInTheDocument();
expect(screen.queryByTestId("api-keys-tab")).not.toBeInTheDocument();
});
it("honours ?tab=audit deep links (used by the home visualiser)", () => {
renderView("/infrastructure?tab=audit");
expect(screen.getByTestId("audit-tab")).toBeInTheDocument();
});
it("ignores a deep link to a disabled tab and stays on the default", () => {
renderView("/infrastructure?tab=storage");
expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument();
expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument();
});
});
@@ -3,34 +3,20 @@ import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, Tabs, type TabItem } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { DeploymentsTab } from "@portal/components/infrastructure/DeploymentsTab";
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
import { SecurityTab } from "@portal/components/infrastructure/SecurityTab";
import { ModelsTab } from "@portal/components/infrastructure/ModelsTab";
import { StorageTab } from "@portal/components/infrastructure/StorageTab";
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
import "@portal/views/Infrastructure.css";
type InfraTab =
| "deployments"
| "api-keys"
| "security"
| "models"
| "storage"
| "audit";
type InfraTab = "api-keys" | "audit";
const INFRA_TABS: InfraTab[] = [
"deployments",
"api-keys",
"security",
"models",
"storage",
"audit",
];
/** Shown but inert: no backend behind these screens yet. */
type DisabledInfraTab = "deployments" | "security" | "models" | "storage";
const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"];
export function Infrastructure() {
const { t } = useTranslation();
const [tab, setTab] = useState<InfraTab>("deployments");
const [tab, setTab] = useState<InfraTab>("api-keys");
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
@@ -39,7 +25,7 @@ export function Infrastructure() {
useEffect(() => {
const requested = searchParams.get("tab");
if (!requested) return;
if ((INFRA_TABS as string[]).includes(requested)) {
if ((ENABLED_TABS as string[]).includes(requested)) {
setTab(requested as InfraTab);
}
const next = new URLSearchParams(searchParams);
@@ -47,13 +33,29 @@ export function Infrastructure() {
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const tabs: TabItem<InfraTab>[] = [
{ key: "deployments", label: t("portal.infrastructure.tabs.deployments") },
const tabs: TabItem<InfraTab | DisabledInfraTab>[] = [
{ key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") },
{ key: "security", label: t("portal.infrastructure.tabs.security") },
{ key: "models", label: t("portal.infrastructure.tabs.models") },
{ key: "storage", label: t("portal.infrastructure.tabs.storage") },
{ key: "audit", label: t("portal.infrastructure.tabs.audit") },
{
key: "deployments",
label: t("portal.infrastructure.tabs.deployments"),
disabled: true,
},
{
key: "security",
label: t("portal.infrastructure.tabs.security"),
disabled: true,
},
{
key: "models",
label: t("portal.infrastructure.tabs.models"),
disabled: true,
},
{
key: "storage",
label: t("portal.infrastructure.tabs.storage"),
disabled: true,
},
];
return (
@@ -67,29 +69,23 @@ export function Infrastructure() {
{t("portal.infrastructure.subtitle")}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setActiveView("editor")}
>
<Button fat onClick={() => setActiveView("editor")}>
{t("portal.infrastructure.manageEditorDeployment")}
</Button>
</header>
<Tabs<InfraTab>
<Tabs<InfraTab | DisabledInfraTab>
items={tabs}
activeKey={tab}
onChange={setTab}
onChange={(key) => {
if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab);
}}
variant="underline"
ariaLabel={t("portal.infrastructure.sectionsAriaLabel")}
/>
<div className="portal-infra__panel">
{tab === "deployments" && <DeploymentsTab />}
{tab === "api-keys" && <ApiKeysTab />}
{tab === "security" && <SecurityTab />}
{tab === "models" && <ModelsTab />}
{tab === "storage" && <StorageTab />}
{tab === "audit" && <AuditTab />}
</div>
</div>
@@ -116,9 +116,6 @@
/* One list, table-shaped: a shared 4-column grid so the connected, available
and coming-soon bands line up under the same headers. */
.portal-integrations__table {
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
overflow: hidden;
}
@@ -24,6 +24,7 @@ import {
} from "@portal/components/sources/connectionTypes";
import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations";
import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes";
import "@portal/theme/surface.css";
import "@portal/views/Integrations.css";
/**
@@ -243,6 +244,7 @@ export function Integrations() {
</div>
{capabilities?.customApi && (
<Button
fat
onClick={() => openCreate("api")}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
@@ -306,7 +308,7 @@ export function Integrations() {
))}
</div>
) : (
<div className="portal-integrations__table">
<div className="portal-surface portal-integrations__table">
<div className="portal-integrations__cols" aria-hidden>
<span>{t("portal.integrations.table.integration")}</span>
<span>{t("portal.integrations.table.worksWith")}</span>
@@ -46,6 +46,7 @@ export function Pipelines() {
</p>
</div>
<Button
fat
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
@@ -279,11 +279,7 @@
margin: 0 calc(-1 * var(--flowmodal-inset));
}
.portal-qb__paper {
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.portal-qb__paper-head {
display: flex;
@@ -386,10 +382,7 @@
/* ── Deal-status hero (Home, active deal) ─────────────────────────────────── */
.portal-hero {
border: 1px solid var(--c-border);
border-radius: 12px;
padding: 1.1rem 1.25rem;
background: var(--c-surface);
display: flex;
flex-direction: column;
gap: 1rem;
@@ -592,12 +585,12 @@
cursor: pointer;
transition:
background 0.12s,
box-shadow 0.12s,
border-color 0.12s,
transform 0.12s;
}
.portal-hero__chip--action:hover {
background: var(--c-surface);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
border-color: var(--c-border-strong);
transform: translateY(-1px);
}
@@ -786,10 +779,6 @@
does the scrolling. */
min-height: 16rem;
padding: 1.35rem 1.6rem;
border: 1px solid var(--c-border-subtle);
border-radius: 12px;
background: var(--c-surface);
box-shadow: var(--shadow-sm);
/* No serif token exists in the theme — the serif is specific to rendering legal terms as paper. */
font-family: Georgia, "Times New Roman", "Liberation Serif", serif;
font-size: 0.84rem;
@@ -89,69 +89,6 @@
color: var(--c-accent-text);
}
/* Expanded detail panel */
.portal-sources__expanded {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.25rem;
animation: portal-sources-reveal var(--motion-fast) ease-out;
}
@keyframes portal-sources-reveal {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.portal-sources__expanded-head {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.875rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.portal-sources__expanded-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--c-text);
}
.portal-sources__expanded-sub {
font-size: 0.75rem;
color: var(--c-text-subtle);
}
.portal-sources__expanded-close {
margin-left: auto;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-subtle);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-sources__expanded-close:hover {
background: var(--c-hover);
color: var(--c-text);
}
/* Detail body */
.portal-sources__detail {
display: flex;
@@ -368,42 +305,6 @@
}
}
/* Override the shared Button's flex-row label to a centred icon-over-label
column so the SVG sits above the name with real spacing. */
.portal-sources__type-card .mantine-Button-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.portal-sources__type-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-sources__type-card:hover {
border-color: var(--color-border-strong, var(--c-border));
background: var(--c-hover);
}
.portal-sources__type-card.is-selected {
border-color: var(--c-primary);
background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface));
box-shadow: 0 0 0 1px var(--c-primary) inset;
}
/* Icon inside the type picker: a plain stroke icon above the label (no badge
box — the tinted box read as an extra bordered chip floating on the tile). */
.portal-sources__type-icon {
@@ -413,10 +314,6 @@
color: var(--c-text-subtle);
}
.portal-sources__type-card.is-selected .portal-sources__type-icon {
color: var(--c-accent-text);
}
.portal-sources__type-icon .portal-sources__type-svg {
width: 1.6rem;
height: 1.6rem;
@@ -460,256 +357,3 @@
gap: 0.5rem;
width: 100%;
}
.portal-sources__connection-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.portal-sources__connection-picker .sui-select,
.portal-sources__connection-picker > div:first-child {
align-self: stretch;
}
.portal-sources__connection-create {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--color-border-2);
border-radius: 0.5rem;
align-self: stretch;
}
.portal-sources__connection-create-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.portal-sources__connection-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Back to the grid from the form. Sits above the fields, where the type dropdown used to be. */
.portal-sources__connection-back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 1rem;
padding: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
color: var(--c-text-muted);
}
.portal-sources__connection-back:hover {
color: var(--text-default, inherit);
}
/* The "request an integration" / "advanced" affordances, set below the form so presets read as
the primary path and these as the way out of it. */
.portal-sources__connection-secondary {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--c-border-subtle);
}
.portal-sources__connection-link {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
color: var(--c-conn-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.portal-sources__connection-link:hover {
opacity: 0.8;
}
/* Free-text fields (result-host list, body template) that outgrow a single-line input. */
.portal-sources__connection-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 0.375rem);
background: var(--surface-input, transparent);
color: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8125rem;
line-height: 1.5;
resize: vertical;
}
.portal-sources__connection-textarea:focus-visible {
outline: 2px solid var(--c-conn-accent);
outline-offset: -1px;
}
/* ---- Integration picker ---------------------------------------------------------------------
A searchable grid of what we can connect to. Sized so a full category is visible without
scrolling on a laptop, and so the search box is the first thing focus lands on. */
.portal-conn-picker {
display: flex;
flex-direction: column;
gap: 1.25rem;
/* Bounded rather than free-growing: the modal should not become the page. */
max-height: min(60vh, 32rem);
overflow-y: auto;
}
.portal-conn-picker__search {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 0.375rem);
/* Real theme token with a transparent fallback so it never falls back to opaque white in dark;
the border alone is enough to read as an input. */
background: var(--color-bg-subtle, transparent);
}
.portal-conn-picker__search-icon {
flex: none;
font-size: 1.125rem;
color: var(--c-text-muted);
}
.portal-conn-picker__search input {
flex: 1;
min-width: 0;
border: none;
background: none;
outline: none;
font: inherit;
color: inherit;
}
.portal-conn-picker__section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.portal-conn-picker__section-title {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-text-muted);
}
.portal-conn-picker__section-desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--c-text-muted);
}
.portal-conn-picker__grid {
display: grid;
/* auto-fill so the grid reflows to the modal rather than to a fixed column count. */
grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
gap: 0.5rem;
}
.portal-conn-picker__card {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
text-align: left;
cursor: pointer;
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-sm, 0.375rem);
background: var(--surface-subtle, transparent);
font: inherit;
color: inherit;
}
.portal-conn-picker__card:hover {
border-color: var(--c-conn-accent);
background: color-mix(in srgb, var(--c-conn-neutral) 6%, transparent);
}
/* The custom-API card is an escape hatch, not a vendor: dashed, so it reads as "something else". */
.portal-conn-picker__card--advanced {
border-style: dashed;
}
/* The vendor's real mark: full colour, no tinted plate behind it. Neutral
(currentColor) marks pick up the muted text tone instead. */
.portal-conn-picker__mark {
flex: none;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
color: var(--c-text-subtle);
}
.portal-conn-picker__mark svg {
display: block;
}
.portal-conn-picker__card-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.portal-conn-picker__card-name {
font-size: 0.875rem;
font-weight: 500;
}
.portal-conn-picker__card-desc {
font-size: 0.75rem;
line-height: 1.35;
color: var(--c-text-muted);
/* Two lines keeps every card the same height without truncating most descriptions. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.portal-conn-picker__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
text-align: center;
}
.portal-conn-picker__empty-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.portal-conn-picker__empty-body {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--c-text-muted);
}
@@ -158,14 +158,14 @@ describe("Sources view", () => {
expect(await screen.findByText("integrations view")).toBeInTheDocument();
});
it("hides the KPI strip and shows the empty state when only the editor exists", async () => {
it("hides the KPI strip when only the editor exists", async () => {
fetchSources.mockResolvedValue({
kpis: RESPONSE.kpis,
sources: [RESPONSE.sources[0]],
});
renderView();
expect(
await screen.findByText("portal.sources.empty.title"),
await screen.findByText("portal.sources.types.editor.label"),
).toBeInTheDocument();
expect(screen.queryByText("portal.sources.kpi.total")).toBeNull();
});
+6 -24
View File
@@ -2,10 +2,9 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useSearchParams } from "react-router-dom";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { Button, Skeleton } from "@app/ui";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { useSources } from "@portal/queries/sources";
import { SourcesIcon } from "@portal/components/icons";
import { type SourceView } from "@portal/api/sources";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { KpiStrip } from "@portal/components/sources/KpiStrip";
@@ -39,9 +38,9 @@ export function Sources() {
const sources = data?.sources ?? [];
// The editor is a virtual row that's always present, so "empty" means no
// configured sources beyond it. Gates the KPI strip and empty panel.
// configured sources beyond it. Gates the KPI strip.
const configuredCount = sources.filter((s) => s.type !== "editor").length;
const showEmpty = !isLoading && configuredCount === 0;
const showKpis = isLoading || configuredCount > 0;
const openCreate = () => setModal({ open: true, sourceId: null });
const openSource = (source: SourceView) =>
@@ -61,6 +60,7 @@ export function Sources() {
</div>
<div className="portal-sources__actions">
<Button
fat
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
@@ -69,7 +69,7 @@ export function Sources() {
</div>
</header>
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{showKpis && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
@@ -79,25 +79,7 @@ export function Sources() {
</div>
)}
{showEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.sources.empty.title")}
description={t("portal.sources.empty.description")}
actions={
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.sources.actions.connectSource")}
</Button>
}
/>
)}
{!isLoading && sources.length > 0 && (
<SourcesTable sources={sources} onRowClick={openSource} />
)}
{!isLoading && <SourcesTable sources={sources} onRowClick={openSource} />}
<SourceModal
open={modal.open}
+1 -1
View File
@@ -176,7 +176,7 @@ export function Usage({ onWalletLoaded, onReauth }: UsageProps = {}) {
{wallet?.status === "subscribed" && (
<Button
variant="secondary"
size="sm"
fat
loading={portal.opening}
onClick={portal.open}
>
@@ -293,9 +293,6 @@
}
.portal-users__group {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
+6 -2
View File
@@ -280,11 +280,15 @@ export function Users() {
</div>
<div className="portal-users__head-actions">
{caps.createTeam && (
<Button variant="secondary" onClick={() => setNewTeamOpen(true)}>
<Button
fat
variant="secondary"
onClick={() => setNewTeamOpen(true)}
>
{t("users.newTeam.action", "+ New team")}
</Button>
)}
<Button onClick={() => openInvite(null)}>
<Button fat onClick={() => openInvite(null)}>
{t("users.invite.action", "Invite people")}
</Button>
</div>