mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" : "",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (0–1). */
|
||||
load: number;
|
||||
status: RegionStatus;
|
||||
/** Deployed Stirling engine version. */
|
||||
version: string;
|
||||
/** 30-day uptime as a fraction (0–1). */
|
||||
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 (0–1). */
|
||||
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 0–1 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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user