mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
I18n on portal (#6761)
## Overview Internationalizes the **developer portal**, which previously had **zero i18n** — every string was hardcoded across ~118 components. Rather than stand up a parallel system, this shares the **editor's** existing i18n setup (same TOML locale format, same Crowdin pipeline), then converts every portal surface to `react-i18next` and adds a CI guard so coverage can't regress. ## What's included ### 🔗 Shared i18n core (`@shared/i18n`) - Extracts the editor's `TomlBackend` (HTTP loader for `public/locales/{lng}/translation.toml`) and language metadata/helpers (the 42-language list, RTL set, `LanguageSource` priority, code normalizers) into `frontend/shared/i18n/`. - The **editor** now imports and re-exports these from `@shared/i18n` — its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted. - The **portal** builds its own i18next instance from the shared core, with **en-US as the source of truth** and the same `/locales/{lng}/translation.toml` layout. ### 🌍 Full portal coverage - Every view and component converted to `t()` — all feature areas (home, pipelines, sources, infrastructure, usage, documents, agent-builder, editor-admin, policies, users, docs, catalogue, components view) plus app shell, nav, modals, and the home/domain widgets. - **1108 keys across ~30 namespaces** in `portal/public/locales/en-US/translation.toml`, grouped by feature; shared strings under `[common]`. Plurals use i18next count forms; dynamic labels (nav, settings sections, status badges) use template keys against populated tables. - Data-driven strings (values from `@portal/api/*` mocks, enum/id values, code samples) are intentionally left untranslated — they're data, not UI chrome. ### ✅ CI coverage guard - `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in portal source is missing from the en-US locale. Wired into `frontend:check` and `frontend:check:all`, so missed keys break CI. This mirrors the editor's `missingTranslations` test for the portal, which has no vitest harness of its own. ## Testing - `task frontend:check:all` passes locally (typecheck all variants, lint, format, **portal i18n guard**, builds, tests, storybook). - Every static `t()` key verified to resolve in the locale (1108 keys / 186 source files); all dynamic key prefixes map to populated tables. - Runtime sweep of all 12 portal routes shows **no unresolved keys** on screen; nav labels, plurals, and array-backed copy all render real text. ## Follow-ups (not in this PR) - **Crowdin** — register `frontend/portal/public/locales/` as a source so portal strings flow through the same translation pipeline as the editor (an ops step on the Crowdin side; there's no Crowdin config in the repo). - Only `en-US` is populated; other languages will arrive via the pipeline.
This commit is contained in:
@@ -1,73 +1,29 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import TomlBackend from "@app/i18n/tomlBackend";
|
||||
import TomlBackend from "@shared/i18n/tomlBackend";
|
||||
import {
|
||||
supportedLanguages,
|
||||
rtlLanguages,
|
||||
I18N_STORAGE_KEYS,
|
||||
LanguageSource,
|
||||
normalizeLanguageCode,
|
||||
toUnderscoreFormat,
|
||||
toUnderscoreLanguages,
|
||||
} from "@shared/i18n/languages";
|
||||
|
||||
// Define supported languages (based on your existing translations)
|
||||
export const supportedLanguages = {
|
||||
"en-US": "English (US)",
|
||||
"en-GB": "English (UK)",
|
||||
"ar-AR": "العربية",
|
||||
"az-AZ": "Azərbaycan Dili",
|
||||
"bg-BG": "Български",
|
||||
"ca-CA": "Català",
|
||||
"cs-CZ": "Česky",
|
||||
"da-DK": "Dansk",
|
||||
"de-DE": "Deutsch",
|
||||
"el-GR": "Ελληνικά",
|
||||
"es-ES": "Español",
|
||||
"eu-ES": "Euskara",
|
||||
"fa-IR": "فارسی",
|
||||
"fr-FR": "Français",
|
||||
"ga-IE": "Gaeilge",
|
||||
"hi-IN": "हिंदी",
|
||||
"hr-HR": "Hrvatski",
|
||||
"hu-HU": "Magyar",
|
||||
"id-ID": "Bahasa Indonesia",
|
||||
"it-IT": "Italiano",
|
||||
"ja-JP": "日本語",
|
||||
"ko-KR": "한국어",
|
||||
"ml-ML": "മലയാളം",
|
||||
"nl-NL": "Nederlands",
|
||||
"no-NB": "Norsk",
|
||||
"pl-PL": "Polski",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"pt-PT": "Português",
|
||||
"ro-RO": "Română",
|
||||
"ru-RU": "Русский",
|
||||
"sk-SK": "Slovensky",
|
||||
"sl-SI": "Slovenščina",
|
||||
"sr-LATN-RS": "Srpski",
|
||||
"sv-SE": "Svenska",
|
||||
"th-TH": "ไทย",
|
||||
"tr-TR": "Türkçe",
|
||||
"uk-UA": "Українська",
|
||||
"vi-VN": "Tiếng Việt",
|
||||
"zh-BO": "བོད་ཡིག",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
// Language metadata and code helpers are shared with the portal via
|
||||
// @shared/i18n. Re-export them so existing `@app/i18n` consumers are unchanged.
|
||||
export {
|
||||
supportedLanguages,
|
||||
rtlLanguages,
|
||||
I18N_STORAGE_KEYS,
|
||||
LanguageSource,
|
||||
normalizeLanguageCode,
|
||||
toUnderscoreFormat,
|
||||
toUnderscoreLanguages,
|
||||
};
|
||||
|
||||
// RTL languages (based on your existing language.direction property)
|
||||
export const rtlLanguages = ["ar-AR", "fa-IR"];
|
||||
|
||||
// LocalStorage keys for i18next
|
||||
export const I18N_STORAGE_KEYS = {
|
||||
LANGUAGE: "i18nextLng",
|
||||
LANGUAGE_SOURCE: "i18nextLng-source",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Language selection priority levels
|
||||
* Higher number = higher priority (cannot be overridden by lower priority)
|
||||
*/
|
||||
export enum LanguageSource {
|
||||
Fallback = 0,
|
||||
Browser = 1,
|
||||
ServerDefault = 2,
|
||||
User = 3,
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(TomlBackend)
|
||||
.use(LanguageDetector)
|
||||
@@ -139,36 +95,6 @@ i18n.on("initialized", () => {
|
||||
}
|
||||
});
|
||||
|
||||
export function normalizeLanguageCode(languageCode: string): string {
|
||||
// Replace underscores with hyphens to align with i18next/translation file naming
|
||||
const hyphenated = languageCode.replace(/_/g, "-");
|
||||
const [base, ...rest] = hyphenated.split("-");
|
||||
|
||||
if (rest.length === 0) {
|
||||
return base.toLowerCase();
|
||||
}
|
||||
|
||||
const normalizedParts = rest.map((part) =>
|
||||
part.length <= 3 ? part.toUpperCase() : part,
|
||||
);
|
||||
return [base.toLowerCase(), ...normalizedParts].join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert language codes to underscore format (e.g., en-US → en_US)
|
||||
* Used for backend API communication which expects underscore format
|
||||
*/
|
||||
export function toUnderscoreFormat(languageCode: string): string {
|
||||
return languageCode.replace(/-/g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of language codes to underscore format
|
||||
*/
|
||||
export function toUnderscoreLanguages(languages: string[]): string[] {
|
||||
return languages.map(toUnderscoreFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current language source priority
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import TomlBackend from "@app/i18n/tomlBackend";
|
||||
import TomlBackend from "@shared/i18n/tomlBackend";
|
||||
|
||||
i18n
|
||||
.use(TomlBackend)
|
||||
|
||||
@@ -1,241 +1,53 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parse } from "smol-toml";
|
||||
import {
|
||||
I18N_PROJECTS,
|
||||
REPO_ROOT,
|
||||
findMissingKeys,
|
||||
} from "@shared/i18n/translationAudit";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "../../../..");
|
||||
const SRC_ROOT = path.join(__dirname, "../..");
|
||||
const EN_US_FILE = path.join(
|
||||
__dirname,
|
||||
"../../../public/locales/en-US/translation.toml",
|
||||
);
|
||||
// One suite per frontend app (editor + portal). The scan logic lives in
|
||||
// @shared/i18n/translationAudit so both apps share one implementation.
|
||||
describe.each(I18N_PROJECTS)(
|
||||
"Missing translation coverage — $name",
|
||||
(project) => {
|
||||
test(
|
||||
"fails if any en-US key used in source is missing from the locale",
|
||||
{ timeout: 10000 },
|
||||
() => {
|
||||
expect(fs.existsSync(project.localeFile)).toBe(true);
|
||||
|
||||
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
|
||||
const IGNORED_FILE_PATTERNS = [
|
||||
/\.d\.ts$/,
|
||||
/\.test\./,
|
||||
/\.spec\./,
|
||||
/\.stories\./,
|
||||
];
|
||||
const IGNORED_KEYS = new Set<string>([
|
||||
// If the script has found a false-positive that shouldn't be in the translations, include it here
|
||||
]);
|
||||
const LIKELY_TRANSLATION_USAGE_RE = /(?:^|[^\w$])t\s*\(|\.t\s*\(|\bi18nKey\b/;
|
||||
const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/;
|
||||
const { missing, usedCount } = findMissingKeys(project);
|
||||
expect(usedCount).toBeGreaterThan(project.minUsedKeys ?? 1); // scan sanity
|
||||
|
||||
type FoundKey = {
|
||||
key: string;
|
||||
fallback: string;
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
|
||||
const flattenKeys = (
|
||||
node: unknown,
|
||||
prefix = "",
|
||||
acc = new Set<string>(),
|
||||
): Set<string> => {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||
if (prefix) {
|
||||
acc.add(prefix);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const [childKey, value] of Object.entries(
|
||||
node as Record<string, unknown>,
|
||||
)) {
|
||||
const next = prefix ? `${prefix}.${childKey}` : childKey;
|
||||
flattenKeys(value, next, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
const hasPluralCoverage = (key: string, availableKeys: Set<string>): boolean =>
|
||||
[...availableKeys].some(
|
||||
(availableKey) =>
|
||||
availableKey.startsWith(`${key}_`) && PLURAL_SUFFIX_RE.test(availableKey),
|
||||
);
|
||||
|
||||
const listSourceFiles = (): string[] => {
|
||||
const files = ts.sys.readDirectory(
|
||||
SRC_ROOT,
|
||||
[".ts", ".tsx", ".js", ".jsx"],
|
||||
undefined,
|
||||
["**/*"],
|
||||
);
|
||||
|
||||
return files
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)),
|
||||
)
|
||||
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
|
||||
};
|
||||
|
||||
const getScriptKind = (file: string): ts.ScriptKind => {
|
||||
if (file.endsWith(".tsx")) {
|
||||
return ts.ScriptKind.TSX;
|
||||
}
|
||||
|
||||
if (file.endsWith(".ts")) {
|
||||
return ts.ScriptKind.TS;
|
||||
}
|
||||
|
||||
if (file.endsWith(".jsx")) {
|
||||
return ts.ScriptKind.JSX;
|
||||
}
|
||||
|
||||
return ts.ScriptKind.JS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all of the static first keys for translation functions that we can.
|
||||
* Ignores dynamic strings because we can't know what the actual translation key will be.
|
||||
*/
|
||||
const extractKeys = (file: string): FoundKey[] => {
|
||||
const code = fs.readFileSync(file, "utf8");
|
||||
if (!LIKELY_TRANSLATION_USAGE_RE.test(code)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
code,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
getScriptKind(file),
|
||||
);
|
||||
|
||||
const found: FoundKey[] = [];
|
||||
|
||||
const record = (node: ts.Node, key: string, fallback: string = "") => {
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
|
||||
node.getStart(),
|
||||
);
|
||||
found.push({ key, fallback, file, line: line + 1, column: character + 1 });
|
||||
};
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression;
|
||||
const arg0 = node.arguments.at(0);
|
||||
const arg1 = node.arguments.at(1);
|
||||
|
||||
const isT =
|
||||
(ts.isIdentifier(callee) && callee.text === "t") ||
|
||||
(ts.isPropertyAccessExpression(callee) && callee.name.text === "t");
|
||||
|
||||
if (
|
||||
isT &&
|
||||
arg0 &&
|
||||
(ts.isStringLiteral(arg0) || ts.isNoSubstitutionTemplateLiteral(arg0))
|
||||
) {
|
||||
let arg1Text: string = "";
|
||||
if (
|
||||
arg1 &&
|
||||
(ts.isStringLiteral(arg1) || ts.isNoSubstitutionTemplateLiteral(arg1))
|
||||
) {
|
||||
arg1Text = arg1.text;
|
||||
}
|
||||
record(arg0, arg0.text, arg1Text);
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||
for (const attr of node.attributes.properties) {
|
||||
if (
|
||||
!ts.isJsxAttribute(attr) ||
|
||||
attr.name.getText(sourceFile) !== "i18nKey" ||
|
||||
!attr.initializer
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const init = attr.initializer;
|
||||
|
||||
if (ts.isStringLiteral(init)) {
|
||||
record(init, init.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isJsxExpression(init) &&
|
||||
init.expression &&
|
||||
ts.isStringLiteral(init.expression)
|
||||
) {
|
||||
record(init.expression, init.expression.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("Missing translation coverage", () => {
|
||||
test(
|
||||
"fails if any en-US translation key used in source is missing",
|
||||
{ timeout: 10000 },
|
||||
() => {
|
||||
expect(fs.existsSync(EN_US_FILE)).toBe(true);
|
||||
|
||||
const localeContent = fs.readFileSync(EN_US_FILE, "utf8");
|
||||
const enUs = parse(localeContent);
|
||||
const availableKeys = flattenKeys(enUs);
|
||||
|
||||
const usedKeys = listSourceFiles()
|
||||
.flatMap(extractKeys)
|
||||
.filter(({ key }) => !IGNORED_KEYS.has(key));
|
||||
expect(usedKeys.length).toBeGreaterThan(100); // Sanity check
|
||||
|
||||
const missingKeys = usedKeys.filter(
|
||||
({ key }) =>
|
||||
!availableKeys.has(key) && !hasPluralCoverage(key, availableKeys),
|
||||
);
|
||||
|
||||
const annotations = missingKeys.map(
|
||||
({ key, fallback, file, line, column }) => {
|
||||
const workspaceRelativeRaw = path.relative(REPO_ROOT, file);
|
||||
const workspaceRelativeFile = workspaceRelativeRaw.replace(
|
||||
/\\/g,
|
||||
"/",
|
||||
);
|
||||
|
||||
return {
|
||||
const annotations = missing.map(
|
||||
({ key, fallback, file, line, column }) => ({
|
||||
key,
|
||||
fallback,
|
||||
file: workspaceRelativeFile,
|
||||
file: path.relative(REPO_ROOT, file).replace(/\\/g, "/"),
|
||||
line,
|
||||
column,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Output errors in GitHub Annotations format so they appear tagged in the code in CI
|
||||
for (const { key, fallback, file, line, column } of annotations) {
|
||||
process.stderr.write(
|
||||
`::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const neatened = annotations.map(
|
||||
({ key, fallback, file, line, column }) => {
|
||||
return {
|
||||
// GitHub Annotations format so misses show up tagged on the code in CI.
|
||||
for (const { key, fallback, file, line, column } of annotations) {
|
||||
process.stderr.write(
|
||||
`::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const located = annotations.map(
|
||||
({ key, fallback, file, line, column }) => ({
|
||||
key,
|
||||
fallback,
|
||||
location: `${file}:${line}:${column}`,
|
||||
};
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(neatened).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(located).toEqual([]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,224 +1,46 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parse } from "smol-toml";
|
||||
import {
|
||||
I18N_PROJECTS,
|
||||
REPO_ROOT,
|
||||
findUnusedKeys,
|
||||
} from "@shared/i18n/translationAudit";
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "../../../..");
|
||||
const SRC_ROOT = path.join(__dirname, "../..");
|
||||
const EN_US_FILE = path.join(
|
||||
__dirname,
|
||||
"../../../public/locales/en-US/translation.toml",
|
||||
);
|
||||
// One suite per frontend app (editor + portal). The scan logic lives in
|
||||
// @shared/i18n/translationAudit so both apps share one implementation; each
|
||||
// project carries its own ignoredKeyPatterns for runtime-assembled keys.
|
||||
describe.each(I18N_PROJECTS)(
|
||||
"Unused translation coverage — $name",
|
||||
(project) => {
|
||||
test(
|
||||
"fails if any en-US key has no source references",
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
expect(fs.existsSync(project.localeFile)).toBe(true);
|
||||
|
||||
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
|
||||
const IGNORED_FILE_PATTERNS = [
|
||||
/\.d\.ts$/,
|
||||
/\.test\./,
|
||||
/\.spec\./,
|
||||
/\.stories\./,
|
||||
];
|
||||
const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/;
|
||||
const { unused, localeCount } = findUnusedKeys(project);
|
||||
expect(localeCount).toBeGreaterThan(project.minLocaleKeys ?? 1); // sanity
|
||||
|
||||
/**
|
||||
* Keys that look unused to the heuristic but are genuinely used: keep them.
|
||||
* These are families assembled at runtime, so no static fragment ever reaches
|
||||
* source code for the literal/template matching to catch. Add a regex here
|
||||
* (with a comment naming the runtime usage) rather than teaching the test
|
||||
* about specific component internals. For a single key, anchor it: /^a\.b$/.
|
||||
*/
|
||||
const IGNORED_KEY_PATTERNS: RegExp[] = [
|
||||
// SignSettings / SavedSignaturesSection look up every key as
|
||||
// t(`${translationScope}.${key}`); the scope ("sign" | "addText" |
|
||||
// "addImage") and the relative key only ever exist as separate literals.
|
||||
/^(sign|addText|addImage)\./,
|
||||
// SettingsSearchBar builds its search index by loading whole subtrees via
|
||||
// t(prefix, { returnObjects: true }); the leaf keys never appear in source.
|
||||
/^admin\.settings\./,
|
||||
/^settings\./,
|
||||
/^account\./,
|
||||
];
|
||||
const localeRelative = path
|
||||
.relative(REPO_ROOT, project.localeFile)
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
const flattenKeys = (
|
||||
node: unknown,
|
||||
prefix = "",
|
||||
acc = new Set<string>(),
|
||||
): Set<string> => {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||
if (prefix) {
|
||||
acc.add(prefix);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const [childKey, value] of Object.entries(
|
||||
node as Record<string, unknown>,
|
||||
)) {
|
||||
const next = prefix ? `${prefix}.${childKey}` : childKey;
|
||||
flattenKeys(value, next, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
const listSourceFiles = (): string[] => {
|
||||
const files = ts.sys.readDirectory(
|
||||
SRC_ROOT,
|
||||
[".ts", ".tsx", ".js", ".jsx"],
|
||||
undefined,
|
||||
["**/*"],
|
||||
);
|
||||
|
||||
return files
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)),
|
||||
)
|
||||
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
|
||||
};
|
||||
|
||||
const getScriptKind = (file: string): ts.ScriptKind => {
|
||||
if (file.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
||||
if (file.endsWith(".ts")) return ts.ScriptKind.TS;
|
||||
if (file.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
||||
return ts.ScriptKind.JS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk each file's AST and collect every template literal whose static parts
|
||||
* could plausibly form a dotted translation key. Each shape replaces ${...}
|
||||
* interpolations with `*`, e.g. `tools.${id}.title` becomes `tools.*.title`.
|
||||
*
|
||||
* We deliberately collect *all* template literals (not just those at t()
|
||||
* call sites), because keys are often built up in helpers, constants or
|
||||
* config objects and only passed to t() somewhere far away. A shape only
|
||||
* counts if it carries at least one identifier-like static fragment though,
|
||||
* so generic templates like `${name}.${ext}` (shape `*.*`) are discarded.
|
||||
*
|
||||
* Using the AST (rather than a backtick-pair regex) is important: source
|
||||
* files contain large multi-line templates with embedded CSS/HTML and
|
||||
* nested interpolations that confuse regex-based pairing.
|
||||
*/
|
||||
const extractTemplateShapesFromFile = (
|
||||
file: string,
|
||||
acc: Set<string>,
|
||||
): void => {
|
||||
const code = fs.readFileSync(file, "utf8");
|
||||
if (!code.includes("${")) return;
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
code,
|
||||
ts.ScriptTarget.Latest,
|
||||
false,
|
||||
getScriptKind(file),
|
||||
);
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
let shape = node.head.text;
|
||||
for (const span of node.templateSpans) {
|
||||
shape += "*";
|
||||
shape += span.literal.text;
|
||||
}
|
||||
if (
|
||||
shape.includes(".") &&
|
||||
/[A-Za-z0-9_-]/.test(shape.replace(/\*/g, ""))
|
||||
) {
|
||||
acc.add(shape);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
};
|
||||
|
||||
const shapeToMatcher = (shape: string): RegExp => {
|
||||
// Each * stands in for one runtime-supplied path segment. We use `[^.]+`
|
||||
// (not `.+`) so a one-variable interpolation doesn't accidentally span
|
||||
// multiple key levels. If a real interpolation does carry a multi-segment
|
||||
// string, the IGNORED_KEY_PATTERNS list is the escape hatch.
|
||||
const escaped = shape
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("[^.]+");
|
||||
return new RegExp(`^${escaped}$`);
|
||||
};
|
||||
|
||||
const isIgnored = (key: string): boolean => {
|
||||
return IGNORED_KEY_PATTERNS.some((re) => re.test(key));
|
||||
};
|
||||
|
||||
const getTranslationLookupKeys = (key: string): string[] => {
|
||||
const pluralBaseKey = key.replace(PLURAL_SUFFIX_PATTERN, "");
|
||||
if (pluralBaseKey === key) {
|
||||
return [key];
|
||||
}
|
||||
|
||||
return [key, pluralBaseKey];
|
||||
};
|
||||
|
||||
describe("Unused translation coverage", () => {
|
||||
test(
|
||||
"fails if any en-US translation key has no source references",
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
expect(fs.existsSync(EN_US_FILE)).toBe(true);
|
||||
|
||||
const enUs = parse(fs.readFileSync(EN_US_FILE, "utf8"));
|
||||
const availableKeys = Array.from(flattenKeys(enUs));
|
||||
expect(availableKeys.length).toBeGreaterThan(100); // sanity check
|
||||
|
||||
const sourceFiles = listSourceFiles();
|
||||
expect(sourceFiles.length).toBeGreaterThan(0);
|
||||
|
||||
const source = sourceFiles
|
||||
.map((file) => fs.readFileSync(file, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
const shapes = new Set<string>();
|
||||
for (const file of sourceFiles) {
|
||||
extractTemplateShapesFromFile(file, shapes);
|
||||
}
|
||||
const shapeMatchers = Array.from(shapes).map(shapeToMatcher);
|
||||
|
||||
const unused = availableKeys.filter((key) => {
|
||||
if (isIgnored(key)) return false;
|
||||
const lookupKeys = getTranslationLookupKeys(key);
|
||||
// Direct: the full key text appears anywhere in source (catches
|
||||
// static t() calls, i18nKey props, constants, and any other place
|
||||
// the literal string sits in code or comments). Plural variants also
|
||||
// count as used when their base key is referenced because i18next
|
||||
// resolves suffixes like _one/_other from a single base lookup.
|
||||
if (lookupKeys.some((lookupKey) => source.includes(lookupKey))) {
|
||||
return false;
|
||||
// GitHub Annotations format so unused keys show up tagged on the locale.
|
||||
for (const key of unused) {
|
||||
process.stderr.write(
|
||||
`::error file=${localeRelative}::Unused en-US translation: ${key}\n`,
|
||||
);
|
||||
}
|
||||
// Dynamic: the key matches a template-literal shape from source.
|
||||
return !lookupKeys.some((lookupKey) =>
|
||||
shapeMatchers.some((re) => re.test(lookupKey)),
|
||||
);
|
||||
});
|
||||
|
||||
const localeRelative = path
|
||||
.relative(REPO_ROOT, EN_US_FILE)
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
// GitHub Annotations format so unused keys show up tagged on the
|
||||
// translation file in CI.
|
||||
for (const key of unused) {
|
||||
process.stderr.write(
|
||||
`::error file=${localeRelative}::Unused en-US translation: ${key}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
unused,
|
||||
`Found ${unused.length} unused en-US translation key(s). ` +
|
||||
`Remove them from ${localeRelative}, or (if the usage is too ` +
|
||||
`dynamic for the heuristic to spot) add to IGNORED_KEY_PATTERNS ` +
|
||||
`in this test.`,
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(
|
||||
unused,
|
||||
`Found ${unused.length} unused en-US translation key(s). ` +
|
||||
`Remove them from ${localeRelative}, or (if the usage is too ` +
|
||||
`dynamic for the heuristic to spot) add a pattern to this ` +
|
||||
`project's ignoredKeyPatterns in @shared/i18n/translationAudit.`,
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ const srcGlobs = [
|
||||
const nodeGlobs = [
|
||||
"scripts/**/*.{js,ts,mjs,mts}",
|
||||
"editor/scripts/**/*.{js,ts,mjs,mts}",
|
||||
"portal/scripts/**/*.{js,ts,mjs,mts}",
|
||||
"editor/*.config.{js,ts,mjs}",
|
||||
"portal/*.config.{js,ts,mjs}",
|
||||
"*.config.{js,ts,mjs}",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { StrictMode } from "react";
|
||||
import { StrictMode, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "@portal/App";
|
||||
import { readMocksPreference } from "@portal/mocks/preference";
|
||||
// Initialise i18n (side effect) before the app renders.
|
||||
import "@portal/i18n/config";
|
||||
|
||||
// Mantine's prebuilt styles load first so SUI tokens/base can override on
|
||||
// conflicts — SUI is the primary design language, Mantine the escape hatch.
|
||||
@@ -26,7 +28,9 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
createRoot(root!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Suspense fallback={null}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { SparklesIcon } from "@portal/components/icons";
|
||||
import "@portal/components/AssistantButton.css";
|
||||
|
||||
export function AssistantButton() {
|
||||
const { assistantOpen, toggleAssistant } = useUI();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={"portal-assistant-btn" + (assistantOpen ? " is-active" : "")}
|
||||
onClick={toggleAssistant}
|
||||
aria-label={assistantOpen ? "Close assistant" : "Open assistant"}
|
||||
aria-label={assistantOpen ? t("assistant.close") : t("assistant.open")}
|
||||
aria-expanded={assistantOpen}
|
||||
title="Assistant"
|
||||
title={t("assistant.title")}
|
||||
>
|
||||
{assistantOpen ? (
|
||||
<svg
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ interface Message {
|
||||
}
|
||||
|
||||
export function AssistantPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { assistantOpen, closeAssistant } = useUI();
|
||||
const { data: suggestions } = useAsync<readonly string[]>(
|
||||
() => fetchAssistantSuggestions(),
|
||||
@@ -69,8 +71,8 @@ export function AssistantPanel() {
|
||||
role: "assistant",
|
||||
text:
|
||||
err instanceof Error
|
||||
? `Couldn't reach the assistant: ${err.message}`
|
||||
: "Couldn't reach the assistant.",
|
||||
? t("assistant.errorWithDetail", { detail: err.message })
|
||||
: t("assistant.error"),
|
||||
};
|
||||
setMessages((prev) => [...prev, failMsg]);
|
||||
} finally {
|
||||
@@ -81,17 +83,23 @@ export function AssistantPanel() {
|
||||
if (!assistantOpen) return null;
|
||||
|
||||
return (
|
||||
<aside className="portal-assistant" role="dialog" aria-label="Assistant">
|
||||
<aside
|
||||
className="portal-assistant"
|
||||
role="dialog"
|
||||
aria-label={t("assistant.title")}
|
||||
>
|
||||
<header className="portal-assistant__header">
|
||||
<div className="portal-assistant__header-left">
|
||||
<SparklesIcon size={16} />
|
||||
<span className="portal-assistant__title">Assistant</span>
|
||||
<span className="portal-assistant__title">
|
||||
{t("assistant.title")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-assistant__close"
|
||||
onClick={closeAssistant}
|
||||
aria-label="Close assistant"
|
||||
aria-label={t("assistant.close")}
|
||||
>
|
||||
<CloseIcon size={16} />
|
||||
</button>
|
||||
@@ -101,7 +109,7 @@ export function AssistantPanel() {
|
||||
{messages.length === 0 && suggestions && (
|
||||
<div className="portal-assistant__suggestions">
|
||||
<div className="portal-assistant__suggestions-eyebrow">
|
||||
Try asking
|
||||
{t("assistant.tryAsking")}
|
||||
</div>
|
||||
<div className="portal-assistant__suggestions-list">
|
||||
{suggestions.map((s) => (
|
||||
@@ -130,7 +138,10 @@ export function AssistantPanel() {
|
||||
))}
|
||||
{typing && (
|
||||
<div className="portal-assistant__bubble portal-assistant__bubble--assistant">
|
||||
<span className="portal-assistant__typing" aria-label="Typing">
|
||||
<span
|
||||
className="portal-assistant__typing"
|
||||
aria-label={t("assistant.typing")}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
@@ -150,8 +161,8 @@ export function AssistantPanel() {
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about Stirling…"
|
||||
aria-label="Ask the assistant"
|
||||
placeholder={t("assistant.inputPlaceholder")}
|
||||
aria-label={t("assistant.inputAriaLabel")}
|
||||
className="portal-assistant__input"
|
||||
disabled={typing}
|
||||
/>
|
||||
@@ -159,7 +170,7 @@ export function AssistantPanel() {
|
||||
type="submit"
|
||||
className="portal-assistant__send"
|
||||
disabled={!input.trim() || typing}
|
||||
aria-label="Send"
|
||||
aria-label={t("assistant.send")}
|
||||
>
|
||||
<SendIcon size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar, Dropdown } from "@shared/components";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
|
||||
import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import {
|
||||
SearchIcon,
|
||||
@@ -15,15 +16,22 @@ import "@portal/components/Header.css";
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-header__icon-btn"
|
||||
onClick={toggle}
|
||||
aria-label={
|
||||
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
|
||||
theme === "light"
|
||||
? t("shell.header.switchToDark")
|
||||
: t("shell.header.switchToLight")
|
||||
}
|
||||
title={
|
||||
theme === "light"
|
||||
? t("shell.header.darkMode")
|
||||
: t("shell.header.lightMode")
|
||||
}
|
||||
title={theme === "light" ? "Dark mode" : "Light mode"}
|
||||
>
|
||||
{theme === "light" ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
||||
</button>
|
||||
@@ -71,22 +79,25 @@ function TierSwitcher() {
|
||||
export function Header() {
|
||||
const { activeView } = useView();
|
||||
const { openSearch } = useUI();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<header className="portal-header">
|
||||
<div className="portal-header__left">
|
||||
<span className="portal-header__breadcrumb">
|
||||
{VIEW_LABELS[activeView]}
|
||||
{t(`nav.${activeView}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="portal-header__search"
|
||||
aria-label="Search"
|
||||
aria-label={t("shell.header.search")}
|
||||
onClick={openSearch}
|
||||
>
|
||||
<SearchIcon size={14} />
|
||||
<span className="portal-header__search-placeholder">Search…</span>
|
||||
<span className="portal-header__search-placeholder">
|
||||
{t("shell.header.searchPlaceholder")}
|
||||
</span>
|
||||
<span className="portal-header__search-kbd" aria-hidden>
|
||||
⌘K
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
readMocksPreference,
|
||||
writeMocksPreference,
|
||||
@@ -17,6 +18,7 @@ import "@portal/components/MocksToggle.css";
|
||||
* app looks like with/without mocks.
|
||||
*/
|
||||
export function MocksToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled] = useState(() => readMocksPreference());
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -39,15 +41,11 @@ export function MocksToggle() {
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-pressed={enabled}
|
||||
title={
|
||||
enabled
|
||||
? "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
|
||||
: "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
|
||||
}
|
||||
title={enabled ? t("mocks.tooltip.on") : t("mocks.tooltip.off")}
|
||||
>
|
||||
<span className="portal-mocks-toggle__dot" aria-hidden />
|
||||
<span className="portal-mocks-toggle__label">
|
||||
Mocks {enabled ? "ON" : "OFF"}
|
||||
{enabled ? t("mocks.label.on") : t("mocks.label.off")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
|
||||
import { BellIcon } from "@portal/components/icons";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -20,6 +21,7 @@ const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
|
||||
};
|
||||
|
||||
export function NotificationsDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
|
||||
const { data: items } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
@@ -48,8 +50,10 @@ export function NotificationsDropdown() {
|
||||
className="portal-header__icon-btn portal-header__icon-btn--badge"
|
||||
aria-label={
|
||||
hasUnread
|
||||
? `Notifications, ${visible.length} unread`
|
||||
: "Notifications, no unread"
|
||||
? t("notifications.ariaLabel.unread", {
|
||||
count: visible.length,
|
||||
})
|
||||
: t("notifications.ariaLabel.none")
|
||||
}
|
||||
>
|
||||
<BellIcon size={16} />
|
||||
@@ -60,16 +64,20 @@ export function NotificationsDropdown() {
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
|
||||
<div className="portal-notif__header">
|
||||
<span className="portal-notif__title">Notifications</span>
|
||||
<span className="portal-notif__title">
|
||||
{t("notifications.title")}
|
||||
</span>
|
||||
{hasUnread ? (
|
||||
<span className="portal-notif__count">{visible.length} new</span>
|
||||
<span className="portal-notif__count">
|
||||
{t("notifications.count.new", { count: visible.length })}
|
||||
</span>
|
||||
) : isLoading ? (
|
||||
<span className="portal-notif__count portal-notif__count--quiet">
|
||||
loading
|
||||
{t("notifications.count.loading")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="portal-notif__count portal-notif__count--quiet">
|
||||
all read
|
||||
{t("notifications.count.allRead")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,8 +92,8 @@ export function NotificationsDropdown() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="You're all caught up"
|
||||
description="No new notifications."
|
||||
title={t("notifications.empty.title")}
|
||||
description={t("notifications.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isEmpty && (
|
||||
@@ -113,10 +121,10 @@ export function NotificationsDropdown() {
|
||||
onClick={onMarkAllRead}
|
||||
disabled={!hasUnread}
|
||||
>
|
||||
Mark all read
|
||||
{t("notifications.markAllRead")}
|
||||
</button>
|
||||
<button type="button" className="portal-notif__action">
|
||||
View all
|
||||
{t("notifications.viewAll")}
|
||||
</button>
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, Chip, StatusBadge } from "@shared/components";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ type Phase = "pick" | "building" | "ready";
|
||||
const STAGE_STEP_MS = 550;
|
||||
|
||||
export function PipelineForkWizard() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const [phase, setPhase] = useState<Phase>("pick");
|
||||
const [template, setTemplate] = useState<PipelineTemplate | null>(null);
|
||||
@@ -68,15 +70,14 @@ export function PipelineForkWizard() {
|
||||
<Card padding="loose" className="portal-fork">
|
||||
<header className="portal-fork__head">
|
||||
<div>
|
||||
<h2 className="portal-fork__title">Fork a starter pipeline</h2>
|
||||
<p className="portal-fork__sub">
|
||||
Clone a proven workflow and tune it — every template ships the same
|
||||
four-stage backbone.
|
||||
</p>
|
||||
<h2 className="portal-fork__title">{t("forkWizard.title")}</h2>
|
||||
<p className="portal-fork__sub">{t("forkWizard.subtitle")}</p>
|
||||
</div>
|
||||
{phase !== "pick" && template && (
|
||||
<StatusBadge tone={phase === "ready" ? "success" : "info"} size="sm">
|
||||
{phase === "ready" ? "Ready to deploy" : "Building…"}
|
||||
{phase === "ready"
|
||||
? t("forkWizard.status.ready")
|
||||
: t("forkWizard.status.building")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</header>
|
||||
@@ -139,7 +140,9 @@ export function PipelineForkWizard() {
|
||||
|
||||
<div className="portal-fork__build-actions">
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
{phase === "ready" ? "Pick another" : "Cancel"}
|
||||
{phase === "ready"
|
||||
? t("forkWizard.action.pickAnother")
|
||||
: t("forkWizard.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
@@ -148,7 +151,9 @@ export function PipelineForkWizard() {
|
||||
disabled={phase !== "ready"}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{phase === "ready" ? "Deploy pipeline" : "Building…"}
|
||||
{phase === "ready"
|
||||
? t("forkWizard.action.deploy")
|
||||
: t("forkWizard.status.building")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -34,11 +35,11 @@ interface PolicyRow {
|
||||
|
||||
const STATE_BADGE: Record<
|
||||
RowState,
|
||||
{ tone: "success" | "neutral" | "info"; label: string }
|
||||
{ tone: "success" | "neutral" | "info"; labelKey: string }
|
||||
> = {
|
||||
active: { tone: "success", label: "Active" },
|
||||
off: { tone: "neutral", label: "Off" },
|
||||
locked: { tone: "info", label: "Soon" },
|
||||
active: { tone: "success", labelKey: "policySummary.state.active" },
|
||||
off: { tone: "neutral", labelKey: "policySummary.state.off" },
|
||||
locked: { tone: "info", labelKey: "policySummary.state.soon" },
|
||||
};
|
||||
|
||||
function toRow(entry: CatalogueEntry): PolicyRow {
|
||||
@@ -48,6 +49,7 @@ function toRow(entry: CatalogueEntry): PolicyRow {
|
||||
}
|
||||
|
||||
export function PolicySummary() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), []);
|
||||
const { data } = state;
|
||||
@@ -58,7 +60,7 @@ export function PolicySummary() {
|
||||
const columns: TableColumn<PolicyRow>[] = [
|
||||
{
|
||||
key: "category",
|
||||
header: "Policy",
|
||||
header: t("policySummary.column.policy"),
|
||||
render: ({ entry }) => (
|
||||
<div className="portal-policysum__cat">
|
||||
<span className="portal-policysum__icon" aria-hidden>
|
||||
@@ -73,23 +75,25 @@ export function PolicySummary() {
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
header: t("policySummary.column.status"),
|
||||
width: "7rem",
|
||||
render: ({ state }) => {
|
||||
const badge = STATE_BADGE[state];
|
||||
return (
|
||||
<StatusBadge tone={badge.tone} size="sm">
|
||||
{badge.label}
|
||||
{t(badge.labelKey)}
|
||||
</StatusBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "rule",
|
||||
header: "Active rule",
|
||||
header: t("policySummary.column.activeRule"),
|
||||
render: ({ entry, state }) => (
|
||||
<span className="portal-policysum__rule">
|
||||
{state === "active" ? entry.config.summary : "No rule enforced yet"}
|
||||
{state === "active"
|
||||
? entry.config.summary
|
||||
: t("policySummary.noRule")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -102,7 +106,7 @@ export function PolicySummary() {
|
||||
if (state === "locked") {
|
||||
return (
|
||||
<Button size="sm" variant="ghost" onClick={goToPolicies}>
|
||||
Coming soon
|
||||
{t("policySummary.action.comingSoon")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +116,9 @@ export function PolicySummary() {
|
||||
variant={state === "active" ? "ghost" : "outline"}
|
||||
onClick={goToPolicies}
|
||||
>
|
||||
{state === "active" ? "Configure" : "Set up"}
|
||||
{state === "active"
|
||||
? t("policySummary.action.configure")
|
||||
: t("policySummary.action.setUp")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
@@ -122,19 +128,23 @@ export function PolicySummary() {
|
||||
const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? [];
|
||||
|
||||
return (
|
||||
<section className="portal-policysum" aria-label="What runs on your PDFs">
|
||||
<section className="portal-policysum" aria-label={t("policySummary.title")}>
|
||||
<Card padding="none">
|
||||
<header className="portal-policysum__head">
|
||||
<div>
|
||||
<h2 className="portal-policysum__title">What runs on your PDFs</h2>
|
||||
<h2 className="portal-policysum__title">
|
||||
{t("policySummary.title")}
|
||||
</h2>
|
||||
<p className="portal-policysum__sub">
|
||||
Standing automations every document passes through, regardless of
|
||||
which pipeline handles it.
|
||||
{t("policySummary.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{data && (
|
||||
<StatusBadge tone="info" size="sm">
|
||||
{data.summary.active} / {data.summary.categories} active
|
||||
{t("policySummary.activeSummary", {
|
||||
active: data.summary.active,
|
||||
total: data.summary.categories,
|
||||
})}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</header>
|
||||
@@ -153,8 +163,8 @@ export function PolicySummary() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No policies yet"
|
||||
description="Once policies are configured, the categories appear here."
|
||||
title={t("policySummary.empty.title")}
|
||||
description={t("policySummary.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, type CardProps } from "@shared/components";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import "@portal/components/PopularUseCases.css";
|
||||
@@ -5,10 +6,8 @@ import "@portal/components/PopularUseCases.css";
|
||||
type Accent = NonNullable<CardProps["accent"]>;
|
||||
|
||||
interface UseCase {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
cta: string;
|
||||
/** Stable key into the useCases.items.* translation table. */
|
||||
key: string;
|
||||
accent: Accent;
|
||||
}
|
||||
|
||||
@@ -23,62 +22,35 @@ const ACCENT_COLOR: Record<Accent, string> = {
|
||||
/**
|
||||
* Curated landing-page use cases — a teaser, not the full catalogue. The
|
||||
* exhaustive per-vertical endpoint list lives on the Documents view; here we
|
||||
* surface the four cross-cutting pipelines people reach for first. Copy mirrors
|
||||
* the prototype's "Popular use cases" block.
|
||||
* surface the four cross-cutting pipelines people reach for first. The display
|
||||
* copy (eyebrow, title, blurb, cta) is keyed into useCases.items.<key>.
|
||||
*/
|
||||
const USE_CASES: UseCase[] = [
|
||||
{
|
||||
eyebrow: "AUTO-ROUTING",
|
||||
title: "Auto-classify and route incoming documents",
|
||||
blurb:
|
||||
"One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.",
|
||||
cta: "Build a classifier pipeline",
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
eyebrow: "PII REDACTION",
|
||||
title: "Redact PII before it leaves your stack",
|
||||
blurb:
|
||||
"Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.",
|
||||
cta: "See redaction pipelines",
|
||||
accent: "red",
|
||||
},
|
||||
{
|
||||
eyebrow: "TRAINING DATA",
|
||||
title: "Turn PDFs into training data",
|
||||
blurb:
|
||||
"Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.",
|
||||
cta: "Build a training-data pipeline",
|
||||
accent: "purple",
|
||||
},
|
||||
{
|
||||
eyebrow: "AUTHENTICITY",
|
||||
title: "Verify signatures and detect tampering",
|
||||
blurb:
|
||||
"Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.",
|
||||
cta: "Try authenticity check",
|
||||
accent: "green",
|
||||
},
|
||||
{ key: "autoRouting", accent: "blue" },
|
||||
{ key: "piiRedaction", accent: "red" },
|
||||
{ key: "trainingData", accent: "purple" },
|
||||
{ key: "authenticity", accent: "green" },
|
||||
];
|
||||
|
||||
export function PopularUseCases() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<section className="portal-usecases" aria-label="Popular use cases">
|
||||
<section className="portal-usecases" aria-label={t("useCases.title")}>
|
||||
<header className="portal-usecases__head">
|
||||
<h2 className="portal-usecases__title">Popular use cases</h2>
|
||||
<h2 className="portal-usecases__title">{t("useCases.title")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-usecases__viewall"
|
||||
onClick={() => setActiveView("pipelines")}
|
||||
>
|
||||
View all pipelines <span aria-hidden>→</span>
|
||||
{t("useCases.viewAll")} <span aria-hidden>→</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="portal-usecases__grid">
|
||||
{USE_CASES.map((uc) => (
|
||||
<Card
|
||||
key={uc.eyebrow}
|
||||
key={uc.key}
|
||||
accent={uc.accent}
|
||||
padding="loose"
|
||||
className="portal-usecases__card"
|
||||
@@ -87,17 +59,21 @@ export function PopularUseCases() {
|
||||
className="portal-usecases__eyebrow"
|
||||
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||
>
|
||||
{uc.eyebrow}
|
||||
{t(`useCases.items.${uc.key}.eyebrow`)}
|
||||
</span>
|
||||
<h3 className="portal-usecases__card-title">{uc.title}</h3>
|
||||
<p className="portal-usecases__blurb">{uc.blurb}</p>
|
||||
<h3 className="portal-usecases__card-title">
|
||||
{t(`useCases.items.${uc.key}.title`)}
|
||||
</h3>
|
||||
<p className="portal-usecases__blurb">
|
||||
{t(`useCases.items.${uc.key}.blurb`)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-usecases__cta"
|
||||
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||
onClick={() => setActiveView("pipelines")}
|
||||
>
|
||||
{uc.cta} <span aria-hidden>→</span>
|
||||
{t(`useCases.items.${uc.key}.cta`)} <span aria-hidden>→</span>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, ProgressBar, Skeleton } from "@shared/components";
|
||||
import { TIER_INFO, useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
@@ -24,6 +25,7 @@ function parseUsage(value: KpiEntry["value"]): {
|
||||
}
|
||||
|
||||
export function ProcessingStatusStrip() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
@@ -58,7 +60,7 @@ export function ProcessingStatusStrip() {
|
||||
variant="outline"
|
||||
onClick={() => setActiveView("usage")}
|
||||
>
|
||||
Upgrade
|
||||
{t("processingStatus.upgrade")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
@@ -66,7 +68,7 @@ export function ProcessingStatusStrip() {
|
||||
<div className="portal-statusstrip__free-row">
|
||||
<span className="portal-statusstrip__free-label">
|
||||
<strong>{used.toLocaleString()}</strong> / {cap.toLocaleString()}{" "}
|
||||
PDFs this month
|
||||
{t("processingStatus.pdfsThisMonth")}
|
||||
</span>
|
||||
<span className="portal-statusstrip__free-pct">
|
||||
{Math.round(ratio * 100)}%
|
||||
@@ -75,7 +77,7 @@ export function ProcessingStatusStrip() {
|
||||
<ProgressBar
|
||||
value={ratio}
|
||||
thresholded
|
||||
label={`${used} of ${cap} PDFs used this month`}
|
||||
label={t("processingStatus.progressLabel", { used, cap })}
|
||||
/>
|
||||
</Banner>
|
||||
);
|
||||
@@ -97,7 +99,7 @@ export function ProcessingStatusStrip() {
|
||||
·
|
||||
</span>
|
||||
<span className="portal-statusstrip__volume">
|
||||
<strong>{volume ?? "—"}</strong> PDFs processed · last 30 days
|
||||
<strong>{volume ?? "—"}</strong> {t("processingStatus.volumeSuffix")}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -105,7 +107,7 @@ export function ProcessingStatusStrip() {
|
||||
className="portal-statusstrip__manage"
|
||||
onClick={() => setActiveView("usage")}
|
||||
>
|
||||
Manage plan
|
||||
{t("processingStatus.managePlan")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ const KIND_COLOUR: Record<ActivityKind, string> = {
|
||||
};
|
||||
|
||||
export function RecentActivity() {
|
||||
const { t } = useTranslation();
|
||||
const state = useAsync<ActivityEvent[]>(() => fetchRecentActivity(), []);
|
||||
const { data: events } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
@@ -25,12 +27,12 @@ export function RecentActivity() {
|
||||
<Card
|
||||
padding="none"
|
||||
className="portal-activity"
|
||||
aria-label="Recent activity"
|
||||
aria-label={t("recentActivity.title")}
|
||||
>
|
||||
<header className="portal-activity__head">
|
||||
<h2 className="portal-activity__title">Recent activity</h2>
|
||||
<h2 className="portal-activity__title">{t("recentActivity.title")}</h2>
|
||||
<button type="button" className="portal-activity__more">
|
||||
View all →
|
||||
{t("recentActivity.viewAll")} →
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -54,8 +56,8 @@ export function RecentActivity() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="Nothing here yet"
|
||||
description="Pipeline runs, deploys and agent events will appear here."
|
||||
title={t("recentActivity.empty.title")}
|
||||
description={t("recentActivity.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState, Modal, Skeleton } from "@shared/components";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -7,6 +8,7 @@ import { SearchIcon } from "@portal/components/icons";
|
||||
import "@portal/components/SearchModal.css";
|
||||
|
||||
export function SearchModal() {
|
||||
const { t } = useTranslation();
|
||||
const { searchOpen, closeSearch } = useUI();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -48,7 +50,7 @@ export function SearchModal() {
|
||||
open={searchOpen}
|
||||
onClose={closeSearch}
|
||||
width="lg"
|
||||
ariaLabel="Search"
|
||||
ariaLabel={t("search.ariaLabel")}
|
||||
>
|
||||
<div className="portal-search">
|
||||
<div className="portal-search__input-row">
|
||||
@@ -57,8 +59,8 @@ export function SearchModal() {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search Stirling — endpoints, pipelines, docs…"
|
||||
aria-label="Search"
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.ariaLabel")}
|
||||
className="portal-search__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
@@ -82,13 +84,13 @@ export function SearchModal() {
|
||||
size="compact"
|
||||
title={
|
||||
query.trim()
|
||||
? `No matches for "${query.trim()}"`
|
||||
: "No quick actions"
|
||||
? t("search.empty.noMatches", { query: query.trim() })
|
||||
: t("search.empty.noActionsTitle")
|
||||
}
|
||||
description={
|
||||
query.trim()
|
||||
? "Try a different keyword or browse the catalogue."
|
||||
: "Quick actions will appear here once they're available."
|
||||
? t("search.empty.noMatchesDescription")
|
||||
: t("search.empty.noActionsDescription")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -55,100 +56,26 @@ interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Display copy for each notification category, keyed by the snapshot id. */
|
||||
const NOTIFICATION_COPY: Record<
|
||||
string,
|
||||
{ label: string; description: string }
|
||||
> = {
|
||||
"pipeline-failures": {
|
||||
label: "Pipeline failures",
|
||||
description: "A run errors out or a step times out.",
|
||||
},
|
||||
"pipeline-success": {
|
||||
label: "Pipeline completions",
|
||||
description: "Every successful pipeline run finishes.",
|
||||
},
|
||||
"usage-alerts": {
|
||||
label: "Usage & quota alerts",
|
||||
description: "You approach a plan limit or rate cap.",
|
||||
},
|
||||
"weekly-digest": {
|
||||
label: "Weekly digest",
|
||||
description: "A Monday summary of volume and health.",
|
||||
},
|
||||
"security-alerts": {
|
||||
label: "Security alerts",
|
||||
description: "New API keys, sign-ins, or permission changes.",
|
||||
},
|
||||
"product-updates": {
|
||||
label: "Product updates",
|
||||
description: "New operations, sources, and release notes.",
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Notification categories with known display copy, in the order the snapshot
|
||||
* exposes them. Labels and descriptions are resolved via i18n at render time,
|
||||
* keyed by id; ids absent from this list are skipped.
|
||||
*/
|
||||
const NOTIFICATION_IDS = [
|
||||
"pipeline-failures",
|
||||
"pipeline-success",
|
||||
"usage-alerts",
|
||||
"weekly-digest",
|
||||
"security-alerts",
|
||||
"product-updates",
|
||||
] as const;
|
||||
|
||||
const SECTION_LABEL: Record<SettingsSection, string> = {
|
||||
profile: "Profile",
|
||||
appearance: "Appearance",
|
||||
notifications: "Notifications",
|
||||
general: "General",
|
||||
authentication: "Authentication",
|
||||
sessions: "Active sessions",
|
||||
"early-access": "Early access",
|
||||
};
|
||||
|
||||
const NAV_SECTIONS: SettingsNavSection[] = [
|
||||
{
|
||||
title: "Account",
|
||||
items: [
|
||||
{ key: "profile", label: "Profile", icon: <UsersIcon size={16} /> },
|
||||
{ key: "appearance", label: "Appearance", icon: <SunIcon size={16} /> },
|
||||
{
|
||||
key: "notifications",
|
||||
label: "Notifications",
|
||||
icon: <BellIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ key: "general", label: "General", icon: <SettingsIcon size={16} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Admin",
|
||||
items: [
|
||||
{
|
||||
key: "authentication",
|
||||
label: "Authentication",
|
||||
icon: <PoliciesIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
label: "Active sessions",
|
||||
icon: <InfrastructureIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "early-access",
|
||||
label: "Early access",
|
||||
icon: <SparklesIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
const THEME_OPTIONS: { value: Theme }[] = [
|
||||
{ value: "light" },
|
||||
{ value: "dark" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [
|
||||
{ value: "light", label: "Light", hint: "Bright surfaces" },
|
||||
{ value: "dark", label: "Dark", hint: "Dim surfaces" },
|
||||
];
|
||||
|
||||
const SESSION_TIMEOUT_OPTIONS: SelectOption[] = [
|
||||
{ value: "60", label: "1 hour" },
|
||||
{ value: "240", label: "4 hours" },
|
||||
{ value: "480", label: "8 hours" },
|
||||
{ value: "720", label: "12 hours" },
|
||||
{ value: "1440", label: "24 hours" },
|
||||
];
|
||||
const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const;
|
||||
|
||||
/**
|
||||
* Account settings as a portal-wide overlay. A grouped left-nav (Account /
|
||||
@@ -157,10 +84,67 @@ const SESSION_TIMEOUT_OPTIONS: SelectOption[] = [
|
||||
* writes straight through to ThemeProvider so the change is real and visible.
|
||||
*/
|
||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [section, setSection] = useState<SettingsSection>("profile");
|
||||
|
||||
const navSections = useMemo<SettingsNavSection[]>(
|
||||
() => [
|
||||
{
|
||||
title: t("settings.groups.account"),
|
||||
items: [
|
||||
{
|
||||
key: "profile",
|
||||
label: t("settings.sections.profile"),
|
||||
icon: <UsersIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "appearance",
|
||||
label: t("settings.sections.appearance"),
|
||||
icon: <SunIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: t("settings.sections.notifications"),
|
||||
icon: <BellIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.groups.workspace"),
|
||||
items: [
|
||||
{
|
||||
key: "general",
|
||||
label: t("settings.sections.general"),
|
||||
icon: <SettingsIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.groups.admin"),
|
||||
items: [
|
||||
{
|
||||
key: "authentication",
|
||||
label: t("settings.sections.authentication"),
|
||||
icon: <PoliciesIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
label: t("settings.sections.sessions"),
|
||||
icon: <InfrastructureIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "early-access",
|
||||
label: t("settings.sections.early-access"),
|
||||
icon: <SparklesIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const { data: snapshot, loading } = useAsync<SettingsSnapshot>(
|
||||
() => fetchSettings(tier),
|
||||
[tier],
|
||||
@@ -214,11 +198,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
value: r.value,
|
||||
label:
|
||||
r.enterpriseOnly && tier !== "enterprise"
|
||||
? `${r.label} · Enterprise`
|
||||
? t("settings.workspace.regionEnterpriseSuffix", { region: r.label })
|
||||
: r.label,
|
||||
disabled: r.enterpriseOnly && tier !== "enterprise",
|
||||
}));
|
||||
}, [snapshot, tier]);
|
||||
}, [snapshot, tier, t]);
|
||||
|
||||
const isLoading = loading && !snapshot;
|
||||
|
||||
@@ -227,25 +211,25 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
ariaLabel="Settings"
|
||||
ariaLabel={t("settings.ariaLabel")}
|
||||
className="portal-settings"
|
||||
>
|
||||
<SettingsShell
|
||||
sections={NAV_SECTIONS}
|
||||
sections={navSections}
|
||||
activeKey={section}
|
||||
onSelect={(k) => setSection(k as SettingsSection)}
|
||||
title={SECTION_LABEL[section]}
|
||||
title={t(`settings.sections.${section}`)}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<span className="portal-settings__footer-note">
|
||||
Changes apply to this workspace.
|
||||
{t("settings.footerNote")}
|
||||
</span>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
{t("settings.cancel")}
|
||||
</Button>
|
||||
<Button variant="gradient" onClick={onClose}>
|
||||
Save changes
|
||||
{t("settings.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -343,6 +327,7 @@ function ProfilePanel({
|
||||
onName: (v: string) => void;
|
||||
onEmail: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
@@ -364,13 +349,13 @@ function ProfilePanel({
|
||||
<div className="portal-settings__identity">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={name || "Account"}
|
||||
name={name || t("settings.profile.accountFallback")}
|
||||
size="lg"
|
||||
tone="blue"
|
||||
/>
|
||||
<div className="portal-settings__identity-meta">
|
||||
<div className="portal-settings__identity-name">
|
||||
{name || "Account"}
|
||||
{name || t("settings.profile.accountFallback")}
|
||||
{role && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{role}
|
||||
@@ -380,27 +365,27 @@ function ProfilePanel({
|
||||
<span className="portal-settings__identity-email">{email}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Change photo
|
||||
{t("settings.profile.changePhoto")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormField label="Full name">
|
||||
<FormField label={t("settings.profile.fullName")}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
placeholder={t("settings.profile.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
helperText="Used for sign-in and notification delivery."
|
||||
label={t("settings.profile.email")}
|
||||
helperText={t("settings.profile.emailHelper")}
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => onEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
placeholder={t("settings.profile.emailPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -418,19 +403,22 @@ function AppearancePanel({
|
||||
theme: Theme;
|
||||
onTheme: (theme: Theme) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Theme</h3>
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("settings.appearance.themeTitle")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Choose how the portal looks on this device.
|
||||
{t("settings.appearance.themeSub")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="portal-settings__theme"
|
||||
role="radiogroup"
|
||||
aria-label="Theme"
|
||||
aria-label={t("settings.appearance.themeTitle")}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -452,8 +440,8 @@ function AppearancePanel({
|
||||
<span />
|
||||
</span>
|
||||
<span className="portal-settings__theme-text">
|
||||
<strong>{opt.label}</strong>
|
||||
<span>{opt.hint}</span>
|
||||
<strong>{t(`settings.appearance.${opt.value}.label`)}</strong>
|
||||
<span>{t(`settings.appearance.${opt.value}.hint`)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -478,13 +466,16 @@ function NotificationsPanel({
|
||||
order: string[];
|
||||
onToggle: (id: string, value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Email notifications</h3>
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("settings.notifications.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Pick which events reach your inbox.
|
||||
{t("settings.notifications.sub")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -505,13 +496,14 @@ function NotificationsPanel({
|
||||
{!loading && (
|
||||
<div className="portal-settings__notifs">
|
||||
{order.map((id) => {
|
||||
const copy = NOTIFICATION_COPY[id];
|
||||
if (!copy) return null;
|
||||
if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={id} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>{copy.label}</strong>
|
||||
<span>{copy.description}</span>
|
||||
<strong>{t(`settings.notifications.${id}.label`)}</strong>
|
||||
<span>{t(`settings.notifications.${id}.description`)}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={notifications[id] ?? false}
|
||||
@@ -550,6 +542,7 @@ function WorkspacePanel({
|
||||
planLabel?: string;
|
||||
seats?: { used: number; total: number };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
@@ -562,17 +555,17 @@ function WorkspacePanel({
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<FormField label="Workspace name">
|
||||
<FormField label={t("settings.workspace.nameLabel")}>
|
||||
<Input
|
||||
value={workspaceName}
|
||||
onChange={(e) => onWorkspaceName(e.target.value)}
|
||||
placeholder="Workspace name"
|
||||
placeholder={t("settings.workspace.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Data residency region"
|
||||
helperText="Where documents are processed and stored at rest."
|
||||
label={t("settings.workspace.regionLabel")}
|
||||
helperText={t("settings.workspace.regionHelper")}
|
||||
>
|
||||
<Select
|
||||
value={region}
|
||||
@@ -583,21 +576,28 @@ function WorkspacePanel({
|
||||
|
||||
<div className="portal-settings__plan">
|
||||
<div className="portal-settings__plan-row">
|
||||
<span className="portal-settings__plan-label">Plan</span>
|
||||
<span className="portal-settings__plan-label">
|
||||
{t("settings.workspace.plan")}
|
||||
</span>
|
||||
<StatusBadge tone="purple" size="sm">
|
||||
{planLabel ?? "—"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{seats && (
|
||||
<div className="portal-settings__plan-row">
|
||||
<span className="portal-settings__plan-label">Seats</span>
|
||||
<span className="portal-settings__plan-label">
|
||||
{t("settings.workspace.seats")}
|
||||
</span>
|
||||
<span className="portal-settings__plan-value">
|
||||
{seats.used} of {seats.total} used
|
||||
{t("settings.workspace.seatsUsed", {
|
||||
used: seats.used,
|
||||
total: seats.total,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Manage billing
|
||||
{t("settings.workspace.manageBilling")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,6 +619,7 @@ function AuthenticationPanel({
|
||||
security: SecurityForm;
|
||||
onSecurity: (patch: Partial<SecurityForm>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
@@ -637,17 +638,19 @@ function AuthenticationPanel({
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Sign-in policy</h3>
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("settings.authentication.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Organisation-wide authentication controls.
|
||||
{t("settings.authentication.sub")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notifs">
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>Enforce two-factor (MFA)</strong>
|
||||
<span>Require every member to complete MFA at sign-in.</span>
|
||||
<strong>{t("settings.authentication.mfa.label")}</strong>
|
||||
<span>{t("settings.authentication.mfa.description")}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={security.mfaEnforced}
|
||||
@@ -658,14 +661,14 @@ function AuthenticationPanel({
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>Single sign-on (SAML)</strong>
|
||||
<strong>{t("settings.authentication.sso.label")}</strong>
|
||||
{!isEnterprise && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
Enterprise
|
||||
{t("settings.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>Federate sign-in through your identity provider.</span>
|
||||
<span>{t("settings.authentication.sso.description")}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isEnterprise && security.ssoEnabled}
|
||||
@@ -677,14 +680,14 @@ function AuthenticationPanel({
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>SCIM provisioning</strong>
|
||||
<strong>{t("settings.authentication.scim.label")}</strong>
|
||||
{!isEnterprise && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
Enterprise
|
||||
{t("settings.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>Sync members and roles from your directory.</span>
|
||||
<span>{t("settings.authentication.scim.description")}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isEnterprise && security.scimEnabled}
|
||||
@@ -695,15 +698,18 @@ function AuthenticationPanel({
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Session timeout"
|
||||
helperText="Members re-authenticate after this idle period."
|
||||
label={t("settings.authentication.sessionTimeout")}
|
||||
helperText={t("settings.authentication.sessionTimeoutHelper")}
|
||||
>
|
||||
<Select
|
||||
value={String(security.sessionTimeoutMins)}
|
||||
onChange={(e) =>
|
||||
onSecurity({ sessionTimeoutMins: Number(e.target.value) })
|
||||
}
|
||||
options={SESSION_TIMEOUT_OPTIONS}
|
||||
options={SESSION_TIMEOUT_VALUES.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.authentication.timeout.${value}`),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -722,6 +728,7 @@ function SessionsPanel({
|
||||
loading: boolean;
|
||||
sessions: ActiveSession[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
@@ -735,9 +742,11 @@ function SessionsPanel({
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Active sessions</h3>
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("settings.sessions.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Devices currently signed in to this account.
|
||||
{t("settings.sessions.sub")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-settings__notifs">
|
||||
@@ -751,12 +760,12 @@ function SessionsPanel({
|
||||
</div>
|
||||
{s.current ? (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
This device
|
||||
{t("settings.sessions.thisDevice")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
// TODO(backend): DELETE /v1/settings/sessions/{id}
|
||||
<Button variant="ghost" size="sm">
|
||||
Revoke
|
||||
{t("settings.sessions.revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -784,6 +793,7 @@ function EarlyAccessPanel({
|
||||
betaToggles: Record<string, boolean>;
|
||||
onBeta: (id: string, value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
@@ -799,9 +809,11 @@ function EarlyAccessPanel({
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Preview features</h3>
|
||||
<h3 className="portal-settings__group-title">
|
||||
{t("settings.earlyAccess.title")}
|
||||
</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Opt into features still in preview.
|
||||
{t("settings.earlyAccess.sub")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-settings__notifs">
|
||||
@@ -814,7 +826,7 @@ function EarlyAccessPanel({
|
||||
<strong>{f.label}</strong>
|
||||
{locked && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
Enterprise
|
||||
{t("settings.enterpriseBadge")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dropdown, NavItem } from "@shared/components";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
@@ -30,35 +31,29 @@ const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
|
||||
|
||||
interface NavEntry {
|
||||
id: ViewId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const GROUP_PRIMARY: NavEntry[] = [
|
||||
{ id: "home", label: "Home", icon: <HomeIcon /> },
|
||||
];
|
||||
const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: <HomeIcon /> }];
|
||||
|
||||
const GROUP_OPERATIONAL: NavEntry[] = [
|
||||
{ id: "users", label: "Users", icon: <UsersIcon /> },
|
||||
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
|
||||
{ id: "policies", label: "Policies", icon: <PoliciesIcon /> },
|
||||
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
|
||||
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
|
||||
{ id: "components", label: "Components", icon: <ComponentsIcon /> },
|
||||
{ id: "users", icon: <UsersIcon /> },
|
||||
{ id: "sources", icon: <SourcesIcon /> },
|
||||
{ id: "policies", icon: <PoliciesIcon /> },
|
||||
{ id: "pipelines", icon: <PipelinesIcon /> },
|
||||
{ id: "documents", icon: <DocumentsIcon /> },
|
||||
{ id: "components", icon: <ComponentsIcon /> },
|
||||
];
|
||||
|
||||
const GROUP_PLATFORM: NavEntry[] = [
|
||||
{
|
||||
id: "infrastructure",
|
||||
label: "Infrastructure",
|
||||
icon: <InfrastructureIcon />,
|
||||
},
|
||||
{ id: "usage", label: "Usage & Billing", icon: <UsageIcon /> },
|
||||
{ id: "docs", label: "Developer Docs", icon: <DocsIcon /> },
|
||||
{ id: "infrastructure", icon: <InfrastructureIcon /> },
|
||||
{ id: "usage", icon: <UsageIcon /> },
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
];
|
||||
|
||||
function UsageFooter() {
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
// Read the same endpoint Home's KPI strip uses so the doc count here can't
|
||||
// drift from the headline figure. The first KPI is always the doc total.
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
@@ -77,7 +72,9 @@ function UsageFooter() {
|
||||
return (
|
||||
<div className="portal-sidebar__usage portal-sidebar__usage--free">
|
||||
<div className="portal-sidebar__usage-line">
|
||||
<span className="portal-sidebar__usage-label">Docs processed</span>
|
||||
<span className="portal-sidebar__usage-label">
|
||||
{t("shell.sidebar.docsProcessed")}
|
||||
</span>
|
||||
<span className="portal-sidebar__usage-value">{docs ?? "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -95,7 +92,10 @@ function UsageFooter() {
|
||||
);
|
||||
}
|
||||
|
||||
const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
|
||||
const planLabel =
|
||||
tier === "pro"
|
||||
? t("shell.sidebar.planPayAsYouGo")
|
||||
: t("shell.sidebar.planEnterprise");
|
||||
|
||||
return (
|
||||
<div className="portal-sidebar__usage">
|
||||
@@ -105,7 +105,7 @@ function UsageFooter() {
|
||||
{planLabel}
|
||||
</span>
|
||||
<span className="portal-sidebar__usage-value">
|
||||
{docs != null ? `${docs} docs` : "—"}
|
||||
{docs != null ? t("shell.sidebar.docsCount", { docs }) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,13 +116,14 @@ export function Sidebar() {
|
||||
const { activeView, setActiveView } = useView();
|
||||
const { theme } = useTheme();
|
||||
const { openSettings } = useUI();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function renderGroup(entries: NavEntry[]) {
|
||||
return entries.map((entry) => (
|
||||
<NavItem
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
label={entry.label}
|
||||
label={t(`nav.${entry.id}`)}
|
||||
icon={entry.icon}
|
||||
isActive={activeView === entry.id}
|
||||
onClick={(id) => setActiveView(id as ViewId)}
|
||||
@@ -131,7 +132,10 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="portal-sidebar" aria-label="Primary navigation">
|
||||
<aside
|
||||
className="portal-sidebar"
|
||||
aria-label={t("shell.sidebar.primaryNav")}
|
||||
>
|
||||
<div className="portal-sidebar__logo">
|
||||
<span className="portal-sidebar__brand">
|
||||
<img
|
||||
@@ -140,7 +144,7 @@ export function Sidebar() {
|
||||
alt="Stirling"
|
||||
/>
|
||||
<span className="portal-sidebar__logo-suffix">
|
||||
Stirling Processor
|
||||
{t("shell.sidebar.brandSuffix")}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -149,7 +153,7 @@ export function Sidebar() {
|
||||
<button
|
||||
type="button"
|
||||
className="portal-sidebar__app-switch-btn"
|
||||
aria-label="Switch app"
|
||||
aria-label={t("shell.sidebar.switchApp")}
|
||||
>
|
||||
<ChevronDownIcon size={14} />
|
||||
</button>
|
||||
@@ -165,7 +169,7 @@ export function Sidebar() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
Processor
|
||||
{t("shell.sidebar.appProcessor")}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onSelect={() => {
|
||||
@@ -179,7 +183,7 @@ export function Sidebar() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
Editor
|
||||
{t("shell.sidebar.appEditor")}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
@@ -202,7 +206,7 @@ export function Sidebar() {
|
||||
<div className="portal-sidebar__footer">
|
||||
<NavItem
|
||||
id="settings"
|
||||
label="Settings"
|
||||
label={t("nav.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -44,6 +45,7 @@ export function SingleOpRunner({
|
||||
onClose,
|
||||
initialOpId,
|
||||
}: SingleOpRunnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const opsState = useAsync<FeaturedOp[]>(() => fetchFeaturedOps(), []);
|
||||
const { data: ops } = opsState;
|
||||
@@ -143,8 +145,8 @@ export function SingleOpRunner({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
title="Try a PDF operation"
|
||||
subtitle="Drop a sample, pick an op, see what Stirling returns."
|
||||
title={t("opRunner.title")}
|
||||
subtitle={t("opRunner.subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<div className="portal-runner__footer-status">
|
||||
@@ -161,24 +163,24 @@ export function SingleOpRunner({
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{errorMsg ?? "Failed"}
|
||||
{errorMsg ?? t("opRunner.status.failed")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
{t("opRunner.action.close")}
|
||||
</Button>
|
||||
{phase === "done" ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={reset}>
|
||||
Run again
|
||||
{t("opRunner.action.runAgain")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={buildPipelineWithOp}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
Open the pipeline builder
|
||||
{t("opRunner.action.openBuilder")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -188,7 +190,9 @@ export function SingleOpRunner({
|
||||
disabled={phase === "running" || !selectedOp}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{phase === "running" ? "Running…" : "Run operation"}
|
||||
{phase === "running"
|
||||
? t("opRunner.action.running")
|
||||
: t("opRunner.action.run")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -226,12 +230,12 @@ export function SingleOpRunner({
|
||||
{sample ? (
|
||||
<>
|
||||
<strong>{sample}</strong>
|
||||
<span>Drop again or pick another sample to replace.</span>
|
||||
<span>{t("opRunner.drop.replaceHint")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Drop a PDF here</strong>
|
||||
<span>or use a sample document.</span>
|
||||
<strong>{t("opRunner.drop.title")}</strong>
|
||||
<span>{t("opRunner.drop.hint")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,12 +244,16 @@ export function SingleOpRunner({
|
||||
className="portal-runner__sample-btn"
|
||||
onClick={pickSample}
|
||||
>
|
||||
{sample ? "Pick another sample" : "Use a sample"}
|
||||
{sample
|
||||
? t("opRunner.drop.pickAnother")
|
||||
: t("opRunner.drop.useSample")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="portal-runner__section-title">Featured ops</div>
|
||||
<div className="portal-runner__section-title">
|
||||
{t("opRunner.featuredOps")}
|
||||
</div>
|
||||
<div className="portal-runner__ops">
|
||||
{opsIsLoading &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
@@ -262,8 +270,8 @@ export function SingleOpRunner({
|
||||
{opsIsEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No featured ops yet"
|
||||
description="Once operations are published, they'll show up here."
|
||||
title={t("opRunner.empty.title")}
|
||||
description={t("opRunner.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{ops?.map((op) => (
|
||||
@@ -298,13 +306,19 @@ export function SingleOpRunner({
|
||||
<section className="portal-runner__right">
|
||||
{phase === "idle" && selectedOp && (
|
||||
<div className="portal-runner__hint">
|
||||
<div className="portal-runner__hint-eyebrow">Ready</div>
|
||||
<div className="portal-runner__hint-eyebrow">
|
||||
{t("opRunner.hint.ready")}
|
||||
</div>
|
||||
<h3>
|
||||
Run <code>{selectedOp.label}</code> on{" "}
|
||||
<code>{sample ?? "a sample"}</code>
|
||||
{t("opRunner.hint.runOn.before")}{" "}
|
||||
<code>{selectedOp.label}</code>{" "}
|
||||
{t("opRunner.hint.runOn.middle")}{" "}
|
||||
<code>{sample ?? t("opRunner.hint.aSample")}</code>
|
||||
</h3>
|
||||
<p>
|
||||
{selectedOp.blurb}. Press <kbd>Run operation</kbd> to invoke{" "}
|
||||
{selectedOp.blurb}. {t("opRunner.hint.press")}{" "}
|
||||
<kbd>{t("opRunner.action.run")}</kbd>{" "}
|
||||
{t("opRunner.hint.toInvoke")}{" "}
|
||||
<code>POST {selectedOp.endpoint}</code>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -314,7 +328,7 @@ export function SingleOpRunner({
|
||||
<div className="portal-runner__spinner-lg" aria-hidden />
|
||||
<div className="portal-runner__running-text">
|
||||
<div className="portal-runner__running-title">
|
||||
Running {selectedOp.label}…
|
||||
{t("opRunner.running.title", { label: selectedOp.label })}
|
||||
</div>
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
</div>
|
||||
@@ -324,11 +338,11 @@ export function SingleOpRunner({
|
||||
<div className="portal-runner__result">
|
||||
<header className="portal-runner__result-head">
|
||||
<StatusBadge tone="success" size="sm">
|
||||
Completed
|
||||
{t("opRunner.status.completed")}
|
||||
</StatusBadge>
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
<span className="portal-runner__result-meta">
|
||||
{runResult.durationMs} ms
|
||||
{t("opRunner.durationMs", { ms: runResult.durationMs })}
|
||||
</span>
|
||||
</header>
|
||||
<pre className="portal-runner__result-code">
|
||||
@@ -342,10 +356,10 @@ export function SingleOpRunner({
|
||||
className="portal-runner__hint-eyebrow"
|
||||
style={{ color: "var(--color-red)" }}
|
||||
>
|
||||
Failed
|
||||
{t("opRunner.status.failed")}
|
||||
</div>
|
||||
<h3>The operation didn’t complete</h3>
|
||||
<p>{errorMsg ?? "Unknown error"}</p>
|
||||
<h3>{t("opRunner.error.title")}</h3>
|
||||
<p>{errorMsg ?? t("opRunner.error.unknown")}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UsagePoint } from "@portal/api/home";
|
||||
import "@portal/components/UsageAreaChart.css";
|
||||
|
||||
@@ -29,10 +30,12 @@ function formatNumber(value: number): string {
|
||||
|
||||
export function UsageAreaChart({
|
||||
data,
|
||||
totalLabel = "Docs processed · last 30 days",
|
||||
totalLabel: totalLabelProp,
|
||||
totalValue,
|
||||
deltaPct,
|
||||
}: UsageAreaChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalLabel = totalLabelProp ?? t("usageChart.defaultLabel");
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
@@ -150,7 +153,9 @@ export function UsageAreaChart({
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
|
||||
{Math.abs(Math.round(deltaPct * 100))}% vs prior 30d
|
||||
{t("usageChart.delta", {
|
||||
pct: Math.abs(Math.round(deltaPct * 100)),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -260,17 +265,22 @@ export function UsageAreaChart({
|
||||
})}
|
||||
</div>
|
||||
<div className="portal-chart__tooltip-value">
|
||||
{hovered.raw.value.toLocaleString()} docs
|
||||
{t("usageChart.docsValue", {
|
||||
value: hovered.raw.value.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{hovered
|
||||
? `${new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}: ${hovered.raw.value.toLocaleString()} docs`
|
||||
? t("usageChart.srAnnounce", {
|
||||
date: new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
value: hovered.raw.value.toLocaleString(),
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatusBadge } from "@shared/components";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import "@portal/components/WelcomeCarousel.css";
|
||||
|
||||
type SlideAction =
|
||||
| { label: string; target: ViewId }
|
||||
| { label: string; action: "try-op" };
|
||||
| { labelKey: string; target: ViewId }
|
||||
| { labelKey: string; action: "try-op" };
|
||||
|
||||
interface Slide {
|
||||
id: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
sub: string;
|
||||
durationMs: number;
|
||||
primary: SlideAction;
|
||||
secondary: SlideAction;
|
||||
@@ -19,21 +17,22 @@ interface Slide {
|
||||
}
|
||||
|
||||
function EditorOrnament() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-carousel__doc">
|
||||
<StatusBadge tone="danger" size="sm" pulse>
|
||||
Critical
|
||||
{t("welcome.ornament.editor.critical")}
|
||||
</StatusBadge>
|
||||
<div className="portal-carousel__doc-title">
|
||||
Vulnerability Assessment Report
|
||||
</div>
|
||||
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
|
||||
<div className="portal-carousel__doc-meta">
|
||||
<span>signed</span>
|
||||
<span>{t("welcome.ornament.editor.signed")}</span>
|
||||
<span>·</span>
|
||||
<span>OCR-clean</span>
|
||||
<span>{t("welcome.ornament.editor.ocrClean")}</span>
|
||||
<span>·</span>
|
||||
<span>schema match 0.97</span>
|
||||
<span>{t("welcome.ornament.editor.schemaMatch")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -88,32 +87,29 @@ function AgentOrnament() {
|
||||
const SLIDES: Slide[] = [
|
||||
{
|
||||
id: "editor",
|
||||
eyebrow: "PDF Editor",
|
||||
title: "The #1 PDF Editor on GitHub",
|
||||
sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.",
|
||||
durationMs: 12000,
|
||||
primary: { label: "Install PDF Editor", target: "editor" },
|
||||
secondary: { label: "Connect an instance", target: "editor" },
|
||||
primary: { labelKey: "welcome.slides.editor.primary", target: "editor" },
|
||||
secondary: {
|
||||
labelKey: "welcome.slides.editor.secondary",
|
||||
target: "editor",
|
||||
},
|
||||
ornament: <EditorOrnament />,
|
||||
},
|
||||
{
|
||||
id: "platform",
|
||||
eyebrow: "Platform",
|
||||
title: "PDF Infrastructure for Developers",
|
||||
sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.",
|
||||
durationMs: 8000,
|
||||
primary: { label: "Try a PDF operation", action: "try-op" },
|
||||
secondary: { label: "Get an API key", target: "infrastructure" },
|
||||
primary: { labelKey: "welcome.slides.platform.primary", action: "try-op" },
|
||||
secondary: {
|
||||
labelKey: "welcome.slides.platform.secondary",
|
||||
target: "infrastructure",
|
||||
},
|
||||
ornament: <PlatformOrnament />,
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
eyebrow: "AI Agents",
|
||||
title: "PDF Processor for AI Agents",
|
||||
sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.",
|
||||
durationMs: 8000,
|
||||
primary: { label: "Try PDF Processor", target: "sources" },
|
||||
secondary: { label: "View MCP docs", target: "docs" },
|
||||
primary: { labelKey: "welcome.slides.agents.primary", target: "sources" },
|
||||
secondary: { labelKey: "welcome.slides.agents.secondary", target: "docs" },
|
||||
ornament: <AgentOrnament />,
|
||||
},
|
||||
];
|
||||
@@ -124,6 +120,7 @@ interface WelcomeCarouselProps {
|
||||
}
|
||||
|
||||
export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const { setActiveView } = useView();
|
||||
@@ -160,27 +157,33 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
|
||||
setPaused(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Stirling product highlights"
|
||||
aria-label={t("welcome.ariaLabel")}
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div className="portal-carousel__inner" key={slide.id}>
|
||||
<div className="portal-carousel__text">
|
||||
<div className="portal-carousel__eyebrow">{slide.eyebrow}</div>
|
||||
<h1 className="portal-carousel__title">{slide.title}</h1>
|
||||
<p className="portal-carousel__sub">{slide.sub}</p>
|
||||
<div className="portal-carousel__eyebrow">
|
||||
{t(`welcome.slides.${slide.id}.eyebrow`)}
|
||||
</div>
|
||||
<h1 className="portal-carousel__title">
|
||||
{t(`welcome.slides.${slide.id}.title`)}
|
||||
</h1>
|
||||
<p className="portal-carousel__sub">
|
||||
{t(`welcome.slides.${slide.id}.sub`)}
|
||||
</p>
|
||||
<div className="portal-carousel__cta">
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => runAction(slide.primary)}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{slide.primary.label}
|
||||
{t(slide.primary.labelKey)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runAction(slide.secondary)}
|
||||
>
|
||||
{slide.secondary.label}
|
||||
{t(slide.secondary.labelKey)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,14 +198,17 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
|
||||
<div
|
||||
className="portal-carousel__dots"
|
||||
role="group"
|
||||
aria-label="Carousel pagination"
|
||||
aria-label={t("welcome.pagination")}
|
||||
>
|
||||
{SLIDES.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
aria-current={i === index ? "true" : undefined}
|
||||
aria-label={`Slide ${i + 1}: ${s.title}`}
|
||||
aria-label={t("welcome.slideLabel", {
|
||||
number: i + 1,
|
||||
title: t(`welcome.slides.${s.id}.title`),
|
||||
})}
|
||||
className={
|
||||
"portal-carousel__dot" + (i === index ? " is-active" : "")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Tabs, type TabItem } from "@shared/components";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
@@ -20,17 +21,26 @@ export function AgentBuilderPanel({
|
||||
agent,
|
||||
governanceUnlocked,
|
||||
}: AgentBuilderPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<BuilderTab>("scenarios");
|
||||
|
||||
const tabs: TabItem<BuilderTab>[] = [
|
||||
{ key: "scenarios", label: "Scenarios", count: agent.scenarios.length },
|
||||
{ key: "tools", label: "Tools" },
|
||||
{
|
||||
key: "scenarios",
|
||||
label: t("agentBuilder.tabs.scenarios"),
|
||||
count: agent.scenarios.length,
|
||||
},
|
||||
{ key: "tools", label: t("agentBuilder.tabs.tools") },
|
||||
{
|
||||
key: "evals",
|
||||
label: "Evals",
|
||||
label: t("agentBuilder.tabs.evals"),
|
||||
count: agent.evalsTotal > 0 ? agent.evalsTotal : undefined,
|
||||
},
|
||||
{ key: "versions", label: "Versions", count: agent.versions.length },
|
||||
{
|
||||
key: "versions",
|
||||
label: t("agentBuilder.tabs.versions"),
|
||||
count: agent.versions.length,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -56,7 +66,7 @@ export function AgentBuilderPanel({
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Agent builder sections"
|
||||
ariaLabel={t("agentBuilder.sectionsAriaLabel")}
|
||||
/>
|
||||
|
||||
{tab === "scenarios" && <ScenariosPanel agent={agent} />}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { AgentsSummary } from "@portal/api/agents";
|
||||
|
||||
/**
|
||||
* KPI labels are product copy — they describe what each metric IS, not its
|
||||
* value — so the strip's structure stays stable across loading / ready states;
|
||||
* only values flow from the API.
|
||||
*/
|
||||
const KPI_LABELS = [
|
||||
"Active agents",
|
||||
"Avg eval pass rate",
|
||||
"Scenarios",
|
||||
"Latest published",
|
||||
] as const;
|
||||
|
||||
interface AgentKpiStripProps {
|
||||
summary: AgentsSummary | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function AgentKpiStrip({ summary, loading }: AgentKpiStripProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* KPI labels are product copy — they describe what each metric IS, not its
|
||||
* value — so the strip's structure stays stable across loading / ready
|
||||
* states; only values flow from the API.
|
||||
*/
|
||||
const kpiLabels = [
|
||||
t("agentBuilder.kpi.activeAgents"),
|
||||
t("agentBuilder.kpi.avgPassRate"),
|
||||
t("agentBuilder.kpi.scenarios"),
|
||||
t("agentBuilder.kpi.latestPublished"),
|
||||
] as const;
|
||||
|
||||
const values: (string | number)[] = summary
|
||||
? [
|
||||
summary.activeAgents,
|
||||
@@ -30,16 +33,16 @@ export function AgentKpiStrip({ summary, loading }: AgentKpiStripProps) {
|
||||
|
||||
const descriptions: (string | undefined)[] = summary
|
||||
? [
|
||||
`${summary.totalAgents} total`,
|
||||
"across golden sets",
|
||||
"test cases",
|
||||
"fleet-wide",
|
||||
t("agentBuilder.kpi.totalDescription", { count: summary.totalAgents }),
|
||||
t("agentBuilder.kpi.acrossGoldenSets"),
|
||||
t("agentBuilder.kpi.testCases"),
|
||||
t("agentBuilder.kpi.fleetWide"),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABELS.map((label, i) => (
|
||||
{kpiLabels.map((label, i) => (
|
||||
<MetricCard
|
||||
key={label}
|
||||
label={label}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import { StatusBadge } from "@shared/components";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
@@ -14,8 +15,12 @@ export function AgentSelector({
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: AgentSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav className="portal-agents__selector" aria-label="Agents">
|
||||
<nav
|
||||
className="portal-agents__selector"
|
||||
aria-label={t("agentBuilder.selectorAriaLabel")}
|
||||
>
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@shared/components";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
@@ -13,6 +14,7 @@ interface BootstrapDialogProps {
|
||||
* it captures the chosen file name locally and closes without provisioning.
|
||||
*/
|
||||
export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
function close() {
|
||||
@@ -31,24 +33,22 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="md"
|
||||
title="Bootstrap from a document"
|
||||
subtitle="Seed a new agent from one representative file"
|
||||
title={t("agentBuilder.bootstrap.title")}
|
||||
subtitle={t("agentBuilder.bootstrap.subtitle")}
|
||||
footer={
|
||||
<div className="portal-agents__dialog-footer">
|
||||
<Button variant="ghost" size="sm" onClick={close}>
|
||||
Cancel
|
||||
{t("agentBuilder.bootstrap.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={bootstrap} disabled={!fileName}>
|
||||
Bootstrap agent
|
||||
{t("agentBuilder.bootstrap.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-agents__bootstrap">
|
||||
<p className="portal-agents__bootstrap-lead">
|
||||
Drop a sample document and we'll propose scenarios and an
|
||||
extraction schema you can refine. Nothing is published until you
|
||||
review it.
|
||||
{t("agentBuilder.bootstrap.lead")}
|
||||
</p>
|
||||
<label className="portal-agents__dropzone">
|
||||
<input
|
||||
@@ -61,7 +61,7 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
|
||||
⇪
|
||||
</span>
|
||||
<span className="portal-agents__dropzone-text">
|
||||
{fileName ?? "Choose a sample document (PDF or image)"}
|
||||
{fileName ?? t("agentBuilder.bootstrap.dropzoneText")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -14,38 +15,50 @@ interface EvalsPanelProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<EvalCase>[] = [
|
||||
{ key: "name", header: "Eval case", render: (c) => c.name },
|
||||
{
|
||||
key: "result",
|
||||
header: "Result",
|
||||
render: (c) =>
|
||||
c.passing === null ? (
|
||||
<span className="portal-agents__muted">not run</span>
|
||||
) : (
|
||||
<StatusBadge tone={c.passing ? "success" : "danger"} size="sm">
|
||||
{c.passing ? "pass" : "fail"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: "Latency",
|
||||
align: "right",
|
||||
render: (c) => (
|
||||
<span className="portal-agents__mono">{c.latencyMs} ms</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Golden-set pass-rate, the per-case results table, and a run affordance. */
|
||||
export function EvalsPanel({ agent }: EvalsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: TableColumn<EvalCase>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: t("agentBuilder.evals.columnCase"),
|
||||
render: (c) => c.name,
|
||||
},
|
||||
{
|
||||
key: "result",
|
||||
header: t("agentBuilder.evals.columnResult"),
|
||||
render: (c) =>
|
||||
c.passing === null ? (
|
||||
<span className="portal-agents__muted">
|
||||
{t("agentBuilder.evals.notRun")}
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge tone={c.passing ? "success" : "danger"} size="sm">
|
||||
{c.passing
|
||||
? t("agentBuilder.evals.pass")
|
||||
: t("agentBuilder.evals.fail")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: t("agentBuilder.evals.columnLatency"),
|
||||
align: "right",
|
||||
render: (c) => (
|
||||
<span className="portal-agents__mono">
|
||||
{t("agentBuilder.evals.latencyMs", { ms: c.latencyMs })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (agent.evalsTotal === 0) {
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<EmptyState
|
||||
title="No golden set yet"
|
||||
description="Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it."
|
||||
title={t("agentBuilder.evals.empty.title")}
|
||||
description={t("agentBuilder.evals.empty.description")}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
@@ -64,34 +77,34 @@ export function EvalsPanel({ agent }: EvalsPanelProps) {
|
||||
<div className="portal-agents__eval-head">
|
||||
<div className="portal-agents__stat-grid portal-agents__stat-grid--two">
|
||||
<StatTile
|
||||
label="Pass rate"
|
||||
label={t("agentBuilder.evals.passRate")}
|
||||
value={`${Math.round(rate * 100)}%`}
|
||||
tone={rate >= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Cases passing"
|
||||
label={t("agentBuilder.evals.casesPassing")}
|
||||
value={`${agent.evalsPassing} / ${agent.evalsTotal}`}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={runEvals}>
|
||||
Run evals
|
||||
{t("agentBuilder.evals.runEvals")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="portal-agents__bar-row">
|
||||
<div className="portal-agents__bar-head">
|
||||
<span>Golden-set pass rate</span>
|
||||
<span>{t("agentBuilder.evals.goldenSetPassRate")}</span>
|
||||
<strong>{Math.round(rate * 100)}%</strong>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={rate}
|
||||
color={rate >= 0.95 ? "var(--color-green)" : "var(--color-amber)"}
|
||||
label="Golden-set pass rate"
|
||||
label={t("agentBuilder.evals.goldenSetPassRate")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<EvalCase>
|
||||
columns={COLUMNS}
|
||||
columns={columns}
|
||||
rows={agent.evalCases}
|
||||
rowKey={(c) => c.id}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
@@ -19,6 +20,7 @@ interface ScenariosPanelProps {
|
||||
* the submit endpoint exists.
|
||||
*/
|
||||
export function ScenariosPanel({ agent }: ScenariosPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
// Seed from the agent and re-seed when the selection changes (key prop on the
|
||||
// builder forces a remount, so a plain useState initialiser is enough).
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>(agent.scenarios);
|
||||
@@ -61,7 +63,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{s.enabled ? "in eval" : "muted"}
|
||||
{s.enabled
|
||||
? t("agentBuilder.scenarios.inEval")
|
||||
: t("agentBuilder.scenarios.muted")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<span className="portal-agents__scenario-expect">
|
||||
@@ -73,7 +77,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
|
||||
variant="ghost"
|
||||
onClick={() => toggleEnabled(s.id)}
|
||||
>
|
||||
{s.enabled ? "Mute" : "Enable"}
|
||||
{s.enabled
|
||||
? t("agentBuilder.scenarios.mute")
|
||||
: t("agentBuilder.scenarios.enable")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
@@ -81,25 +87,25 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
|
||||
|
||||
<div className="portal-agents__scenario-add">
|
||||
<Chip tone="blue" size="sm">
|
||||
Add scenario
|
||||
{t("agentBuilder.scenarios.addScenario")}
|
||||
</Chip>
|
||||
<div className="portal-agents__scenario-form">
|
||||
<FormField label="Name">
|
||||
<FormField label={t("agentBuilder.scenarios.nameLabel")}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Compliance escalation"
|
||||
placeholder={t("agentBuilder.scenarios.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Expected behaviour">
|
||||
<FormField label={t("agentBuilder.scenarios.expectationLabel")}>
|
||||
<Input
|
||||
value={expectation}
|
||||
onChange={(e) => setExpectation(e.target.value)}
|
||||
placeholder="What the agent should do"
|
||||
placeholder={t("agentBuilder.scenarios.expectationPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<Button size="sm" onClick={addScenario} disabled={!canAdd}>
|
||||
Add
|
||||
{t("agentBuilder.scenarios.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, ToggleSwitch } from "@shared/components";
|
||||
import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
@@ -14,6 +15,7 @@ interface ToolsPanelProps {
|
||||
* default minus an explicit deny list, picked from the known tool catalogue.
|
||||
*/
|
||||
export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<ToolMode>(agent.toolMode);
|
||||
const [denied, setDenied] = useState<string[]>(agent.deniedTools);
|
||||
|
||||
@@ -38,23 +40,27 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
|
||||
checked={restricted}
|
||||
onChange={setRestricted}
|
||||
disabled={!governanceUnlocked}
|
||||
label="Restricted tool access"
|
||||
label={t("agentBuilder.tools.restrictedAccess")}
|
||||
description={
|
||||
governanceUnlocked
|
||||
? "Allow every tool except the ones you deny below."
|
||||
: "Tool governance is available on the Enterprise plan."
|
||||
? t("agentBuilder.tools.restrictedDescription")
|
||||
: t("agentBuilder.tools.governanceGate")
|
||||
}
|
||||
/>
|
||||
<Chip tone={restricted ? "amber" : "green"} size="sm">
|
||||
{restricted ? "Restricted" : "Broad access"}
|
||||
{restricted
|
||||
? t("agentBuilder.tools.restricted")
|
||||
: t("agentBuilder.tools.broadAccess")}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{restricted && (
|
||||
<div className="portal-agents__detail-section">
|
||||
<span className="portal-agents__detail-heading">Denied tools</span>
|
||||
<span className="portal-agents__detail-heading">
|
||||
{t("agentBuilder.tools.deniedTools")}
|
||||
</span>
|
||||
<p className="portal-agents__hint">
|
||||
Selected tools are blocked. Everything else stays callable.
|
||||
{t("agentBuilder.tools.deniedHint")}
|
||||
</p>
|
||||
<div className="portal-agents__chips">
|
||||
{TOOL_CATALOGUE.map((tool) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatusBadge } from "@shared/components";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
@@ -19,6 +20,7 @@ function formatDate(iso: string): string {
|
||||
|
||||
/** Version history with publish / rollback actions per row. */
|
||||
export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
// Without governance, only the current version is meaningful to show.
|
||||
const versions = historyUnlocked
|
||||
? agent.versions
|
||||
@@ -54,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
</StatusBadge>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
current
|
||||
{t("agentBuilder.versions.current")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
@@ -70,7 +72,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
variant="outline"
|
||||
onClick={() => publish(v.version)}
|
||||
>
|
||||
Publish
|
||||
{t("agentBuilder.versions.publish")}
|
||||
</Button>
|
||||
)}
|
||||
{v.status === "published" && !isCurrent && (
|
||||
@@ -79,7 +81,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
variant="ghost"
|
||||
onClick={() => rollback(v.version)}
|
||||
>
|
||||
Roll back
|
||||
{t("agentBuilder.versions.rollBack")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -90,8 +92,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
|
||||
{!historyUnlocked && publishedExists && (
|
||||
<p className="portal-agents__hint">
|
||||
Full version history and rollback are available on the Enterprise
|
||||
plan.
|
||||
{t("agentBuilder.versions.historyGate")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge } from "@shared/components";
|
||||
import {
|
||||
type SdkComponent,
|
||||
@@ -19,6 +20,7 @@ export function ComponentCard({
|
||||
unlocked,
|
||||
onOpen,
|
||||
}: ComponentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const maturity = MATURITY_META[component.maturity];
|
||||
|
||||
return (
|
||||
@@ -28,7 +30,7 @@ export function ComponentCard({
|
||||
className={"portal-components__card" + (unlocked ? "" : " is-locked")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${component.name} component`}
|
||||
aria-label={t("catalogue.card.openAriaLabel", { name: component.name })}
|
||||
onClick={() => onOpen(component)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -43,7 +45,10 @@ export function ComponentCard({
|
||||
{maturity.label}
|
||||
</StatusBadge>
|
||||
{!unlocked && (
|
||||
<span className="portal-components__lock" aria-label="Locked">
|
||||
<span
|
||||
className="portal-components__lock"
|
||||
aria-label={t("catalogue.card.lockedAriaLabel")}
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -19,12 +20,7 @@ import "@portal/views/Components.css";
|
||||
|
||||
type DetailTab = "overview" | "code" | "props" | "pricing";
|
||||
|
||||
const TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "code", label: "Code" },
|
||||
{ key: "props", label: "Props / API" },
|
||||
{ key: "pricing", label: "Pricing" },
|
||||
];
|
||||
const TAB_KEYS: DetailTab[] = ["overview", "code", "props", "pricing"];
|
||||
|
||||
interface ComponentDetailModalProps {
|
||||
component: SdkComponent | null;
|
||||
@@ -43,16 +39,26 @@ export function ComponentDetailModal({
|
||||
unlocked,
|
||||
onClose,
|
||||
}: ComponentDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<DetailTab>("overview");
|
||||
|
||||
// Reset to the first tab whenever a new component is opened.
|
||||
const open = component !== null;
|
||||
if (!component) {
|
||||
return (
|
||||
<Modal open={false} onClose={onClose} ariaLabel="Component detail" />
|
||||
<Modal
|
||||
open={false}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("catalogue.detail.ariaLabel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = TAB_KEYS.map((key) => ({
|
||||
key,
|
||||
label: t(`catalogue.detail.tabs.${key}`),
|
||||
}));
|
||||
|
||||
const maturity = MATURITY_META[component.maturity];
|
||||
const npm = `@stirling/${component.package}`;
|
||||
|
||||
@@ -86,7 +92,7 @@ export function ComponentDetailModal({
|
||||
// publishable key scoped to this component.
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Add to project
|
||||
{t("catalogue.detail.addToProject")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -96,7 +102,7 @@ export function ComponentDetailModal({
|
||||
// TODO(backend): route to the upgrade / contact-sales flow.
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Upgrade to unlock
|
||||
{t("catalogue.detail.upgradeToUnlock")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -104,8 +110,11 @@ export function ComponentDetailModal({
|
||||
{!unlocked && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title="Not available on your plan"
|
||||
description={`${component.name} is included from the ${component.minTier} plan. Upgrade to embed it.`}
|
||||
title={t("catalogue.detail.locked.title")}
|
||||
description={t("catalogue.detail.locked.description", {
|
||||
name: component.name,
|
||||
tier: component.minTier,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -113,19 +122,21 @@ export function ComponentDetailModal({
|
||||
<div className="portal-components__preview" aria-hidden>
|
||||
{/* TODO(backend)/host: mount the live <Sandbox> here, booting the
|
||||
component against a demo document and the dev's publishable key. */}
|
||||
<span className="portal-components__preview-badge">Live preview</span>
|
||||
<span className="portal-components__preview-badge">
|
||||
{t("catalogue.detail.preview.badge")}
|
||||
</span>
|
||||
<span className="portal-components__preview-note">
|
||||
Interactive sandbox renders here
|
||||
{t("catalogue.detail.preview.note")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs<DetailTab>
|
||||
className="portal-components__tabs"
|
||||
items={TABS}
|
||||
items={tabs}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Component detail sections"
|
||||
ariaLabel={t("catalogue.detail.tabsAriaLabel")}
|
||||
/>
|
||||
|
||||
<div className="portal-components__tab-body">
|
||||
@@ -142,18 +153,26 @@ export function ComponentDetailModal({
|
||||
))}
|
||||
</div>
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile label="Maturity" value={maturity.label} />
|
||||
<StatTile label="Price" value={formatPrice(component.pricing)} />
|
||||
<StatTile
|
||||
label="Free quota"
|
||||
label={t("catalogue.detail.stats.maturity")}
|
||||
value={maturity.label}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("catalogue.detail.stats.price")}
|
||||
value={formatPrice(component.pricing)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("catalogue.detail.stats.freeQuota")}
|
||||
value={
|
||||
component.pricing.freeQuota > 0
|
||||
? `${component.pricing.freeQuota.toLocaleString()} / mo`
|
||||
: "None"
|
||||
? t("catalogue.detail.stats.freeQuotaValue", {
|
||||
amount: component.pricing.freeQuota.toLocaleString(),
|
||||
})
|
||||
: t("catalogue.detail.stats.none")
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="Embeds (30d)"
|
||||
label={t("catalogue.detail.stats.embeds30d")}
|
||||
value={component.embeds30d.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
@@ -162,11 +181,15 @@ export function ComponentDetailModal({
|
||||
|
||||
{tab === "code" && (
|
||||
<div className="portal-components__code">
|
||||
<CodeBlock code={component.install} lang="bash" caption="Install" />
|
||||
<CodeBlock
|
||||
code={component.install}
|
||||
lang="bash"
|
||||
caption={t("catalogue.detail.code.install")}
|
||||
/>
|
||||
<CodeBlock
|
||||
code={component.usage}
|
||||
lang="typescript"
|
||||
caption="Usage"
|
||||
caption={t("catalogue.detail.code.usage")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -177,23 +200,28 @@ export function ComponentDetailModal({
|
||||
<div className="portal-components__pricing">
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile
|
||||
label="Per action"
|
||||
label={t("catalogue.detail.stats.perAction")}
|
||||
value={formatPrice(component.pricing)}
|
||||
/>
|
||||
<StatTile label="Billed on" value={component.pricing.unit} />
|
||||
<StatTile
|
||||
label="Free quota"
|
||||
label={t("catalogue.detail.stats.billedOn")}
|
||||
value={component.pricing.unit}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("catalogue.detail.stats.freeQuota")}
|
||||
value={
|
||||
component.pricing.freeQuota > 0
|
||||
? `${component.pricing.freeQuota.toLocaleString()} / mo`
|
||||
: "None"
|
||||
? t("catalogue.detail.stats.freeQuotaValue", {
|
||||
amount: component.pricing.freeQuota.toLocaleString(),
|
||||
})
|
||||
: t("catalogue.detail.stats.none")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="portal-components__pricing-note">
|
||||
Metered per {component.pricing.unit}. Usage beyond the monthly
|
||||
free quota is billed to your account and itemised under Usage
|
||||
& Billing.
|
||||
{t("catalogue.detail.pricing.note", {
|
||||
unit: component.pricing.unit,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, Table, type TableColumn } from "@shared/components";
|
||||
import type { ComponentProp } from "@portal/api/sdkComponents";
|
||||
import "@portal/views/Components.css";
|
||||
@@ -9,43 +10,46 @@ interface ComponentPropsTableProps {
|
||||
|
||||
/** Small Props/API reference shown under the detail modal's Props tab. */
|
||||
export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<ComponentProp>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Prop",
|
||||
header: t("catalogue.props.columns.name"),
|
||||
render: (p) => (
|
||||
<span className="portal-components__prop-name">{p.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Type",
|
||||
header: t("catalogue.props.columns.type"),
|
||||
render: (p) => (
|
||||
<code className="portal-components__prop-type">{p.type}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "required",
|
||||
header: "Required",
|
||||
header: t("catalogue.props.columns.required"),
|
||||
render: (p) =>
|
||||
p.required ? (
|
||||
<Chip size="sm" tone="amber">
|
||||
required
|
||||
{t("catalogue.props.required")}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="portal-components__muted">optional</span>
|
||||
<span className="portal-components__muted">
|
||||
{t("catalogue.props.optional")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Description",
|
||||
header: t("catalogue.props.columns.description"),
|
||||
render: (p) => (
|
||||
<span className="portal-components__prop-desc">{p.description}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { ComponentsResponse } from "@portal/api/sdkComponents";
|
||||
|
||||
@@ -6,11 +7,11 @@ import type { ComponentsResponse } from "@portal/api/sdkComponents";
|
||||
* so the strip's structure stays stable across loading / empty / ready states.
|
||||
* Only values flow from the API.
|
||||
*/
|
||||
const KPI_LABELS = [
|
||||
"Components GA",
|
||||
"In beta",
|
||||
"Embeds this month",
|
||||
"Component spend (MTD)",
|
||||
const KPI_LABEL_KEYS = [
|
||||
"catalogue.summary.componentsGa",
|
||||
"catalogue.summary.inBeta",
|
||||
"catalogue.summary.embedsThisMonth",
|
||||
"catalogue.summary.componentSpendMtd",
|
||||
] as const;
|
||||
|
||||
interface ComponentsSummaryStripProps {
|
||||
@@ -22,6 +23,7 @@ export function ComponentsSummaryStrip({
|
||||
data,
|
||||
loading,
|
||||
}: ComponentsSummaryStripProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = loading ? undefined : data?.summary;
|
||||
const values: (string | number)[] = [
|
||||
s?.gaCount ?? "—",
|
||||
@@ -32,8 +34,8 @@ export function ComponentsSummaryStrip({
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABELS.map((label, i) => (
|
||||
<MetricCard key={label} label={label} value={values[i]} />
|
||||
{KPI_LABEL_KEYS.map((labelKey, i) => (
|
||||
<MetricCard key={labelKey} label={t(labelKey)} value={values[i]} />
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, CodeBlock } from "@shared/components";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
export function AuthenticationSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="authentication"
|
||||
eyebrow="GETTING STARTED"
|
||||
title="Authentication"
|
||||
lead="All requests authenticate with a bearer token. Keys are scoped per environment and never expire unless rotated."
|
||||
eyebrow={t("docs.authentication.eyebrow")}
|
||||
title={t("docs.authentication.title")}
|
||||
lead={t("docs.authentication.lead")}
|
||||
>
|
||||
<CodeBlock
|
||||
lang="http"
|
||||
caption="every request"
|
||||
caption={t("docs.authentication.codeCaption")}
|
||||
code={`Authorization: Bearer sk_live_8f2c...e10`}
|
||||
/>
|
||||
<div className="portal-docs__keytable">
|
||||
@@ -19,13 +21,13 @@ export function AuthenticationSection() {
|
||||
<Chip tone="green" size="sm" showDot>
|
||||
sk_live_
|
||||
</Chip>
|
||||
<span>Production keys — billed, rate-limited per your plan.</span>
|
||||
<span>{t("docs.authentication.liveKey")}</span>
|
||||
</div>
|
||||
<div className="portal-docs__keyrow">
|
||||
<Chip tone="amber" size="sm" showDot>
|
||||
sk_test_
|
||||
</Chip>
|
||||
<span>Sandbox keys — free, return synthetic fixtures.</span>
|
||||
<span>{t("docs.authentication.testKey")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DocsSection>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, CodeBlock } from "@shared/components";
|
||||
import type { EmbedComponent } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
@@ -7,12 +8,13 @@ export function ComponentsSection({
|
||||
}: {
|
||||
components: EmbedComponent[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="component-library"
|
||||
eyebrow="COMPONENTS"
|
||||
title="Drop-in viewers"
|
||||
lead="Embeddable UI for review queues and document inspection. Bring your own styles or use the shipped theme."
|
||||
eyebrow={t("docs.components.eyebrow")}
|
||||
title={t("docs.components.title")}
|
||||
lead={t("docs.components.lead")}
|
||||
>
|
||||
<div className="portal-docs__component-grid">
|
||||
{components.map((c) => (
|
||||
@@ -29,7 +31,7 @@ export function ComponentsSection({
|
||||
</div>
|
||||
<CodeBlock
|
||||
lang="typescript"
|
||||
caption="embed the viewer"
|
||||
caption={t("docs.components.codeCaption")}
|
||||
code={`import { DocumentViewer } from "@stirling/react";
|
||||
|
||||
<DocumentViewer
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Skeleton, StatusBadge } from "@shared/components";
|
||||
import type { DocsNavSection } from "@portal/api/docs";
|
||||
|
||||
@@ -11,8 +12,9 @@ export function DocsNav({
|
||||
active: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav className="portal-docs__nav" aria-label="Documentation">
|
||||
<nav className="portal-docs__nav" aria-label={t("docs.nav.ariaLabel")}>
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="portal-docs__nav-group">
|
||||
<div className="portal-docs__nav-head">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MethodBadge,
|
||||
Tabs,
|
||||
@@ -11,10 +12,15 @@ import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
type VerticalFilter = "all" | (typeof VERTICALS)[number]["key"];
|
||||
|
||||
export function EndpointReferenceSection() {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<VerticalFilter>("all");
|
||||
|
||||
const tabItems: TabItem<VerticalFilter>[] = [
|
||||
{ key: "all", label: "All", count: ALL_ENDPOINTS.length },
|
||||
{
|
||||
key: "all",
|
||||
label: t("docs.endpoints.filterAll"),
|
||||
count: ALL_ENDPOINTS.length,
|
||||
},
|
||||
...VERTICALS.map<TabItem<VerticalFilter>>((v) => ({
|
||||
key: v.key,
|
||||
label: v.label,
|
||||
@@ -33,15 +39,15 @@ export function EndpointReferenceSection() {
|
||||
return (
|
||||
<DocsSection
|
||||
id="endpoints"
|
||||
eyebrow="API REFERENCE"
|
||||
title="Endpoints"
|
||||
lead="Every document type is a typed endpoint. POST a file, receive schema-validated JSON. Filter by vertical below."
|
||||
eyebrow={t("docs.endpoints.eyebrow")}
|
||||
title={t("docs.endpoints.title")}
|
||||
lead={t("docs.endpoints.lead")}
|
||||
>
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
activeKey={filter}
|
||||
onChange={setFilter}
|
||||
ariaLabel="Filter endpoints by vertical"
|
||||
ariaLabel={t("docs.endpoints.filterAriaLabel")}
|
||||
/>
|
||||
<div className="portal-docs__endpoints">
|
||||
{shown.map((v) => (
|
||||
@@ -60,7 +66,9 @@ export function EndpointReferenceSection() {
|
||||
<code className="portal-docs__endpoint-path">{e.endpoint}</code>
|
||||
<span className="portal-docs__endpoint-name">{e.name}</span>
|
||||
<span className="portal-docs__endpoint-fields">
|
||||
{Object.keys(e.schema).length} fields
|
||||
{t("docs.endpoints.fieldCount", {
|
||||
count: Object.keys(e.schema).length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CodeBlock, StatusBadge } from "@shared/components";
|
||||
import type { ApiErrorRow } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
export function ErrorsSection({ errors }: { errors: ApiErrorRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="errors"
|
||||
eyebrow="API REFERENCE"
|
||||
title="Errors"
|
||||
lead="Errors return a stable machine-readable code plus a human message. The 4xx body always includes a request_id for support."
|
||||
eyebrow={t("docs.errors.eyebrow")}
|
||||
title={t("docs.errors.title")}
|
||||
lead={t("docs.errors.lead")}
|
||||
>
|
||||
<div className="portal-docs__errors">
|
||||
{errors.map((e) => (
|
||||
@@ -25,7 +27,7 @@ export function ErrorsSection({ errors }: { errors: ApiErrorRow[] }) {
|
||||
</div>
|
||||
<CodeBlock
|
||||
lang="json"
|
||||
caption="422 Unprocessable Entity"
|
||||
caption={t("docs.errors.codeCaption")}
|
||||
code={`{
|
||||
"error": "schema_validation_failed",
|
||||
"message": "Field 'total' could not be located",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CodeBlock } from "@shared/components";
|
||||
import type { CodeSample } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
@@ -10,22 +11,20 @@ export function GettingStartedSection({
|
||||
samples: CodeSample[];
|
||||
response: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="quickstart"
|
||||
eyebrow="GETTING STARTED"
|
||||
title="Quickstart"
|
||||
lead="Send your first document to a typed endpoint and get structured JSON back in three steps. No model training, no prompt engineering."
|
||||
eyebrow={t("docs.quickstart.eyebrow")}
|
||||
title={t("docs.quickstart.title")}
|
||||
lead={t("docs.quickstart.lead")}
|
||||
>
|
||||
<ol className="portal-docs__steps">
|
||||
<li className="portal-docs__step">
|
||||
<span className="portal-docs__step-mark">1</span>
|
||||
<div className="portal-docs__step-body">
|
||||
<h3>Issue an API key</h3>
|
||||
<p>
|
||||
Create a scoped key from the Infrastructure tab. Keys carry rate
|
||||
limits and an optional IP allowlist. Export it into your shell:
|
||||
</p>
|
||||
<h3>{t("docs.quickstart.step1.title")}</h3>
|
||||
<p>{t("docs.quickstart.step1.body")}</p>
|
||||
<CodeBlock
|
||||
lang="bash"
|
||||
code={`export STIRLING_API_KEY="sk_live_8f2c...e10"`}
|
||||
@@ -35,31 +34,33 @@ export function GettingStartedSection({
|
||||
<li className="portal-docs__step">
|
||||
<span className="portal-docs__step-mark">2</span>
|
||||
<div className="portal-docs__step-body">
|
||||
<h3>Send a document</h3>
|
||||
<p>
|
||||
POST a file to any typed endpoint. The endpoint determines the
|
||||
schema you get back — here, the invoice extractor.
|
||||
</p>
|
||||
<LangSnippet samples={samples} caption="extract an invoice" />
|
||||
<h3>{t("docs.quickstart.step2.title")}</h3>
|
||||
<p>{t("docs.quickstart.step2.body")}</p>
|
||||
<LangSnippet
|
||||
samples={samples}
|
||||
caption={t("docs.quickstart.step2.snippetCaption")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li className="portal-docs__step">
|
||||
<span className="portal-docs__step-mark">3</span>
|
||||
<div className="portal-docs__step-body">
|
||||
<h3>Read the structured result</h3>
|
||||
<p>
|
||||
Every response is validated against the endpoint schema, with a
|
||||
confidence score and per-field provenance.
|
||||
</p>
|
||||
<CodeBlock lang="json" code={response} caption="200 OK" />
|
||||
<h3>{t("docs.quickstart.step3.title")}</h3>
|
||||
<p>{t("docs.quickstart.step3.body")}</p>
|
||||
<CodeBlock
|
||||
lang="json"
|
||||
code={response}
|
||||
caption={t("docs.quickstart.step3.codeCaption")}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Card className="portal-docs__callout" accent="blue" padding="loose">
|
||||
<strong>Next:</strong> wire the same call into a pipeline to chain
|
||||
validation, redaction, and delivery — or expose it to an agent over MCP.
|
||||
See <em>Playbooks</em> for copy-paste recipes.
|
||||
<strong>{t("docs.quickstart.callout.label")}</strong>{" "}
|
||||
{t("docs.quickstart.callout.bodyBeforeLink")}{" "}
|
||||
<em>{t("docs.quickstart.callout.link")}</em>{" "}
|
||||
{t("docs.quickstart.callout.bodyAfterLink")}
|
||||
</Card>
|
||||
</DocsSection>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, Chip } from "@shared/components";
|
||||
import type { Playbook } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
export function PlaybooksSection({ playbooks }: { playbooks: Playbook[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="recipes"
|
||||
eyebrow="PLAYBOOKS"
|
||||
title="Recipes"
|
||||
lead="End-to-end patterns that chain sources, operations, and destinations. Each maps to a pipeline you can clone."
|
||||
eyebrow={t("docs.recipes.eyebrow")}
|
||||
title={t("docs.recipes.title")}
|
||||
lead={t("docs.recipes.lead")}
|
||||
>
|
||||
<div className="portal-docs__playbook-grid">
|
||||
{playbooks.map((p) => (
|
||||
@@ -32,7 +34,7 @@ export function PlaybooksSection({ playbooks }: { playbooks: Playbook[] }) {
|
||||
{/* TODO(backend): POST /v1/pipelines/clone-from-playbook to seed a
|
||||
draft pipeline from this recipe, then route to the composer. */}
|
||||
<Button variant="outline" accent={p.accent} size="sm">
|
||||
Clone recipe
|
||||
{t("docs.recipes.cloneButton")}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CodeBlock } from "@shared/components";
|
||||
import type { RateLimit } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
export function RateLimitsSection({ rateLimit }: { rateLimit: RateLimit }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="rate-limits"
|
||||
eyebrow="GETTING STARTED"
|
||||
title="Rate limits & quotas"
|
||||
lead="Limits scale with your plan. A 429 response includes a Retry-After header; the SDKs back off automatically."
|
||||
eyebrow={t("docs.rateLimits.eyebrow")}
|
||||
title={t("docs.rateLimits.title")}
|
||||
lead={t("docs.rateLimits.lead")}
|
||||
>
|
||||
<div className="portal-docs__limits">
|
||||
<Card padding="default">
|
||||
<div className="portal-docs__limit-label">Requests / minute</div>
|
||||
<div className="portal-docs__limit-label">
|
||||
{t("docs.rateLimits.requestsPerMinute")}
|
||||
</div>
|
||||
<div className="portal-docs__limit-value">{rateLimit.rpm}</div>
|
||||
</Card>
|
||||
<Card padding="default">
|
||||
<div className="portal-docs__limit-label">Burst</div>
|
||||
<div className="portal-docs__limit-label">
|
||||
{t("docs.rateLimits.burst")}
|
||||
</div>
|
||||
<div className="portal-docs__limit-value">{rateLimit.burst}</div>
|
||||
</Card>
|
||||
<Card padding="default">
|
||||
<div className="portal-docs__limit-label">Concurrency</div>
|
||||
<div className="portal-docs__limit-label">
|
||||
{t("docs.rateLimits.concurrency")}
|
||||
</div>
|
||||
<div className="portal-docs__limit-value">
|
||||
{rateLimit.concurrency}
|
||||
</div>
|
||||
@@ -28,7 +36,7 @@ export function RateLimitsSection({ rateLimit }: { rateLimit: RateLimit }) {
|
||||
</div>
|
||||
<CodeBlock
|
||||
lang="http"
|
||||
caption="429 Too Many Requests"
|
||||
caption={t("docs.rateLimits.codeCaption")}
|
||||
code={`HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 2
|
||||
X-RateLimit-Remaining: 0`}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CodeBlock, StatusBadge } from "@shared/components";
|
||||
import type { Sdk, SdkStatus } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
/** GA clients carry no badge; only non-stable maturity is called out. */
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<SdkStatus, { label: string; tone: "info" | "warning" }>
|
||||
Record<SdkStatus, { labelKey: string; tone: "info" | "warning" }>
|
||||
> = {
|
||||
beta: { label: "Beta", tone: "info" },
|
||||
deprecated: { label: "Deprecated", tone: "warning" },
|
||||
beta: { labelKey: "docs.sdks.status.beta", tone: "info" },
|
||||
deprecated: { labelKey: "docs.sdks.status.deprecated", tone: "warning" },
|
||||
};
|
||||
|
||||
export function SdksSection({ sdks }: { sdks: Sdk[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="sdk-overview"
|
||||
eyebrow="SDKS"
|
||||
title="Official SDKs"
|
||||
lead="First-party clients with typed responses, automatic retries, and streaming uploads. All track the same endpoint catalogue."
|
||||
eyebrow={t("docs.sdks.eyebrow")}
|
||||
title={t("docs.sdks.title")}
|
||||
lead={t("docs.sdks.lead")}
|
||||
>
|
||||
<div className="portal-docs__sdk-grid">
|
||||
{sdks.map((sdk) => {
|
||||
@@ -30,7 +32,7 @@ export function SdksSection({ sdks }: { sdks: Sdk[] }) {
|
||||
<h3 className="portal-docs__sdk-name">{sdk.name}</h3>
|
||||
{badge && (
|
||||
<StatusBadge tone={badge.tone} size="sm">
|
||||
{badge.label}
|
||||
{t(badge.labelKey)}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@shared/components";
|
||||
import type { AgentSkill } from "@portal/api/docs";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
export function SkillsSection({ skills }: { skills: AgentSkill[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="skill-catalog"
|
||||
eyebrow="SKILLS"
|
||||
title="Agent skills"
|
||||
lead="Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
eyebrow={t("docs.skills.eyebrow")}
|
||||
title={t("docs.skills.title")}
|
||||
lead={t("docs.skills.lead")}
|
||||
>
|
||||
<div className="portal-docs__skill-grid">
|
||||
{skills.map((s) => (
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CodeBlock } from "@shared/components";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
|
||||
export function WebhooksSection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DocsSection
|
||||
id="webhooks"
|
||||
eyebrow="API REFERENCE"
|
||||
title="Webhooks"
|
||||
lead="Subscribe to document.processed, pipeline.completed, and quota.threshold events. Payloads are signed with HMAC-SHA256."
|
||||
eyebrow={t("docs.webhooks.eyebrow")}
|
||||
title={t("docs.webhooks.title")}
|
||||
lead={t("docs.webhooks.lead")}
|
||||
>
|
||||
<CodeBlock
|
||||
lang="json"
|
||||
caption="document.processed"
|
||||
caption={t("docs.webhooks.codeCaption")}
|
||||
code={`{
|
||||
"event": "document.processed",
|
||||
"id": "evt_91ac3f",
|
||||
@@ -24,9 +26,10 @@ export function WebhooksSection() {
|
||||
}`}
|
||||
/>
|
||||
<Card className="portal-docs__callout" accent="amber" padding="loose">
|
||||
Verify the <code>Stirling-Signature</code> header against your signing
|
||||
secret before trusting a payload. SDKs ship a{" "}
|
||||
<code>verifyWebhook()</code> helper.
|
||||
{t("docs.webhooks.callout.beforeSignature")}{" "}
|
||||
<code>Stirling-Signature</code>{" "}
|
||||
{t("docs.webhooks.callout.beforeHelper")} <code>verifyWebhook()</code>{" "}
|
||||
{t("docs.webhooks.callout.afterHelper")}
|
||||
</Card>
|
||||
</DocsSection>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge } from "@shared/components";
|
||||
import {
|
||||
DOC_AUDIT_LABEL,
|
||||
@@ -7,8 +8,11 @@ import {
|
||||
|
||||
/** Lifecycle timeline for a single document, oldest first. */
|
||||
export function DocumentAudit({ doc }: { doc: ReviewDocument }) {
|
||||
const { t } = useTranslation();
|
||||
if (doc.audit.length === 0) {
|
||||
return <p className="portal-documents__muted">No events recorded yet.</p>;
|
||||
return (
|
||||
<p className="portal-documents__muted">{t("documents.audit.empty")}</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ol className="portal-documents__timeline">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Drawer, StatusBadge, Tabs, type TabItem } from "@shared/components";
|
||||
import {
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
@@ -14,12 +15,6 @@ import { ElevationBanner } from "@portal/components/documents/ElevationBanner";
|
||||
|
||||
type SubTab = "overview" | "extractions" | "audit";
|
||||
|
||||
const SUB_TABS: TabItem<SubTab>[] = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "extractions", label: "Extractions" },
|
||||
{ key: "audit", label: "Audit" },
|
||||
];
|
||||
|
||||
interface DocumentDrawerProps {
|
||||
/** Selected document, or null when the drawer is closed. */
|
||||
doc: ReviewDocument | null;
|
||||
@@ -32,9 +27,16 @@ interface DocumentDrawerProps {
|
||||
* behind a client-side timed elevation; enterprise adds a four-eyes note.
|
||||
*/
|
||||
export function DocumentDrawer({ doc, onClose }: DocumentDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const fourEyes = tier === "enterprise";
|
||||
|
||||
const subTabs: TabItem<SubTab>[] = [
|
||||
{ key: "overview", label: t("documents.drawer.tabs.overview") },
|
||||
{ key: "extractions", label: t("documents.drawer.tabs.extractions") },
|
||||
{ key: "audit", label: t("documents.drawer.tabs.audit") },
|
||||
];
|
||||
|
||||
const [tab, setTab] = useState<SubTab>("overview");
|
||||
// Seconds remaining on the active elevation grant; null means no grant.
|
||||
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
||||
@@ -92,11 +94,11 @@ export function DocumentDrawer({ doc, onClose }: DocumentDrawerProps) {
|
||||
)}
|
||||
|
||||
<Tabs<SubTab>
|
||||
items={SUB_TABS}
|
||||
items={subTabs}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Document detail sections"
|
||||
ariaLabel={t("documents.drawer.sectionsAriaLabel")}
|
||||
/>
|
||||
|
||||
<div className="portal-documents__drawer-panel">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Table, type TableColumn } from "@shared/components";
|
||||
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
|
||||
import {
|
||||
@@ -5,34 +6,6 @@ import {
|
||||
confidenceTone,
|
||||
} from "@portal/components/documents/format";
|
||||
|
||||
const cols: TableColumn<Extraction>[] = [
|
||||
{
|
||||
key: "field",
|
||||
header: "Field",
|
||||
render: (e) => <span className="portal-documents__field">{e.field}</span>,
|
||||
},
|
||||
{
|
||||
key: "value",
|
||||
header: "Value",
|
||||
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
header: "Confidence",
|
||||
align: "right",
|
||||
width: "7rem",
|
||||
render: (e) => (
|
||||
<StatusBadge
|
||||
tone={confidenceTone(e.confidence)}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{confidencePct(e.confidence)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface DocumentExtractionsProps {
|
||||
doc: ReviewDocument;
|
||||
/**
|
||||
@@ -48,6 +21,36 @@ export function DocumentExtractions({
|
||||
doc,
|
||||
unlocked,
|
||||
}: DocumentExtractionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cols: TableColumn<Extraction>[] = [
|
||||
{
|
||||
key: "field",
|
||||
header: t("documents.extractions.columns.field"),
|
||||
render: (e) => <span className="portal-documents__field">{e.field}</span>,
|
||||
},
|
||||
{
|
||||
key: "value",
|
||||
header: t("documents.extractions.columns.value"),
|
||||
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
header: t("documents.extractions.columns.confidence"),
|
||||
align: "right",
|
||||
width: "7rem",
|
||||
render: (e) => (
|
||||
<StatusBadge
|
||||
tone={confidenceTone(e.confidence)}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{confidencePct(e.confidence)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (doc.sensitive && !unlocked) {
|
||||
return (
|
||||
<div className="portal-documents__masked">
|
||||
@@ -55,8 +58,7 @@ export function DocumentExtractions({
|
||||
🔒
|
||||
</span>
|
||||
<p className="portal-documents__masked-text">
|
||||
Extracted fields are hidden. Request timed access to view this
|
||||
document's content.
|
||||
{t("documents.extractions.masked")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -67,7 +69,7 @@ export function DocumentExtractions({
|
||||
columns={cols}
|
||||
rows={doc.extractions}
|
||||
rowKey={(e) => e.field}
|
||||
empty="No fields were extracted from this document."
|
||||
empty={t("documents.extractions.empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatTile } from "@shared/components";
|
||||
import {
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
@@ -7,15 +8,25 @@ import { confidencePct } from "@portal/components/documents/format";
|
||||
|
||||
/** Key fields for the selected document — status, source, confidence. */
|
||||
export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-documents__overview">
|
||||
<div className="portal-documents__stat-grid">
|
||||
<StatTile label="Status" value={DOCUMENT_STATUS_LABEL[doc.status]} />
|
||||
<StatTile label="Type" value={doc.type} />
|
||||
<StatTile label="Confidence" value={confidencePct(doc.confidence)} />
|
||||
<StatTile label="Fields extracted" value={doc.fieldsExtracted} />
|
||||
<StatTile label="Source" value={doc.source} />
|
||||
<StatTile label="Received" value={doc.time} />
|
||||
<StatTile
|
||||
label={t("documents.overview.status")}
|
||||
value={DOCUMENT_STATUS_LABEL[doc.status]}
|
||||
/>
|
||||
<StatTile label={t("documents.overview.type")} value={doc.type} />
|
||||
<StatTile
|
||||
label={t("documents.overview.confidence")}
|
||||
value={confidencePct(doc.confidence)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("documents.overview.fieldsExtracted")}
|
||||
value={doc.fieldsExtracted}
|
||||
/>
|
||||
<StatTile label={t("documents.overview.source")} value={doc.source} />
|
||||
<StatTile label={t("documents.overview.received")} value={doc.time} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip, Skeleton } from "@shared/components";
|
||||
import type { DocumentsSummary } from "@portal/api/documents";
|
||||
import { confidencePct } from "@portal/components/documents/format";
|
||||
@@ -12,6 +13,7 @@ export function DocumentsSummaryStrip({
|
||||
summary,
|
||||
loading,
|
||||
}: DocumentsSummaryStripProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<MetricStrip>
|
||||
@@ -26,19 +28,19 @@ export function DocumentsSummaryStrip({
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricCard
|
||||
label="In queue"
|
||||
label={t("documents.summary.inQueue")}
|
||||
value={summary.totalInQueue.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Needs review"
|
||||
label={t("documents.summary.needsReview")}
|
||||
value={summary.needsReview.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Avg confidence"
|
||||
label={t("documents.summary.avgConfidence")}
|
||||
value={confidencePct(summary.avgConfidence)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Processed today"
|
||||
label={t("documents.summary.processedToday")}
|
||||
value={summary.processedToday.toLocaleString()}
|
||||
/>
|
||||
</MetricStrip>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button } from "@shared/components";
|
||||
import { formatCountdown } from "@portal/components/documents/format";
|
||||
|
||||
@@ -20,16 +21,19 @@ export function ElevationBanner({
|
||||
fourEyes,
|
||||
onRequest,
|
||||
}: ElevationBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
if (secondsLeft !== null) {
|
||||
return (
|
||||
<Banner
|
||||
tone="success"
|
||||
icon={<span aria-hidden>⏱</span>}
|
||||
title={`Access expires in ${formatCountdown(secondsLeft)}`}
|
||||
title={t("documents.elevation.active.title", {
|
||||
time: formatCountdown(secondsLeft),
|
||||
})}
|
||||
description={
|
||||
fourEyes
|
||||
? "Temporary grant — a peer reviewer was notified (four-eyes)."
|
||||
: "Temporary grant — access is logged and time-boxed."
|
||||
? t("documents.elevation.active.descriptionFourEyes")
|
||||
: t("documents.elevation.active.description")
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -39,15 +43,15 @@ export function ElevationBanner({
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<span aria-hidden>🔒</span>}
|
||||
title="Sensitive document"
|
||||
title={t("documents.elevation.gated.title")}
|
||||
description={
|
||||
fourEyes
|
||||
? "Content is gated by zero-standing-access. Requesting starts a time-boxed grant and notifies a peer reviewer (four-eyes)."
|
||||
: "Content is gated by zero-standing-access. Requesting starts a time-boxed grant."
|
||||
? t("documents.elevation.gated.descriptionFourEyes")
|
||||
: t("documents.elevation.gated.description")
|
||||
}
|
||||
action={
|
||||
<Button size="sm" onClick={onRequest}>
|
||||
Request access
|
||||
{t("documents.elevation.requestAccess")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState, Skeleton, Tabs, type TabItem } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -35,6 +36,7 @@ function countFor(docs: ReviewDocument[], filter: QueueFilter): number {
|
||||
* stream table, and a detail drawer. The primary Documents surface.
|
||||
*/
|
||||
export function ReviewQueue() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<DocumentsResponse>(() => fetchDocuments(tier), [tier]);
|
||||
const { data, loading } = state;
|
||||
@@ -54,20 +56,24 @@ export function ReviewQueue() {
|
||||
const selected = documents.find((d) => d.id === selectedId) ?? null;
|
||||
|
||||
const filterItems: TabItem<QueueFilter>[] = [
|
||||
{ key: "all", label: "All", count: countFor(documents, "all") },
|
||||
{
|
||||
key: "all",
|
||||
label: t("documents.filters.all"),
|
||||
count: countFor(documents, "all"),
|
||||
},
|
||||
{
|
||||
key: "needs-review",
|
||||
label: "Needs review",
|
||||
label: t("documents.filters.needsReview"),
|
||||
count: countFor(documents, "needs-review"),
|
||||
},
|
||||
{
|
||||
key: "processed",
|
||||
label: "Processed",
|
||||
label: t("documents.filters.processed"),
|
||||
count: countFor(documents, "processed"),
|
||||
},
|
||||
{
|
||||
key: "archived",
|
||||
label: "Archived",
|
||||
label: t("documents.filters.archived"),
|
||||
count: countFor(documents, "archived"),
|
||||
},
|
||||
];
|
||||
@@ -84,7 +90,7 @@ export function ReviewQueue() {
|
||||
activeKey={filter}
|
||||
onChange={setFilter}
|
||||
variant="pill"
|
||||
ariaLabel="Filter documents by status"
|
||||
ariaLabel={t("documents.filters.ariaLabel")}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
@@ -97,8 +103,8 @@ export function ReviewQueue() {
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title="No documents in the queue"
|
||||
description="As sources feed documents into your pipelines they'll appear here for review."
|
||||
title={t("documents.queue.empty.title")}
|
||||
description={t("documents.queue.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ProgressBar,
|
||||
StatusBadge,
|
||||
@@ -25,19 +26,20 @@ export function ReviewQueueTable({
|
||||
documents,
|
||||
onRowClick,
|
||||
}: ReviewQueueTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<ReviewDocument>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
header: t("documents.table.columns.name"),
|
||||
render: (d) => (
|
||||
<div className="portal-documents__name-cell">
|
||||
<span className="portal-documents__name">{d.name}</span>
|
||||
{d.sensitive && (
|
||||
<span
|
||||
className="portal-documents__lock"
|
||||
title="Sensitive — access required"
|
||||
aria-label="Sensitive"
|
||||
title={t("documents.table.sensitiveTitle")}
|
||||
aria-label={t("documents.table.sensitiveLabel")}
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
@@ -45,10 +47,14 @@ export function ReviewQueueTable({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "type", header: "Type", render: (d) => d.type },
|
||||
{
|
||||
key: "type",
|
||||
header: t("documents.table.columns.type"),
|
||||
render: (d) => d.type,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
header: t("documents.table.columns.status"),
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
|
||||
{DOCUMENT_STATUS_LABEL[d.status]}
|
||||
@@ -57,14 +63,14 @@ export function ReviewQueueTable({
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: "Source",
|
||||
header: t("documents.table.columns.source"),
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.source}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
header: "Confidence",
|
||||
header: t("documents.table.columns.confidence"),
|
||||
width: "9rem",
|
||||
render: (d) => (
|
||||
<div className="portal-documents__confidence">
|
||||
@@ -81,7 +87,7 @@ export function ReviewQueueTable({
|
||||
},
|
||||
{
|
||||
key: "fields",
|
||||
header: "Fields",
|
||||
header: t("documents.table.columns.fields"),
|
||||
align: "right",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__mono">{d.fieldsExtracted}</span>
|
||||
@@ -89,14 +95,14 @@ export function ReviewQueueTable({
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
header: "Time",
|
||||
header: t("documents.table.columns.time"),
|
||||
align: "right",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.time}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -106,7 +112,7 @@ export function ReviewQueueTable({
|
||||
rows={documents}
|
||||
rowKey={(d) => d.id}
|
||||
onRowClick={onRowClick}
|
||||
empty="No documents match this filter."
|
||||
empty={t("documents.table.empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Card, StatTile } from "@shared/components";
|
||||
import type { DeploymentSummary } from "@portal/api/editorDeploy";
|
||||
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
* there's no submit endpoint yet.
|
||||
*/
|
||||
export function CredentialRotationCard({ serviceToken }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [rotated, setRotated] = useState(false);
|
||||
|
||||
@@ -30,24 +32,31 @@ export function CredentialRotationCard({ serviceToken }: Props) {
|
||||
<Card padding="default" className="portal-editor__panel">
|
||||
<div className="portal-editor__panel-head">
|
||||
<div>
|
||||
<h3 className="portal-editor__panel-title">Service token</h3>
|
||||
<h3 className="portal-editor__panel-title">
|
||||
{t("editorAdmin.serviceToken.title")}
|
||||
</h3>
|
||||
<p className="portal-editor__panel-sub">
|
||||
Instances authenticate to the org with this credential. Rotate it on
|
||||
a schedule or immediately after a suspected leak.
|
||||
{t("editorAdmin.serviceToken.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-editor__token-row">
|
||||
<StatTile label="Current token" value={serviceToken.masked} />
|
||||
<StatTile label="Last rotated" value={serviceToken.lastRotated} />
|
||||
<StatTile
|
||||
label={t("editorAdmin.serviceToken.currentToken")}
|
||||
value={serviceToken.masked}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("editorAdmin.serviceToken.lastRotated")}
|
||||
value={serviceToken.lastRotated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rotated && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title="Rotate running instances"
|
||||
description="A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline."
|
||||
title={t("editorAdmin.serviceToken.rotatedBanner.title")}
|
||||
description={t("editorAdmin.serviceToken.rotatedBanner.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -58,7 +67,7 @@ export function CredentialRotationCard({ serviceToken }: Props) {
|
||||
loading={rotating}
|
||||
onClick={rotate}
|
||||
>
|
||||
Rotate service token
|
||||
{t("editorAdmin.serviceToken.rotateButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, CodeBlock, StatusBadge } from "@shared/components";
|
||||
import { TARGET_META, type DeploymentTarget } from "@portal/api/editorDeploy";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
|
||||
const STATE_BADGE: Record<
|
||||
const STATE_BADGE_TONE: Record<
|
||||
DeploymentTarget["state"],
|
||||
{ label: string; tone: "success" | "info" | "neutral" }
|
||||
"success" | "info" | "neutral"
|
||||
> = {
|
||||
running: { label: "Running", tone: "success" },
|
||||
available: { label: "Available", tone: "info" },
|
||||
locked: { label: "Locked", tone: "neutral" },
|
||||
running: "success",
|
||||
available: "info",
|
||||
locked: "neutral",
|
||||
};
|
||||
|
||||
/** Upgrade-nudge copy for a target gated behind a higher tier. */
|
||||
function lockCopy(target: DeploymentTarget): string {
|
||||
return target.requiresTier === "enterprise"
|
||||
? "On-prem and Kubernetes self-hosting are part of Enterprise."
|
||||
: "Self-hosting with Docker and Kubernetes unlocks on a paid plan.";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
targets: DeploymentTarget[];
|
||||
/** Invoked from a locked target's upgrade nudge. */
|
||||
@@ -30,17 +24,22 @@ interface Props {
|
||||
* nudge so the value of the higher tier is visible inline.
|
||||
*/
|
||||
export function DeploymentTargets({ targets, onUpgrade }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
|
||||
const lockCopy = (target: DeploymentTarget): string =>
|
||||
target.requiresTier === "enterprise"
|
||||
? t("editorAdmin.targets.lock.enterprise")
|
||||
: t("editorAdmin.targets.lock.paid");
|
||||
|
||||
return (
|
||||
<div className="portal-editor__targets">
|
||||
{targets.map((t) => {
|
||||
const meta = TARGET_META[t.kind];
|
||||
const badge = STATE_BADGE[t.state];
|
||||
const locked = t.state === "locked";
|
||||
{targets.map((target) => {
|
||||
const meta = TARGET_META[target.kind];
|
||||
const locked = target.state === "locked";
|
||||
return (
|
||||
<Card
|
||||
key={t.kind}
|
||||
key={target.kind}
|
||||
padding="default"
|
||||
className="portal-editor__target"
|
||||
>
|
||||
@@ -52,45 +51,51 @@ export function DeploymentTargets({ targets, onUpgrade }: Props) {
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="portal-editor__target-titles">
|
||||
<h3 className="portal-editor__target-name">{t.label}</h3>
|
||||
<p className="portal-editor__target-tagline">{t.tagline}</p>
|
||||
<h3 className="portal-editor__target-name">{target.label}</h3>
|
||||
<p className="portal-editor__target-tagline">
|
||||
{target.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={badge.tone}
|
||||
tone={STATE_BADGE_TONE[target.state]}
|
||||
size="sm"
|
||||
pulse={t.state === "running"}
|
||||
pulse={target.state === "running"}
|
||||
>
|
||||
{badge.label}
|
||||
{t(`editorAdmin.targets.state.${target.state}`)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{t.state === "running" && (
|
||||
{target.state === "running" && (
|
||||
<p className="portal-editor__target-meta">
|
||||
v{t.runningVersion} · {t.instanceCount}{" "}
|
||||
{t.instanceCount === 1 ? "instance" : "instances"}
|
||||
v{target.runningVersion} ·{" "}
|
||||
{t("editorAdmin.targets.instanceCount", {
|
||||
count: target.instanceCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{locked ? (
|
||||
<div className="portal-editor__lock">
|
||||
<p className="portal-editor__lock-copy">{lockCopy(t)}</p>
|
||||
<p className="portal-editor__lock-copy">{lockCopy(target)}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
accent={t.requiresTier === "enterprise" ? "purple" : "blue"}
|
||||
accent={
|
||||
target.requiresTier === "enterprise" ? "purple" : "blue"
|
||||
}
|
||||
onClick={onUpgrade}
|
||||
disabled={tier === "enterprise"}
|
||||
>
|
||||
{t.requiresTier === "enterprise"
|
||||
? "Talk to sales"
|
||||
: "Upgrade plan"}
|
||||
{target.requiresTier === "enterprise"
|
||||
? t("editorAdmin.targets.talkToSales")
|
||||
: t("editorAdmin.targets.upgradePlan")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CodeBlock
|
||||
code={t.snippet}
|
||||
lang={t.snippetLang}
|
||||
caption={t.label}
|
||||
code={target.snippet}
|
||||
lang={target.snippetLang}
|
||||
caption={target.label}
|
||||
maxHeight={180}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
@@ -19,70 +20,74 @@ const TARGET_LABEL: Record<EditorInstance["target"], string> = {
|
||||
kubernetes: "K8s",
|
||||
};
|
||||
|
||||
const cols: TableColumn<EditorInstance>[] = [
|
||||
{
|
||||
key: "host",
|
||||
header: "Host",
|
||||
render: (i) => (
|
||||
<div className="portal-editor__cell-stack">
|
||||
<span className="portal-editor__cell-strong">{i.host}</span>
|
||||
<span className="portal-editor__cell-muted">
|
||||
<Chip size="sm" tone={TARGET_META[i.target].tone}>
|
||||
{TARGET_LABEL[i.target]}
|
||||
</Chip>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
header: "Region",
|
||||
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (i) => (
|
||||
<StatusBadge
|
||||
tone={INSTANCE_STATUS_TONE[i.status]}
|
||||
size="sm"
|
||||
pulse={i.status === "healthy"}
|
||||
>
|
||||
{INSTANCE_STATUS_LABEL[i.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastSeen",
|
||||
header: "Last seen",
|
||||
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
|
||||
},
|
||||
{
|
||||
key: "activeUsers",
|
||||
header: "Active users",
|
||||
align: "right",
|
||||
render: (i) => <span className="portal-editor__mono">{i.activeUsers}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
instances: EditorInstance[];
|
||||
}
|
||||
|
||||
/** Live health for every Editor instance reporting in to the org. */
|
||||
export function InstanceHealthTable({ instances }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cols: TableColumn<EditorInstance>[] = [
|
||||
{
|
||||
key: "host",
|
||||
header: t("editorAdmin.health.columns.host"),
|
||||
render: (i) => (
|
||||
<div className="portal-editor__cell-stack">
|
||||
<span className="portal-editor__cell-strong">{i.host}</span>
|
||||
<span className="portal-editor__cell-muted">
|
||||
<Chip size="sm" tone={TARGET_META[i.target].tone}>
|
||||
{TARGET_LABEL[i.target]}
|
||||
</Chip>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: t("editorAdmin.health.columns.version"),
|
||||
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
header: t("editorAdmin.health.columns.region"),
|
||||
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("editorAdmin.health.columns.status"),
|
||||
render: (i) => (
|
||||
<StatusBadge
|
||||
tone={INSTANCE_STATUS_TONE[i.status]}
|
||||
size="sm"
|
||||
pulse={i.status === "healthy"}
|
||||
>
|
||||
{INSTANCE_STATUS_LABEL[i.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastSeen",
|
||||
header: t("editorAdmin.health.columns.lastSeen"),
|
||||
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
|
||||
},
|
||||
{
|
||||
key: "activeUsers",
|
||||
header: t("editorAdmin.health.columns.activeUsers"),
|
||||
align: "right",
|
||||
render: (i) => (
|
||||
<span className="portal-editor__mono">{i.activeUsers}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No instances reporting"
|
||||
description="Deploy a target and pair it to see live instance health here."
|
||||
title={t("editorAdmin.health.empty.title")}
|
||||
description={t("editorAdmin.health.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<Table columns={cols} rows={instances} rowKey={(i) => i.id} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Card } from "@shared/components";
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
* shell with no submit endpoint yet.
|
||||
*/
|
||||
export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
|
||||
@@ -32,13 +34,13 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
<div className="portal-editor__panel-head">
|
||||
<div>
|
||||
<h3 className="portal-editor__panel-title">
|
||||
Air-gapped activation
|
||||
<span className="portal-editor__enterprise-tag">Enterprise</span>
|
||||
{t("editorAdmin.offlineActivation.title")}
|
||||
<span className="portal-editor__enterprise-tag">
|
||||
{t("editorAdmin.offlineActivation.enterpriseTag")}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="portal-editor__panel-sub">
|
||||
Generate a signed activation bundle for an offline or on-prem
|
||||
install with no outbound network path. Transfer it to the instance
|
||||
and apply it during first-run setup.
|
||||
{t("editorAdmin.offlineActivation.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +48,7 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
{!available ? (
|
||||
<div className="portal-editor__lock">
|
||||
<p className="portal-editor__lock-copy">
|
||||
Offline and on-prem activation is part of Enterprise.
|
||||
{t("editorAdmin.offlineActivation.lockCopy")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -54,7 +56,7 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
size="sm"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
Talk to sales
|
||||
{t("editorAdmin.offlineActivation.talkToSales")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -62,8 +64,11 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
{generated && (
|
||||
<Banner
|
||||
tone="success"
|
||||
title="Bundle ready"
|
||||
description="activation-acme-3.2.1.stirlingpkg is signed and ready to transfer. It activates one instance and expires in 14 days."
|
||||
title={t("editorAdmin.offlineActivation.readyBanner.title")}
|
||||
description={t(
|
||||
"editorAdmin.offlineActivation.readyBanner.description",
|
||||
{ file: "activation-acme-3.2.1.stirlingpkg" },
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="portal-editor__panel-actions">
|
||||
@@ -73,7 +78,7 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
loading={generating}
|
||||
onClick={generate}
|
||||
>
|
||||
Generate offline bundle
|
||||
{t("editorAdmin.offlineActivation.generateButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, Chip, CodeBlock } from "@shared/components";
|
||||
import type { PairingMethod, PairingOption } from "@portal/api/editorDeploy";
|
||||
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
* has no submit endpoint yet.
|
||||
*/
|
||||
export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
const { t } = useTranslation();
|
||||
// Tracks which option just got a (mock) rotate so we can flash confirmation.
|
||||
const [rotated, setRotated] = useState<PairingMethod | null>(null);
|
||||
|
||||
@@ -53,7 +55,7 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
{p.locked ? (
|
||||
<div className="portal-editor__lock">
|
||||
<p className="portal-editor__lock-copy">
|
||||
IaC provisioning is part of Enterprise.
|
||||
{t("editorAdmin.pairing.lockCopy")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -61,7 +63,7 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
accent="purple"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
Talk to sales
|
||||
{t("editorAdmin.pairing.talkToSales")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -85,10 +87,10 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
onClick={() => rotate(p.method)}
|
||||
>
|
||||
{rotated === p.method
|
||||
? "Generated ✓"
|
||||
? t("editorAdmin.pairing.generated")
|
||||
: p.method === "shortcode"
|
||||
? "Generate new code"
|
||||
: "Rotate"}
|
||||
? t("editorAdmin.pairing.generateNewCode")
|
||||
: t("editorAdmin.pairing.rotate")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge } from "@shared/components";
|
||||
import type { ApiKey } from "@portal/api/infrastructure";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
|
||||
/** Collapsible row for a single API key: header summary + expandable detail grid. */
|
||||
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Card padding="default" className="portal-infra__key">
|
||||
@@ -38,33 +40,35 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
<div className="portal-infra__key-body">
|
||||
<dl className="portal-infra__kv">
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.created")}</dt>
|
||||
<dd>{apiKey.created}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last used</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.lastUsed")}</dt>
|
||||
<dd>{apiKey.lastUsed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rate limit</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.rateLimit")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{apiKey.rateLimit.toLocaleString()} req/min
|
||||
{t("infrastructure.apiKeys.card.rateLimitValue", {
|
||||
value: apiKey.rateLimit.toLocaleString(),
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Usage today</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.usageToday")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{apiKey.usageToday.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Usage this month</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.usageMonth")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{apiKey.usageMonth.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Permissions</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.permissions")}</dt>
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.permissions.map((p) => (
|
||||
<Chip key={p} tone="blue" size="sm">
|
||||
@@ -74,11 +78,11 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
</dd>
|
||||
</div>
|
||||
<div className="portal-infra__kv-wide">
|
||||
<dt>Allowed IPs</dt>
|
||||
<dt>{t("infrastructure.apiKeys.card.allowedIps")}</dt>
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.allowedIps.length === 0 ? (
|
||||
<span className="portal-infra__muted">
|
||||
Any IP (no allowlist)
|
||||
{t("infrastructure.apiKeys.card.anyIp")}
|
||||
</span>
|
||||
) : (
|
||||
apiKey.allowedIps.map((ip) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, EmptyState, Skeleton } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -8,6 +9,7 @@ import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
|
||||
@@ -18,8 +20,8 @@ export function ApiKeysTab() {
|
||||
<div className="portal-infra__stack">
|
||||
<div className="portal-infra__bar">
|
||||
<SectionHeader
|
||||
title="API keys"
|
||||
sub="Scoped credentials with per-key rate limits, permissions, and IP allowlists."
|
||||
title={t("infrastructure.apiKeys.heading")}
|
||||
sub={t("infrastructure.apiKeys.subheading")}
|
||||
/>
|
||||
<Button
|
||||
variant="gradient"
|
||||
@@ -27,7 +29,7 @@ export function ApiKeysTab() {
|
||||
onClick={() => setModalOpen(true)}
|
||||
leadingIcon={<span aria-hidden>+</span>}
|
||||
>
|
||||
Create key
|
||||
{t("infrastructure.apiKeys.createKey")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +44,8 @@ export function ApiKeysTab() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No API keys yet"
|
||||
description="Create a scoped key to start calling the Stirling API."
|
||||
title={t("infrastructure.apiKeys.empty.title")}
|
||||
description={t("infrastructure.apiKeys.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
EmptyState,
|
||||
@@ -28,59 +29,69 @@ import {
|
||||
|
||||
type AuditFilter = "all" | AuditCategory;
|
||||
|
||||
const AUDIT_FILTERS: TabItem<AuditFilter>[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "auth", label: "Auth" },
|
||||
{ key: "config", label: "Config" },
|
||||
{ key: "elevation", label: "Elevation" },
|
||||
{ key: "processing", label: "Processing" },
|
||||
{ key: "security", label: "Security" },
|
||||
];
|
||||
|
||||
const cols: TableColumn<AuditEvent>[] = [
|
||||
{
|
||||
key: "timestamp",
|
||||
header: "Timestamp",
|
||||
render: (e) => <span className="portal-infra__mono">{e.timestamp}</span>,
|
||||
},
|
||||
{
|
||||
key: "event",
|
||||
header: "Event",
|
||||
render: (e) => (
|
||||
<div className="portal-infra__event">
|
||||
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
|
||||
{AUDIT_CAT_LABEL[e.category]}
|
||||
</StatusBadge>
|
||||
<span>{e.action}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actor",
|
||||
header: "Actor",
|
||||
render: (e) => <span className="portal-infra__mono">{e.actor}</span>,
|
||||
},
|
||||
{ key: "target", header: "Target", render: (e) => e.target },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (e) => (
|
||||
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
|
||||
{titleCase(e.status)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: "Latency",
|
||||
align: "right",
|
||||
render: (e) => <span className="portal-infra__mono">{e.latencyMs} ms</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export function AuditTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [filter, setFilter] = useState<AuditFilter>("all");
|
||||
|
||||
const auditFilters: TabItem<AuditFilter>[] = [
|
||||
{ key: "all", label: t("infrastructure.audit.filters.all") },
|
||||
{ key: "auth", label: t("infrastructure.audit.filters.auth") },
|
||||
{ key: "config", label: t("infrastructure.audit.filters.config") },
|
||||
{ key: "elevation", label: t("infrastructure.audit.filters.elevation") },
|
||||
{ key: "processing", label: t("infrastructure.audit.filters.processing") },
|
||||
{ key: "security", label: t("infrastructure.audit.filters.security") },
|
||||
];
|
||||
|
||||
const cols: TableColumn<AuditEvent>[] = [
|
||||
{
|
||||
key: "timestamp",
|
||||
header: t("infrastructure.audit.columns.timestamp"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.timestamp}</span>,
|
||||
},
|
||||
{
|
||||
key: "event",
|
||||
header: t("infrastructure.audit.columns.event"),
|
||||
render: (e) => (
|
||||
<div className="portal-infra__event">
|
||||
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
|
||||
{AUDIT_CAT_LABEL[e.category]}
|
||||
</StatusBadge>
|
||||
<span>{e.action}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actor",
|
||||
header: t("infrastructure.audit.columns.actor"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.actor}</span>,
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
header: t("infrastructure.audit.columns.target"),
|
||||
render: (e) => e.target,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("infrastructure.audit.columns.status"),
|
||||
render: (e) => (
|
||||
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
|
||||
{titleCase(e.status)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: t("infrastructure.audit.columns.latency"),
|
||||
align: "right",
|
||||
render: (e) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("infrastructure.audit.latencyValue", { value: e.latencyMs })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const state = useAsync<AuditLogResponse>(() => fetchAuditLog(tier), [tier]);
|
||||
const { data } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
@@ -94,37 +105,37 @@ export function AuditTab() {
|
||||
return (
|
||||
<div className="portal-infra__stack">
|
||||
<SectionHeader
|
||||
title="Audit logs"
|
||||
sub="Every authentication, configuration, and processing event across your workspace."
|
||||
title={t("infrastructure.audit.heading")}
|
||||
sub={t("infrastructure.audit.subheading")}
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<section className="portal-infra__metrics">
|
||||
<MetricCard
|
||||
label="Total events · 24h"
|
||||
label={t("infrastructure.audit.metrics.totalEvents")}
|
||||
value={data.summary.totalEvents.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Processing"
|
||||
label={t("infrastructure.audit.metrics.processing")}
|
||||
value={data.summary.processing.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Elevation"
|
||||
label={t("infrastructure.audit.metrics.elevation")}
|
||||
value={data.summary.elevation.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Config"
|
||||
label={t("infrastructure.audit.metrics.config")}
|
||||
value={data.summary.config.toLocaleString()}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Tabs<AuditFilter>
|
||||
items={AUDIT_FILTERS}
|
||||
items={auditFilters}
|
||||
activeKey={filter}
|
||||
onChange={setFilter}
|
||||
variant="pill"
|
||||
ariaLabel="Filter audit events by category"
|
||||
ariaLabel={t("infrastructure.audit.filterAriaLabel")}
|
||||
/>
|
||||
|
||||
<Card padding="none">
|
||||
@@ -132,8 +143,8 @@ export function AuditTab() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No audit events"
|
||||
description="Workspace activity will appear here as it happens."
|
||||
title={t("infrastructure.audit.empty.title")}
|
||||
description={t("infrastructure.audit.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && data && (
|
||||
@@ -141,7 +152,7 @@ export function AuditTab() {
|
||||
columns={cols}
|
||||
rows={rows}
|
||||
rowKey={(e) => e.id}
|
||||
empty="No events in this category."
|
||||
empty={t("infrastructure.audit.noEventsInCategory")}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -23,6 +24,7 @@ export function CreateKeyModal({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
|
||||
const [ips, setIps] = useState("");
|
||||
@@ -58,28 +60,32 @@ export function CreateKeyModal({
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="md"
|
||||
title={created ? "Key created" : "Create API key"}
|
||||
title={
|
||||
created
|
||||
? t("infrastructure.createKey.titleCreated")
|
||||
: t("infrastructure.createKey.title")
|
||||
}
|
||||
subtitle={
|
||||
created
|
||||
? "Copy this secret now — it won't be shown again."
|
||||
: "Scope the key to the minimum it needs. You can rotate or revoke at any time."
|
||||
? t("infrastructure.createKey.subtitleCreated")
|
||||
: t("infrastructure.createKey.subtitle")
|
||||
}
|
||||
footer={
|
||||
created ? (
|
||||
<Button variant="gradient" onClick={close}>
|
||||
Done
|
||||
{t("infrastructure.createKey.done")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="portal-infra__modal-actions">
|
||||
<Button variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
{t("infrastructure.createKey.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
disabled={name.trim() === "" || perms.length === 0}
|
||||
onClick={createKey}
|
||||
>
|
||||
Create key
|
||||
{t("infrastructure.createKey.createKey")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -90,24 +96,27 @@ export function CreateKeyModal({
|
||||
<CodeBlock
|
||||
code={DEMO_NEW_KEY_SECRET}
|
||||
lang="bash"
|
||||
caption="Secret key"
|
||||
caption={t("infrastructure.createKey.secretKeyCaption")}
|
||||
/>
|
||||
<Banner
|
||||
tone="warning"
|
||||
description="Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
|
||||
description={t("infrastructure.createKey.secretWarning")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-infra__form">
|
||||
<FormField label="Key name" required>
|
||||
<FormField
|
||||
label={t("infrastructure.createKey.keyNameLabel")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Production · ingest"
|
||||
placeholder={t("infrastructure.createKey.keyNamePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Permissions">
|
||||
<FormField label={t("infrastructure.createKey.permissionsLabel")}>
|
||||
<div className="portal-infra__perm-row">
|
||||
{PERMISSION_OPTS.map((p) => (
|
||||
<Checkbox
|
||||
@@ -121,8 +130,8 @@ export function CreateKeyModal({
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="IP allowlist"
|
||||
helperText="Comma-separated CIDR ranges. Leave blank to allow any IP."
|
||||
label={t("infrastructure.createKey.ipAllowlistLabel")}
|
||||
helperText={t("infrastructure.createKey.ipAllowlistHelper")}
|
||||
>
|
||||
<Input
|
||||
value={ips}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
@@ -25,132 +26,8 @@ import {
|
||||
titleCase,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const regionCols: TableColumn<DeploymentRegion>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "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: "Latency",
|
||||
align: "right",
|
||||
render: (r) => <span className="portal-infra__mono">{r.latencyMs} ms</span>,
|
||||
},
|
||||
{
|
||||
key: "load",
|
||||
header: "Load",
|
||||
width: "9rem",
|
||||
render: (r) => (
|
||||
<div className="portal-infra__load">
|
||||
<ProgressBar value={r.load} thresholded height={6} />
|
||||
<span className="portal-infra__load-pct">{pct(r.load)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (r) => (
|
||||
<StatusBadge
|
||||
tone={REGION_TONE[r.status]}
|
||||
size="sm"
|
||||
pulse={r.status === "healthy"}
|
||||
>
|
||||
{titleCase(r.status)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
render: (r) => <code className="portal-infra__cell-code">{r.version}</code>,
|
||||
},
|
||||
{
|
||||
key: "uptime",
|
||||
header: "Uptime",
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span className="portal-infra__mono">{pct(r.uptime, 3)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "instances",
|
||||
header: "Instances",
|
||||
align: "right",
|
||||
render: (r) => <span className="portal-infra__mono">{r.instances}</span>,
|
||||
},
|
||||
{
|
||||
key: "throughput",
|
||||
header: "Throughput",
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span className="portal-infra__mono">
|
||||
{r.throughput.toLocaleString()}/min
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "p99",
|
||||
header: "P99",
|
||||
align: "right",
|
||||
render: (r) => <span className="portal-infra__mono">{r.p99Ms} ms</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const deployCols: TableColumn<RecentDeployment>[] = [
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
render: (d) => <code className="portal-infra__cell-code">{d.version}</code>,
|
||||
},
|
||||
{
|
||||
key: "environment",
|
||||
header: "Environment",
|
||||
render: (d) => (
|
||||
<Chip
|
||||
tone={
|
||||
d.environment === "production"
|
||||
? "blue"
|
||||
: d.environment === "canary"
|
||||
? "purple"
|
||||
: "neutral"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{d.environment}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{ key: "product", header: "Product", render: (d) => d.product },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
|
||||
{DEPLOY_LABEL[d.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "deployedBy",
|
||||
header: "Deployed by",
|
||||
render: (d) => <span className="portal-infra__mono">{d.deployedBy}</span>,
|
||||
},
|
||||
{
|
||||
key: "timestamp",
|
||||
header: "When",
|
||||
align: "right",
|
||||
render: (d) => <span className="portal-infra__muted">{d.timestamp}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentsTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<DeploymentsResponse>(
|
||||
() => fetchDeployments(tier),
|
||||
@@ -159,20 +36,165 @@ export function DeploymentsTab() {
|
||||
const { data } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
const regionCols: TableColumn<DeploymentRegion>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: t("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("infrastructure.deployments.regionColumns.latency"),
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("infrastructure.deployments.msValue", { value: r.latencyMs })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "load",
|
||||
header: t("infrastructure.deployments.regionColumns.load"),
|
||||
width: "9rem",
|
||||
render: (r) => (
|
||||
<div className="portal-infra__load">
|
||||
<ProgressBar value={r.load} thresholded height={6} />
|
||||
<span className="portal-infra__load-pct">{pct(r.load)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("infrastructure.deployments.regionColumns.status"),
|
||||
render: (r) => (
|
||||
<StatusBadge
|
||||
tone={REGION_TONE[r.status]}
|
||||
size="sm"
|
||||
pulse={r.status === "healthy"}
|
||||
>
|
||||
{titleCase(r.status)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: t("infrastructure.deployments.regionColumns.version"),
|
||||
render: (r) => (
|
||||
<code className="portal-infra__cell-code">{r.version}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "uptime",
|
||||
header: t("infrastructure.deployments.regionColumns.uptime"),
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span className="portal-infra__mono">{pct(r.uptime, 3)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "instances",
|
||||
header: t("infrastructure.deployments.regionColumns.instances"),
|
||||
align: "right",
|
||||
render: (r) => <span className="portal-infra__mono">{r.instances}</span>,
|
||||
},
|
||||
{
|
||||
key: "throughput",
|
||||
header: t("infrastructure.deployments.regionColumns.throughput"),
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("infrastructure.deployments.throughputValue", {
|
||||
value: r.throughput.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "p99",
|
||||
header: t("infrastructure.deployments.regionColumns.p99"),
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("infrastructure.deployments.msValue", { value: r.p99Ms })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const deployCols: TableColumn<RecentDeployment>[] = [
|
||||
{
|
||||
key: "version",
|
||||
header: t("infrastructure.deployments.deployColumns.version"),
|
||||
render: (d) => (
|
||||
<code className="portal-infra__cell-code">{d.version}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "environment",
|
||||
header: t("infrastructure.deployments.deployColumns.environment"),
|
||||
render: (d) => (
|
||||
<Chip
|
||||
tone={
|
||||
d.environment === "production"
|
||||
? "blue"
|
||||
: d.environment === "canary"
|
||||
? "purple"
|
||||
: "neutral"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{d.environment}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "product",
|
||||
header: t("infrastructure.deployments.deployColumns.product"),
|
||||
render: (d) => d.product,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("infrastructure.deployments.deployColumns.status"),
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
|
||||
{DEPLOY_LABEL[d.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "deployedBy",
|
||||
header: t("infrastructure.deployments.deployColumns.deployedBy"),
|
||||
render: (d) => <span className="portal-infra__mono">{d.deployedBy}</span>,
|
||||
},
|
||||
{
|
||||
key: "timestamp",
|
||||
header: t("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="Regions"
|
||||
sub="Live health for every deployed Stirling region — latency, load, and rollout version."
|
||||
title={t("infrastructure.deployments.regions.heading")}
|
||||
sub={t("infrastructure.deployments.regions.subheading")}
|
||||
/>
|
||||
<Card padding="none">
|
||||
{isLoading && <TableSkeleton rows={3} cols={9} />}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No regions deployed"
|
||||
description="Deployed regions appear here once your workspace is provisioned."
|
||||
title={t("infrastructure.deployments.regions.empty.title")}
|
||||
description={t(
|
||||
"infrastructure.deployments.regions.empty.description",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && data && data.regions.length > 0 && (
|
||||
@@ -187,8 +209,8 @@ export function DeploymentsTab() {
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Recent deployments"
|
||||
sub="The latest rollouts across products and environments."
|
||||
title={t("infrastructure.deployments.recent.heading")}
|
||||
sub={t("infrastructure.deployments.recent.subheading")}
|
||||
/>
|
||||
<Card padding="none">
|
||||
{isLoading && <TableSkeleton rows={4} cols={6} />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Card,
|
||||
@@ -31,81 +32,88 @@ import {
|
||||
pct,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const modelCols: TableColumn<ModelEntry>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Model",
|
||||
render: (m) => (
|
||||
<div className="portal-infra__cell-stack">
|
||||
<span className="portal-infra__cell-strong">{m.name}</span>
|
||||
<Chip tone="neutral" size="sm">
|
||||
{MODEL_PROVIDER_LABEL[m.provider]}
|
||||
</Chip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Type",
|
||||
render: (m) => (
|
||||
<Chip tone={MODEL_TYPE_TONE[m.type]} size="sm">
|
||||
{MODEL_TYPE_LABEL[m.type]}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (m) => (
|
||||
<StatusBadge
|
||||
tone={MODEL_TONE[m.status]}
|
||||
size="sm"
|
||||
pulse={m.status === "active"}
|
||||
>
|
||||
{MODEL_LABEL[m.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "load",
|
||||
header: "Load",
|
||||
width: "9rem",
|
||||
render: (m) => (
|
||||
<div className="portal-infra__load">
|
||||
<ProgressBar value={m.load} thresholded height={6} />
|
||||
<span className="portal-infra__load-pct">{pct(m.load)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: "Latency",
|
||||
align: "right",
|
||||
render: (m) => <span className="portal-infra__mono">{m.latencyMs} ms</span>,
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
header: "Cost",
|
||||
align: "right",
|
||||
render: (m) => (
|
||||
<span className="portal-infra__mono">
|
||||
{modelCost(m.cost, m.costUnit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
render: (m) => <code className="portal-infra__cell-code">{m.version}</code>,
|
||||
},
|
||||
];
|
||||
|
||||
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("infrastructure.models.columns.model"),
|
||||
render: (m) => (
|
||||
<div className="portal-infra__cell-stack">
|
||||
<span className="portal-infra__cell-strong">{m.name}</span>
|
||||
<Chip tone="neutral" size="sm">
|
||||
{MODEL_PROVIDER_LABEL[m.provider]}
|
||||
</Chip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: t("infrastructure.models.columns.type"),
|
||||
render: (m) => (
|
||||
<Chip tone={MODEL_TYPE_TONE[m.type]} size="sm">
|
||||
{MODEL_TYPE_LABEL[m.type]}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("infrastructure.models.columns.status"),
|
||||
render: (m) => (
|
||||
<StatusBadge
|
||||
tone={MODEL_TONE[m.status]}
|
||||
size="sm"
|
||||
pulse={m.status === "active"}
|
||||
>
|
||||
{MODEL_LABEL[m.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "load",
|
||||
header: t("infrastructure.models.columns.load"),
|
||||
width: "9rem",
|
||||
render: (m) => (
|
||||
<div className="portal-infra__load">
|
||||
<ProgressBar value={m.load} thresholded height={6} />
|
||||
<span className="portal-infra__load-pct">{pct(m.load)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: t("infrastructure.models.columns.latency"),
|
||||
align: "right",
|
||||
render: (m) => (
|
||||
<span className="portal-infra__mono">
|
||||
{t("infrastructure.models.msValue", { value: m.latencyMs })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
header: t("infrastructure.models.columns.cost"),
|
||||
align: "right",
|
||||
render: (m) => (
|
||||
<span className="portal-infra__mono">
|
||||
{modelCost(m.cost, m.costUnit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: t("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";
|
||||
@@ -121,29 +129,35 @@ export function ModelsTab() {
|
||||
const routingCols: TableColumn<RoutingRule>[] = [
|
||||
{
|
||||
key: "operation",
|
||||
header: "Operation",
|
||||
header: t("infrastructure.models.routingColumns.operation"),
|
||||
render: (r) => (
|
||||
<div className="portal-infra__cell-stack">
|
||||
<span className="portal-infra__cell-strong">{r.operation}</span>
|
||||
{r.isDefault && (
|
||||
<Chip tone="blue" size="sm">
|
||||
Default
|
||||
{t("infrastructure.models.routingColumns.default")}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "docType", header: "Document type", render: (r) => r.docType },
|
||||
{
|
||||
key: "docType",
|
||||
header: t("infrastructure.models.routingColumns.docType"),
|
||||
render: (r) => r.docType,
|
||||
},
|
||||
{
|
||||
key: "modelId",
|
||||
header: "Routed to",
|
||||
header: t("infrastructure.models.routingColumns.routedTo"),
|
||||
width: "16rem",
|
||||
render: (r) => (
|
||||
<Select
|
||||
inputSize="sm"
|
||||
options={modelOptions}
|
||||
defaultValue={r.modelId}
|
||||
aria-label={`Model for ${r.operation}`}
|
||||
aria-label={t("infrastructure.models.routingColumns.modelForAria", {
|
||||
operation: r.operation,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -152,23 +166,28 @@ export function ModelsTab() {
|
||||
return (
|
||||
<div className="portal-infra__stack">
|
||||
<SectionHeader
|
||||
title="Models"
|
||||
sub="The model catalogue and routing that powers document processing across your workspace."
|
||||
title={t("infrastructure.models.heading")}
|
||||
sub={t("infrastructure.models.subheading")}
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<section className="portal-infra__metrics">
|
||||
<MetricCard label="Active models" value={data.summary.activeModels} />
|
||||
<MetricCard
|
||||
label="Avg latency"
|
||||
value={`${data.summary.avgLatencyMs} ms`}
|
||||
label={t("infrastructure.models.metrics.activeModels")}
|
||||
value={data.summary.activeModels}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Monthly model spend"
|
||||
label={t("infrastructure.models.metrics.avgLatency")}
|
||||
value={t("infrastructure.models.msValue", {
|
||||
value: data.summary.avgLatencyMs,
|
||||
})}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("infrastructure.models.metrics.monthlySpend")}
|
||||
value={
|
||||
data.summary.monthlySpend > 0
|
||||
? `$${data.summary.monthlySpend.toLocaleString()}`
|
||||
: "Included"
|
||||
: t("infrastructure.models.metrics.included")
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
@@ -176,11 +195,11 @@ export function ModelsTab() {
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Catalogue"
|
||||
title={t("infrastructure.models.catalogue.heading")}
|
||||
sub={
|
||||
tier === "enterprise"
|
||||
? "Managed, bring-your-own, and on-prem models — with per-region pinning available."
|
||||
: "Managed models available to your workspace, with live latency and cost."
|
||||
? t("infrastructure.models.catalogue.subEnterprise")
|
||||
: t("infrastructure.models.catalogue.sub")
|
||||
}
|
||||
/>
|
||||
<Card padding="none">
|
||||
@@ -188,8 +207,10 @@ export function ModelsTab() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No models available"
|
||||
description="Models in your workspace's catalogue appear here."
|
||||
title={t("infrastructure.models.catalogue.empty.title")}
|
||||
description={t(
|
||||
"infrastructure.models.catalogue.empty.description",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && data && data.models.length > 0 && (
|
||||
@@ -205,18 +226,18 @@ export function ModelsTab() {
|
||||
{tier === "enterprise" && (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="Bring your own model"
|
||||
description="Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing."
|
||||
title={t("infrastructure.models.byom.title")}
|
||||
description={t("infrastructure.models.byom.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Routing rules"
|
||||
title={t("infrastructure.models.routing.heading")}
|
||||
sub={
|
||||
canRoute
|
||||
? "Which model handles each operation. The default applies when no narrower rule matches."
|
||||
: "Route operations to specific models — available on paid plans."
|
||||
? t("infrastructure.models.routing.sub")
|
||||
: t("infrastructure.models.routing.subLocked")
|
||||
}
|
||||
/>
|
||||
{canRoute ? (
|
||||
@@ -227,15 +248,17 @@ export function ModelsTab() {
|
||||
columns={routingCols}
|
||||
rows={data.routing}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No routing rules configured."
|
||||
empty={t("infrastructure.models.routing.empty")}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="Model routing is a paid feature"
|
||||
description="Upgrade to Pro to control which model handles each operation and document type."
|
||||
title={t("infrastructure.models.routing.lockedBanner.title")}
|
||||
description={t(
|
||||
"infrastructure.models.routing.lockedBanner.description",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -30,62 +31,73 @@ import {
|
||||
KEY_MODE_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const ACCESS_OPTS: RadioOption<AccessPolicy>[] = [
|
||||
{
|
||||
value: "stirling",
|
||||
label: "Stirling-held keys",
|
||||
description:
|
||||
"Stirling manages encryption keys. Simplest — zero key ops on your side.",
|
||||
},
|
||||
{
|
||||
value: "byok",
|
||||
label: "Bring your own key (BYOK)",
|
||||
description:
|
||||
"Supply a key from your own KMS. Stirling encrypts with it but can still read.",
|
||||
},
|
||||
{
|
||||
value: "hyok",
|
||||
label: "Hold your own key (HYOK)",
|
||||
description: "Keys never leave your KMS. Stirling holds only ciphertext.",
|
||||
},
|
||||
];
|
||||
|
||||
const RESIDENCY_OPTS: RadioOption<DataResidency>[] = [
|
||||
{ value: "us", label: "United States", description: "us-east-1 · us-west-2" },
|
||||
{
|
||||
value: "eu",
|
||||
label: "European Union",
|
||||
description: "eu-west-1 · GDPR data boundary",
|
||||
},
|
||||
{ value: "apac", label: "Asia Pacific", description: "ap-southeast-1" },
|
||||
];
|
||||
|
||||
const ipCols: TableColumn<SecurityConfig["ipAllowlist"][number]>[] = [
|
||||
{ key: "label", header: "Label", render: (e) => e.label },
|
||||
{
|
||||
key: "cidr",
|
||||
header: "CIDR",
|
||||
render: (e) => <code className="portal-infra__cell-code">{e.cidr}</code>,
|
||||
},
|
||||
{
|
||||
key: "addedBy",
|
||||
header: "Added by",
|
||||
render: (e) => <span className="portal-infra__mono">{e.addedBy}</span>,
|
||||
},
|
||||
{
|
||||
key: "added",
|
||||
header: "Added",
|
||||
align: "right",
|
||||
render: (e) => <span className="portal-infra__muted">{e.added}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
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("infrastructure.security.access.stirling.label"),
|
||||
description: t("infrastructure.security.access.stirling.description"),
|
||||
},
|
||||
{
|
||||
value: "byok",
|
||||
label: t("infrastructure.security.access.byok.label"),
|
||||
description: t("infrastructure.security.access.byok.description"),
|
||||
},
|
||||
{
|
||||
value: "hyok",
|
||||
label: t("infrastructure.security.access.hyok.label"),
|
||||
description: t("infrastructure.security.access.hyok.description"),
|
||||
},
|
||||
];
|
||||
|
||||
const RESIDENCY_OPTS: RadioOption<DataResidency>[] = [
|
||||
{
|
||||
value: "us",
|
||||
label: t("infrastructure.security.residency.us.label"),
|
||||
description: t("infrastructure.security.residency.us.description"),
|
||||
},
|
||||
{
|
||||
value: "eu",
|
||||
label: t("infrastructure.security.residency.eu.label"),
|
||||
description: t("infrastructure.security.residency.eu.description"),
|
||||
},
|
||||
{
|
||||
value: "apac",
|
||||
label: t("infrastructure.security.residency.apac.label"),
|
||||
description: t("infrastructure.security.residency.apac.description"),
|
||||
},
|
||||
];
|
||||
|
||||
const ipCols: TableColumn<SecurityConfig["ipAllowlist"][number]>[] = [
|
||||
{
|
||||
key: "label",
|
||||
header: t("infrastructure.security.ipColumns.label"),
|
||||
render: (e) => e.label,
|
||||
},
|
||||
{
|
||||
key: "cidr",
|
||||
header: t("infrastructure.security.ipColumns.cidr"),
|
||||
render: (e) => <code className="portal-infra__cell-code">{e.cidr}</code>,
|
||||
},
|
||||
{
|
||||
key: "addedBy",
|
||||
header: t("infrastructure.security.ipColumns.addedBy"),
|
||||
render: (e) => <span className="portal-infra__mono">{e.addedBy}</span>,
|
||||
},
|
||||
{
|
||||
key: "added",
|
||||
header: t("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 }
|
||||
@@ -108,8 +120,8 @@ export function SecurityTab() {
|
||||
return (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="Security posture unavailable"
|
||||
description="Your workspace's security configuration will appear here."
|
||||
title={t("infrastructure.security.empty.title")}
|
||||
description={t("infrastructure.security.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -119,8 +131,8 @@ export function SecurityTab() {
|
||||
<section className="portal-infra__split">
|
||||
<Card padding="loose">
|
||||
<SectionHeader
|
||||
title="Document access policy"
|
||||
sub="Controls who can decrypt processed documents at rest."
|
||||
title={t("infrastructure.security.accessPolicy.heading")}
|
||||
sub={t("infrastructure.security.accessPolicy.subheading")}
|
||||
/>
|
||||
<RadioGroup
|
||||
name="access-policy"
|
||||
@@ -132,16 +144,16 @@ export function SecurityTab() {
|
||||
<Banner
|
||||
tone="success"
|
||||
className="portal-infra__banner"
|
||||
title="Stirling cannot decrypt your documents"
|
||||
description="With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time."
|
||||
title={t("infrastructure.security.hyokBanner.title")}
|
||||
description={t("infrastructure.security.hyokBanner.description")}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padding="loose">
|
||||
<SectionHeader
|
||||
title="Data residency"
|
||||
sub="Where documents are stored and processed."
|
||||
title={t("infrastructure.security.residencyHeader.heading")}
|
||||
sub={t("infrastructure.security.residencyHeader.subheading")}
|
||||
/>
|
||||
<RadioGroup
|
||||
name="data-residency"
|
||||
@@ -154,8 +166,8 @@ export function SecurityTab() {
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Encryption key management"
|
||||
sub="Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate."
|
||||
title={t("infrastructure.security.keyManagement.heading")}
|
||||
sub={t("infrastructure.security.keyManagement.subheading")}
|
||||
/>
|
||||
<Card padding="loose" className="portal-infra__keymgmt">
|
||||
<div className="portal-infra__keymgmt-head">
|
||||
@@ -180,13 +192,13 @@ export function SecurityTab() {
|
||||
// TODO(backend): POST /v1/infrastructure/security/keys/rotate
|
||||
}}
|
||||
>
|
||||
Rotate key
|
||||
{t("infrastructure.security.keyManagement.rotateKey")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<dl className="portal-infra__kv">
|
||||
<div className="portal-infra__kv-wide">
|
||||
<dt>Key identifier</dt>
|
||||
<dt>{t("infrastructure.security.keyManagement.keyId")}</dt>
|
||||
<dd>
|
||||
<code className="portal-infra__cell-code">
|
||||
{data.keyManagement.keyId}
|
||||
@@ -194,17 +206,19 @@ export function SecurityTab() {
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Algorithm</dt>
|
||||
<dt>{t("infrastructure.security.keyManagement.algorithm")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{data.keyManagement.algorithm}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last rotated</dt>
|
||||
<dt>{t("infrastructure.security.keyManagement.lastRotated")}</dt>
|
||||
<dd>{data.keyManagement.lastRotated}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rotation policy</dt>
|
||||
<dt>
|
||||
{t("infrastructure.security.keyManagement.rotationPolicy")}
|
||||
</dt>
|
||||
<dd>{data.keyManagement.rotationPolicy}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -213,8 +227,10 @@ export function SecurityTab() {
|
||||
<Banner
|
||||
tone="info"
|
||||
className="portal-infra__banner"
|
||||
title="Keys are managed by Stirling on your plan"
|
||||
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={t("infrastructure.security.managedBanner.title")}
|
||||
description={t(
|
||||
"infrastructure.security.managedBanner.description",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -222,8 +238,8 @@ export function SecurityTab() {
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Compliance"
|
||||
sub="Attestations and certifications covering the Stirling platform."
|
||||
title={t("infrastructure.security.compliance.heading")}
|
||||
sub={t("infrastructure.security.compliance.subheading")}
|
||||
/>
|
||||
<div className="portal-infra__certs">
|
||||
{data.certs.map((c) => (
|
||||
@@ -242,8 +258,8 @@ export function SecurityTab() {
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Compliance attestations"
|
||||
sub="Framework-by-framework audit posture, with reports available on attested controls."
|
||||
title={t("infrastructure.security.attestations.heading")}
|
||||
sub={t("infrastructure.security.attestations.subheading")}
|
||||
/>
|
||||
<div className="portal-infra__attestations">
|
||||
{data.attestations.map((a) => (
|
||||
@@ -269,10 +285,12 @@ export function SecurityTab() {
|
||||
// TODO(backend): GET /v1/infrastructure/security/reports/:id
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
View report →
|
||||
{t("infrastructure.security.attestations.viewReport")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="portal-infra__muted">No report available</span>
|
||||
<span className="portal-infra__muted">
|
||||
{t("infrastructure.security.attestations.noReport")}
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
@@ -281,18 +299,20 @@ export function SecurityTab() {
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="IP allowlist"
|
||||
title={t("infrastructure.security.ipAllowlist.heading")}
|
||||
sub={
|
||||
tier === "free"
|
||||
? "Restrict API access to known IP ranges — available on paid plans."
|
||||
: "API access is restricted to these CIDR ranges."
|
||||
? t("infrastructure.security.ipAllowlist.subLocked")
|
||||
: t("infrastructure.security.ipAllowlist.sub")
|
||||
}
|
||||
/>
|
||||
{tier === "free" ? (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="IP allowlisting is a paid feature"
|
||||
description="Upgrade to Pro to restrict API access to specific networks."
|
||||
title={t("infrastructure.security.ipAllowlist.lockedBanner.title")}
|
||||
description={t(
|
||||
"infrastructure.security.ipAllowlist.lockedBanner.description",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Card padding="none">
|
||||
@@ -300,7 +320,7 @@ export function SecurityTab() {
|
||||
columns={ipCols}
|
||||
rows={data.ipAllowlist}
|
||||
rowKey={(e) => e.id}
|
||||
empty="No IP ranges configured — all IPs allowed."
|
||||
empty={t("infrastructure.security.ipAllowlist.empty")}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -19,14 +20,6 @@ import {
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
import { pct } from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const RETENTION_OPTS = [
|
||||
{ value: "30", label: "30 days" },
|
||||
{ value: "60", label: "60 days" },
|
||||
{ value: "90", label: "90 days" },
|
||||
{ value: "180", label: "180 days" },
|
||||
{ value: "never", label: "Never delete" },
|
||||
];
|
||||
|
||||
const PROVIDER_GLYPH: Record<
|
||||
StorageConfig["providers"][number]["kind"],
|
||||
string
|
||||
@@ -40,11 +33,35 @@ const PROVIDER_GLYPH: Record<
|
||||
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("infrastructure.storage.retentionOption.days", { count: 30 }),
|
||||
},
|
||||
{
|
||||
value: "60",
|
||||
label: t("infrastructure.storage.retentionOption.days", { count: 60 }),
|
||||
},
|
||||
{
|
||||
value: "90",
|
||||
label: t("infrastructure.storage.retentionOption.days", { count: 90 }),
|
||||
},
|
||||
{
|
||||
value: "180",
|
||||
label: t("infrastructure.storage.retentionOption.days", { count: 180 }),
|
||||
},
|
||||
{
|
||||
value: "never",
|
||||
label: t("infrastructure.storage.retentionOption.never"),
|
||||
},
|
||||
];
|
||||
|
||||
// TODO(backend): PATCH /v1/infrastructure/storage { retention }
|
||||
const [retention, setRetention] = useState<RetentionWindow | null>(null);
|
||||
const retentionValue = retention ?? data?.retention ?? "90";
|
||||
@@ -62,8 +79,8 @@ export function StorageTab() {
|
||||
return (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No storage configured"
|
||||
description="Connected storage and usage appear here."
|
||||
title={t("infrastructure.storage.empty.title")}
|
||||
description={t("infrastructure.storage.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -75,20 +92,27 @@ export function StorageTab() {
|
||||
<div className="portal-infra__stack">
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Total usage"
|
||||
sub="Storage consumed across all connected providers."
|
||||
title={t("infrastructure.storage.totalUsage.heading")}
|
||||
sub={t("infrastructure.storage.totalUsage.subheading")}
|
||||
/>
|
||||
<Card padding="loose">
|
||||
<div className="portal-infra__usage-head">
|
||||
<span className="portal-infra__usage-value">
|
||||
{data.usedGb.toLocaleString()} GB
|
||||
{t("infrastructure.storage.gbValue", {
|
||||
value: data.usedGb.toLocaleString(),
|
||||
})}
|
||||
<span className="portal-infra__muted">
|
||||
{" "}
|
||||
/ {data.quotaGb.toLocaleString()} GB
|
||||
/{" "}
|
||||
{t("infrastructure.storage.gbValue", {
|
||||
value: data.quotaGb.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<StatusBadge tone={overThreshold ? "danger" : "success"} size="sm">
|
||||
{pct(usedFrac)} used
|
||||
{t("infrastructure.storage.percentUsed", {
|
||||
value: pct(usedFrac),
|
||||
})}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -99,7 +123,7 @@ export function StorageTab() {
|
||||
? "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="Storage used"
|
||||
label={t("infrastructure.storage.totalUsage.progressLabel")}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -107,8 +131,8 @@ export function StorageTab() {
|
||||
<section className="portal-infra__split">
|
||||
<Card padding="loose">
|
||||
<SectionHeader
|
||||
title="Connected providers"
|
||||
sub="Where processed artifacts are written."
|
||||
title={t("infrastructure.storage.providers.heading")}
|
||||
sub={t("infrastructure.storage.providers.subheading")}
|
||||
/>
|
||||
<ul className="portal-infra__providers">
|
||||
{data.providers.map((p) => (
|
||||
@@ -122,16 +146,18 @@ export function StorageTab() {
|
||||
</span>
|
||||
{p.connected ? (
|
||||
<span className="portal-infra__provider-meta">
|
||||
<span className="portal-infra__mono">{p.usedGb} GB</span>
|
||||
<span className="portal-infra__mono">
|
||||
{t("infrastructure.storage.gbValue", { value: p.usedGb })}
|
||||
</span>
|
||||
<StatusBadge tone="success" size="sm">
|
||||
Connected
|
||||
{t("infrastructure.storage.providers.connected")}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
) : (
|
||||
// TODO(backend): launch the provider OAuth/credential flow,
|
||||
// then POST /v1/infrastructure/storage/providers/{id}/connect
|
||||
<Button variant="outline" size="sm">
|
||||
Connect
|
||||
{t("infrastructure.storage.providers.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
@@ -141,10 +167,10 @@ export function StorageTab() {
|
||||
|
||||
<Card padding="loose">
|
||||
<SectionHeader
|
||||
title="Retention"
|
||||
sub="How long artifacts are kept before lifecycle deletion."
|
||||
title={t("infrastructure.storage.retention.heading")}
|
||||
sub={t("infrastructure.storage.retention.subheading")}
|
||||
/>
|
||||
<FormField label="Default retention window">
|
||||
<FormField label={t("infrastructure.storage.retention.windowLabel")}>
|
||||
<Select
|
||||
options={RETENTION_OPTS}
|
||||
value={retentionValue}
|
||||
@@ -155,9 +181,13 @@ export function StorageTab() {
|
||||
<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">Active</span>
|
||||
<span className="portal-infra__lifecycle-label">
|
||||
{t("infrastructure.storage.lifecycle.active")}
|
||||
</span>
|
||||
<span className="portal-infra__muted">
|
||||
0–{retentionValue === "never" ? "∞" : retentionValue}d
|
||||
{t("infrastructure.storage.lifecycle.activeRange", {
|
||||
value: retentionValue === "never" ? "∞" : retentionValue,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-infra__lifecycle-arrow" aria-hidden>
|
||||
@@ -165,17 +195,25 @@ export function StorageTab() {
|
||||
</span>
|
||||
<div className="portal-infra__lifecycle-stage">
|
||||
<span className="portal-infra__lifecycle-dot" />
|
||||
<span className="portal-infra__lifecycle-label">Archived</span>
|
||||
<span className="portal-infra__muted">cold storage</span>
|
||||
<span className="portal-infra__lifecycle-label">
|
||||
{t("infrastructure.storage.lifecycle.archived")}
|
||||
</span>
|
||||
<span className="portal-infra__muted">
|
||||
{t("infrastructure.storage.lifecycle.coldStorage")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-infra__lifecycle-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
<div className="portal-infra__lifecycle-stage">
|
||||
<span className="portal-infra__lifecycle-dot" />
|
||||
<span className="portal-infra__lifecycle-label">Deleted</span>
|
||||
<span className="portal-infra__lifecycle-label">
|
||||
{t("infrastructure.storage.lifecycle.deleted")}
|
||||
</span>
|
||||
<span className="portal-infra__muted">
|
||||
{retentionValue === "never" ? "never" : "purged"}
|
||||
{retentionValue === "never"
|
||||
? t("infrastructure.storage.lifecycle.never")
|
||||
: t("infrastructure.storage.lifecycle.purged")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Table, type TableColumn } from "@shared/components";
|
||||
import type { Pipeline } from "@portal/api/pipelines";
|
||||
import { compact, goldenTone, pct } from "@portal/components/pipelines/format";
|
||||
@@ -17,11 +18,12 @@ export function DeployedPipelinesTable({
|
||||
pipelines,
|
||||
onRowClick,
|
||||
}: DeployedPipelinesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<Pipeline>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Pipeline",
|
||||
header: t("pipelines.table.header.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__roster-name">
|
||||
<strong>{p.name}</strong>
|
||||
@@ -33,20 +35,22 @@ export function DeployedPipelinesTable({
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Health",
|
||||
header: t("pipelines.table.header.health"),
|
||||
render: (p) => (
|
||||
<StatusBadge
|
||||
tone={p.status === "degraded" ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={p.status === "degraded"}
|
||||
>
|
||||
{p.status === "degraded" ? "Degraded" : "Healthy"}
|
||||
{p.status === "degraded"
|
||||
? t("pipelines.status.degraded")
|
||||
: t("pipelines.status.healthy")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "golden",
|
||||
header: "Golden set",
|
||||
header: t("pipelines.table.header.goldenSet"),
|
||||
width: "11rem",
|
||||
render: (p) => {
|
||||
const tone = goldenTone(p.golden);
|
||||
@@ -58,7 +62,9 @@ export function DeployedPipelinesTable({
|
||||
</StatusBadge>
|
||||
<span
|
||||
className="portal-pipelines__roster-rate"
|
||||
title={`Bound: ${pct(p.golden.threshold, 0)}`}
|
||||
title={t("pipelines.table.boundTooltip", {
|
||||
bound: pct(p.golden.threshold, 0),
|
||||
})}
|
||||
>
|
||||
{pct(rate, 1)}
|
||||
</span>
|
||||
@@ -68,7 +74,7 @@ export function DeployedPipelinesTable({
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: "Docs / 24h",
|
||||
header: t("pipelines.table.header.docs24h"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__roster-num">
|
||||
@@ -78,14 +84,14 @@ export function DeployedPipelinesTable({
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
header: t("pipelines.table.header.version"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__roster-version">{p.version}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, StatTile, StatusBadge } from "@shared/components";
|
||||
import type { Pipeline, StageSummary } from "@portal/api/pipelines";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import { compact, pct } from "@portal/components/pipelines/format";
|
||||
|
||||
/** Compact five-dot stage indicator: a lit dot per stage that has ops. */
|
||||
function StageDots({ stages }: { stages: StageSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="portal-pipelines__stage-dots" aria-hidden>
|
||||
{stages.map((s) => (
|
||||
@@ -19,7 +21,10 @@ function StageDots({ stages }: { stages: StageSummary[] }) {
|
||||
? STAGE_COLOR_VAR[STAGE_ACCENT[s.key]]
|
||||
: "var(--color-border)",
|
||||
}}
|
||||
title={`${s.label}: ${s.ops.length} op${s.ops.length === 1 ? "" : "s"}`}
|
||||
title={t("pipelines.card.stageTooltip", {
|
||||
label: s.label,
|
||||
count: s.ops.length,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
@@ -33,6 +38,7 @@ export interface PipelineCardProps {
|
||||
|
||||
/** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */
|
||||
export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const m = pipeline.metrics;
|
||||
const degraded = pipeline.status === "degraded";
|
||||
const errorTone =
|
||||
@@ -60,7 +66,9 @@ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
|
||||
size="sm"
|
||||
pulse={degraded}
|
||||
>
|
||||
{degraded ? "Degraded" : "Healthy"}
|
||||
{degraded
|
||||
? t("pipelines.status.degraded")
|
||||
: t("pipelines.status.healthy")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
@@ -79,15 +87,27 @@ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__metrics">
|
||||
<StatTile label="Docs / 24h" value={compact(m.docs24h)} />
|
||||
<StatTile label="Throughput" value={`${m.throughputPerMin}/min`} />
|
||||
<StatTile
|
||||
label="Error rate"
|
||||
label={t("pipelines.metrics.docs24h")}
|
||||
value={compact(m.docs24h)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.throughput")}
|
||||
value={`${m.throughputPerMin}/min`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.errorRate")}
|
||||
value={pct(m.errorRate, 2)}
|
||||
tone={errorTone}
|
||||
/>
|
||||
<StatTile label="P95 latency" value={`${m.p95LatencyMs} ms`} />
|
||||
<StatTile label="Uptime" value={pct(m.uptime, 2)} />
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.p95Latency")}
|
||||
value={`${m.p95LatencyMs} ms`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.uptime")}
|
||||
value={pct(m.uptime, 2)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__card-foot">
|
||||
@@ -95,11 +115,14 @@ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
|
||||
{pipeline.version} · {pipeline.regions.join(", ")}
|
||||
</span>
|
||||
<span className="portal-pipelines__card-golden">
|
||||
Golden {pipeline.golden.passing}/{pipeline.golden.total}
|
||||
{t("pipelines.card.golden", {
|
||||
passing: pipeline.golden.passing,
|
||||
total: pipeline.golden.total,
|
||||
})}
|
||||
{driftCount > 0 && (
|
||||
<span className="portal-pipelines__card-drift">
|
||||
{" · "}
|
||||
{driftCount} drift{driftCount > 1 ? "s" : ""}
|
||||
{t("pipelines.card.drift", { count: driftCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, Modal } from "@shared/components";
|
||||
import {
|
||||
DESTINATION_OPTIONS,
|
||||
@@ -13,15 +14,16 @@ import {
|
||||
STAGE_COLOR_VAR,
|
||||
} from "@portal/components/pipelines/stageAccent";
|
||||
|
||||
const COMPOSER_STEPS = ["Source", "Operations", "Routing"] as const;
|
||||
const COMPOSER_STEPS = ["source", "operations", "routing"] as const;
|
||||
|
||||
const OP_KIND_LABEL: Record<OpKind, string> = {
|
||||
ingest: "Ingest",
|
||||
validate: "Validate",
|
||||
modify: "Modify",
|
||||
secure: "Secure",
|
||||
store: "Route / Store",
|
||||
alert: "Alerts",
|
||||
/** Translation key suffixes for each op-kind group heading in the picker. */
|
||||
const OP_KIND_LABEL_KEY: Record<OpKind, string> = {
|
||||
ingest: "ingest",
|
||||
validate: "validate",
|
||||
modify: "modify",
|
||||
secure: "secure",
|
||||
store: "store",
|
||||
alert: "alert",
|
||||
};
|
||||
|
||||
/** Selectable ops in the picker — excludes pipeline-only structural ops. */
|
||||
@@ -48,6 +50,7 @@ export interface PipelineComposerProps {
|
||||
|
||||
/** Three-step wizard: pick a source, compose the op chain, route the output. */
|
||||
export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [source, setSource] = useState<string>("upload");
|
||||
const [selectedOps, setSelectedOps] = useState<string[]>([
|
||||
@@ -103,29 +106,29 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="xl"
|
||||
title="New pipeline"
|
||||
subtitle="Pick a source, compose the operation chain, then route the output."
|
||||
title={t("pipelines.composer.title")}
|
||||
subtitle={t("pipelines.composer.subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<div className="portal-pipelines__composer-steps" aria-hidden>
|
||||
{COMPOSER_STEPS.map((label, i) => (
|
||||
{COMPOSER_STEPS.map((stepId, i) => (
|
||||
<span
|
||||
key={label}
|
||||
key={stepId}
|
||||
className={
|
||||
"portal-pipelines__composer-step" +
|
||||
(i === step ? " is-active" : i < step ? " is-done" : "")
|
||||
}
|
||||
>
|
||||
{i + 1}. {label}
|
||||
{i + 1}. {t(`pipelines.composer.steps.${stepId}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
{t("pipelines.composer.cancel")}
|
||||
</Button>
|
||||
{step > 0 && (
|
||||
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||||
Back
|
||||
{t("pipelines.composer.back")}
|
||||
</Button>
|
||||
)}
|
||||
{isLast ? (
|
||||
@@ -134,7 +137,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
onClick={deploy}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
Deploy pipeline
|
||||
{t("pipelines.composer.deploy")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -143,7 +146,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
disabled={!canAdvance}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
Continue
|
||||
{t("pipelines.composer.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -162,10 +165,10 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
onClick={() => setSource("any")}
|
||||
>
|
||||
<span className="portal-pipelines__option-label">
|
||||
Any source
|
||||
{t("pipelines.composer.anySource.label")}
|
||||
</span>
|
||||
<span className="portal-pipelines__option-desc">
|
||||
Accept documents from every connected channel
|
||||
{t("pipelines.composer.anySource.desc")}
|
||||
</span>
|
||||
</button>
|
||||
{SOURCE_OPTIONS.map((opt) => (
|
||||
@@ -194,12 +197,14 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
<div className="portal-pipelines__composer-body">
|
||||
<div className="portal-pipelines__chain">
|
||||
<span className="portal-pipelines__chain-label">
|
||||
Operation chain ({selectedOps.length})
|
||||
{t("pipelines.composer.operationChain", {
|
||||
count: selectedOps.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="portal-pipelines__chain-chips">
|
||||
{selectedOps.length === 0 ? (
|
||||
<span className="portal-pipelines__chain-empty">
|
||||
Add operations from the library below.
|
||||
{t("pipelines.composer.chainEmpty")}
|
||||
</span>
|
||||
) : (
|
||||
selectedOps.map((id) => {
|
||||
@@ -222,7 +227,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
|
||||
<div className="portal-pipelines__agents">
|
||||
<span className="portal-pipelines__agents-label">
|
||||
Quick-add bundles
|
||||
{t("pipelines.composer.quickAddBundles")}
|
||||
</span>
|
||||
<div className="portal-pipelines__agents-row">
|
||||
{PIPELINE_AGENTS.map((agent) => (
|
||||
@@ -249,7 +254,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{OP_KIND_LABEL[kind]}
|
||||
{t(`pipelines.composer.opKind.${OP_KIND_LABEL_KEY[kind]}`)}
|
||||
</div>
|
||||
<div className="portal-pipelines__library-chips">
|
||||
{PICKER_OPS[kind].map((op) => {
|
||||
@@ -275,7 +280,9 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
|
||||
{step === 2 && (
|
||||
<div className="portal-pipelines__composer-body">
|
||||
<span className="portal-pipelines__chain-label">Destination</span>
|
||||
<span className="portal-pipelines__chain-label">
|
||||
{t("pipelines.composer.destination")}
|
||||
</span>
|
||||
<div className="portal-pipelines__composer-grid">
|
||||
{DESTINATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -297,7 +304,9 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="portal-pipelines__chain-label">Alerts</span>
|
||||
<span className="portal-pipelines__chain-label">
|
||||
{t("pipelines.composer.alerts")}
|
||||
</span>
|
||||
<div className="portal-pipelines__alerts">
|
||||
<label className="portal-pipelines__alert">
|
||||
<input
|
||||
@@ -306,10 +315,8 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
onChange={(e) => setNotifyEmail(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Email on failure</strong>
|
||||
<span>
|
||||
Notify the on-call list when error rate trips its bound
|
||||
</span>
|
||||
<strong>{t("pipelines.composer.alert.email.title")}</strong>
|
||||
<span>{t("pipelines.composer.alert.email.desc")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="portal-pipelines__alert">
|
||||
@@ -319,8 +326,8 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
onChange={(e) => setNotifyWebhook(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Webhook on completion</strong>
|
||||
<span>POST a run summary to a URL you control</span>
|
||||
<strong>{t("pipelines.composer.alert.webhook.title")}</strong>
|
||||
<span>{t("pipelines.composer.alert.webhook.desc")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="portal-pipelines__alert">
|
||||
@@ -330,10 +337,8 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
onChange={(e) => setReviewQueue(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Route low-confidence to review</strong>
|
||||
<span>
|
||||
Send docs under the confidence bound to a human queue
|
||||
</span>
|
||||
<strong>{t("pipelines.composer.alert.review.title")}</strong>
|
||||
<span>{t("pipelines.composer.alert.review.desc")}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Chip,
|
||||
EmptyState,
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
import { compact, pct } from "@portal/components/pipelines/format";
|
||||
|
||||
function DriftRow({ drift }: { drift: SchemaDrift }) {
|
||||
const { t } = useTranslation();
|
||||
const confDelta =
|
||||
(drift.confidenceDelta > 0 ? "+" : "") + drift.confidenceDelta.toFixed(2);
|
||||
return (
|
||||
<li className="portal-pipelines__drift">
|
||||
<span
|
||||
@@ -31,10 +35,11 @@ function DriftRow({ drift }: { drift: SchemaDrift }) {
|
||||
</div>
|
||||
<div className="portal-pipelines__drift-meta">
|
||||
<span>
|
||||
{drift.confidenceDelta > 0 ? "+" : ""}
|
||||
{drift.confidenceDelta.toFixed(2)} conf
|
||||
{t("pipelines.detail.drift.confidence", { delta: confDelta })}
|
||||
</span>
|
||||
<span>
|
||||
{t("pipelines.detail.drift.docs", { count: drift.affectedDocs })}
|
||||
</span>
|
||||
<span>{drift.affectedDocs} docs</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -46,6 +51,7 @@ export interface PipelineDetailProps {
|
||||
|
||||
/** Drawer body: 24h metrics, the five stages, golden-set health, and schema drift. */
|
||||
export function PipelineDetail({ pipeline }: PipelineDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const m = pipeline.metrics;
|
||||
const goldenRatio = pipeline.golden.total
|
||||
? pipeline.golden.passing / pipeline.golden.total
|
||||
@@ -55,18 +61,37 @@ export function PipelineDetail({ pipeline }: PipelineDetailProps) {
|
||||
return (
|
||||
<div className="portal-pipelines__detail">
|
||||
<section className="portal-pipelines__detail-metrics">
|
||||
<StatTile label="Docs / 24h" value={compact(m.docs24h)} />
|
||||
<StatTile label="Throughput" value={`${m.throughputPerMin}/min`} />
|
||||
<StatTile label="Error rate" value={pct(m.errorRate, 2)} />
|
||||
<StatTile label="P95 latency" value={`${m.p95LatencyMs} ms`} />
|
||||
<StatTile label="Uptime" value={pct(m.uptime, 2)} />
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.docs24h")}
|
||||
value={compact(m.docs24h)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.throughput")}
|
||||
value={`${m.throughputPerMin}/min`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.errorRate")}
|
||||
value={pct(m.errorRate, 2)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.p95Latency")}
|
||||
value={`${m.p95LatencyMs} ms`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.uptime")}
|
||||
value={pct(m.uptime, 2)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="portal-pipelines__detail-section">
|
||||
<h3 className="portal-pipelines__detail-h">Pipeline stages</h3>
|
||||
<h3 className="portal-pipelines__detail-h">
|
||||
{t("pipelines.detail.stages.heading")}
|
||||
</h3>
|
||||
<p className="portal-pipelines__detail-sub">
|
||||
Every document flows through five stages between {pipeline.source} and{" "}
|
||||
{pipeline.destination}.
|
||||
{t("pipelines.detail.stages.description", {
|
||||
source: pipeline.source,
|
||||
destination: pipeline.destination,
|
||||
})}
|
||||
</p>
|
||||
<div className="portal-pipelines__stages">
|
||||
{pipeline.stages.map((stage) => {
|
||||
@@ -86,7 +111,7 @@ export function PipelineDetail({ pipeline }: PipelineDetailProps) {
|
||||
<div className="portal-pipelines__stage-chips">
|
||||
{stage.ops.length === 0 ? (
|
||||
<span className="portal-pipelines__stage-empty">
|
||||
No ops
|
||||
{t("pipelines.detail.stages.noOps")}
|
||||
</span>
|
||||
) : (
|
||||
stage.ops.map((op) => (
|
||||
@@ -103,14 +128,21 @@ export function PipelineDetail({ pipeline }: PipelineDetailProps) {
|
||||
</section>
|
||||
|
||||
<section className="portal-pipelines__detail-section">
|
||||
<h3 className="portal-pipelines__detail-h">Golden-set validation</h3>
|
||||
<h3 className="portal-pipelines__detail-h">
|
||||
{t("pipelines.detail.golden.heading")}
|
||||
</h3>
|
||||
<div className="portal-pipelines__golden">
|
||||
<div className="portal-pipelines__golden-head">
|
||||
<StatusBadge tone={goldenClean ? "success" : "warning"} size="sm">
|
||||
{pipeline.golden.passing} of {pipeline.golden.total} passing
|
||||
{t("pipelines.detail.golden.passing", {
|
||||
passing: pipeline.golden.passing,
|
||||
total: pipeline.golden.total,
|
||||
})}
|
||||
</StatusBadge>
|
||||
<span className="portal-pipelines__golden-when">
|
||||
last run {pipeline.golden.lastRun}
|
||||
{t("pipelines.detail.golden.lastRun", {
|
||||
lastRun: pipeline.golden.lastRun,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -120,18 +152,23 @@ export function PipelineDetail({ pipeline }: PipelineDetailProps) {
|
||||
? "var(--color-green)"
|
||||
: "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
|
||||
}
|
||||
label={`Golden set ${pipeline.golden.passing} of ${pipeline.golden.total} passing`}
|
||||
label={t("pipelines.detail.golden.barLabel", {
|
||||
passing: pipeline.golden.passing,
|
||||
total: pipeline.golden.total,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="portal-pipelines__detail-section">
|
||||
<h3 className="portal-pipelines__detail-h">Schema drift</h3>
|
||||
<h3 className="portal-pipelines__detail-h">
|
||||
{t("pipelines.detail.drift.heading")}
|
||||
</h3>
|
||||
{pipeline.drift.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No drift detected"
|
||||
description="Every document in the last 24h matched its inferred schema."
|
||||
title={t("pipelines.detail.drift.empty.title")}
|
||||
description={t("pipelines.detail.drift.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="portal-pipelines__drift-list">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
StatusBadge,
|
||||
@@ -18,10 +19,11 @@ const STATUS_TONE: Record<PromotedStatus, StatusTone> = {
|
||||
review: "warning",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<PromotedStatus, string> = {
|
||||
deployed: "Deployed",
|
||||
staged: "Staged",
|
||||
review: "Needs review",
|
||||
/** Translation key suffixes for each promoted-pipeline status badge. */
|
||||
const STATUS_LABEL_KEY: Record<PromotedStatus, string> = {
|
||||
deployed: "deployed",
|
||||
staged: "staged",
|
||||
review: "review",
|
||||
};
|
||||
|
||||
/** Per-row promote-to-policy lifecycle, kept local until a backend exists. */
|
||||
@@ -37,6 +39,7 @@ interface PromotedPipelinesProps {
|
||||
* offers a one-click path to lift its rules into a fleet-wide org policy.
|
||||
*/
|
||||
export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
const { t } = useTranslation();
|
||||
// Promote submits have no backend yet, so reflect acceptance per row locally.
|
||||
const [promoteState, setPromoteState] = useState<
|
||||
Record<string, PromoteState>
|
||||
@@ -58,7 +61,7 @@ export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Pipeline",
|
||||
header: t("pipelines.table.header.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__promoted-name">
|
||||
<strong>{p.name}</strong>
|
||||
@@ -70,7 +73,7 @@ export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
},
|
||||
{
|
||||
key: "docType",
|
||||
header: "Source doc type",
|
||||
header: t("pipelines.promoted.table.sourceDocType"),
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__promoted-muted">
|
||||
{p.sourceDocType}
|
||||
@@ -79,7 +82,7 @@ export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
},
|
||||
{
|
||||
key: "watchFolder",
|
||||
header: "Watch folder",
|
||||
header: t("pipelines.promoted.table.watchFolder"),
|
||||
render: (p) => (
|
||||
<code className="portal-pipelines__promoted-folder">
|
||||
{p.watchFolder}
|
||||
@@ -88,10 +91,10 @@ export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
header: t("pipelines.promoted.table.status"),
|
||||
render: (p) => (
|
||||
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
|
||||
{STATUS_LABEL[p.status]}
|
||||
{t(`pipelines.promoted.status.${STATUS_LABEL_KEY[p.status]}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
@@ -105,7 +108,7 @@ export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
if (state === "done") {
|
||||
return (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
Policy created
|
||||
{t("pipelines.promoted.policyCreated")}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
@@ -116,13 +119,13 @@ export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
loading={state === "pending"}
|
||||
onClick={() => onPromote(p)}
|
||||
>
|
||||
Promote to policy
|
||||
{t("pipelines.promoted.promoteToPolicy")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[promoteState],
|
||||
[promoteState, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { PoliciesResponse } from "@portal/api/policies";
|
||||
|
||||
@@ -12,28 +13,29 @@ interface CatalogueSummaryProps {
|
||||
* across loading / ready states; only the values flow from the API.
|
||||
*/
|
||||
export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = loading ? undefined : data?.summary;
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricCard
|
||||
label="Active policies"
|
||||
label={t("policies.summary.active.label")}
|
||||
value={s ? s.active : "—"}
|
||||
description="Enforcing on upload/export"
|
||||
description={t("policies.summary.active.description")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Paused"
|
||||
label={t("policies.summary.paused.label")}
|
||||
value={s ? s.paused : "—"}
|
||||
description="Configured but not firing"
|
||||
description={t("policies.summary.paused.description")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Categories"
|
||||
label={t("policies.summary.categories.label")}
|
||||
value={s ? s.categories : "—"}
|
||||
description="Available to configure"
|
||||
description={t("policies.summary.categories.description")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Docs enforced"
|
||||
label={t("policies.summary.docsEnforced.label")}
|
||||
value={s ? s.docsEnforced.toLocaleString() : "—"}
|
||||
description="Across active policies"
|
||||
description={t("policies.summary.docsEnforced.description")}
|
||||
/>
|
||||
</MetricStrip>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge, StatTile } from "@shared/components";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
@@ -14,6 +15,7 @@ interface PolicyCategoryCardProps {
|
||||
* "Set up" affordance; coming-soon categories render locked and inert.
|
||||
*/
|
||||
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon;
|
||||
@@ -53,7 +55,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
</div>
|
||||
{comingSoon ? (
|
||||
<Chip tone="neutral" size="sm">
|
||||
Coming soon
|
||||
{t("policies.card.comingSoon")}
|
||||
</Chip>
|
||||
) : policy ? (
|
||||
<StatusBadge
|
||||
@@ -61,11 +63,13 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
size="sm"
|
||||
pulse={status !== "paused"}
|
||||
>
|
||||
{status === "paused" ? "Paused" : "Active"}
|
||||
{status === "paused"
|
||||
? t("policies.status.paused")
|
||||
: t("policies.status.active")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<Chip tone="blue" size="sm">
|
||||
Not set up
|
||||
{t("policies.card.notSetUp")}
|
||||
</Chip>
|
||||
)}
|
||||
</header>
|
||||
@@ -75,11 +79,17 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
{policy ? (
|
||||
<footer className="portal-policies__card-stats">
|
||||
<StatTile
|
||||
label="Docs enforced"
|
||||
label={t("policies.stats.docsEnforced")}
|
||||
value={policy.stats.enforced.toLocaleString()}
|
||||
/>
|
||||
<StatTile label="Data processed" value={policy.stats.dataProcessed} />
|
||||
<StatTile label="Active" value={policy.stats.activeFor} />
|
||||
<StatTile
|
||||
label={t("policies.stats.dataProcessed")}
|
||||
value={policy.stats.dataProcessed}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("policies.stats.activeFor")}
|
||||
value={policy.stats.activeFor}
|
||||
/>
|
||||
</footer>
|
||||
) : (
|
||||
<footer className="portal-policies__card-foot">
|
||||
@@ -91,7 +101,9 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
))}
|
||||
</div>
|
||||
{!comingSoon && (
|
||||
<span className="portal-policies__card-cta">Set up →</span>
|
||||
<span className="portal-policies__card-cta">
|
||||
{t("policies.card.setUp")}
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -45,6 +46,7 @@ export function PolicyDetailPanel({
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!policy) return null;
|
||||
const { category, config, state, steps, stats, activity } = policy;
|
||||
const isPaused = state.status === "paused";
|
||||
@@ -64,7 +66,7 @@ export function PolicyDetailPanel({
|
||||
>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{category.label} policy
|
||||
{t("policies.detail.title", { category: category.label })}
|
||||
</span>
|
||||
}
|
||||
subtitle={config.summary}
|
||||
@@ -79,7 +81,7 @@ export function PolicyDetailPanel({
|
||||
disabled={busy}
|
||||
style={{ marginRight: "auto" }}
|
||||
>
|
||||
Delete
|
||||
{t("policies.detail.actions.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -89,7 +91,7 @@ export function PolicyDetailPanel({
|
||||
disabled={busy}
|
||||
style={canDelete ? undefined : { marginRight: "auto" }}
|
||||
>
|
||||
Run now
|
||||
{t("policies.detail.actions.runNow")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -97,27 +99,34 @@ export function PolicyDetailPanel({
|
||||
onClick={onTogglePause}
|
||||
disabled={busy}
|
||||
>
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
{isPaused
|
||||
? t("policies.detail.actions.resume")
|
||||
: t("policies.detail.actions.pause")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit} disabled={busy}>
|
||||
Edit settings
|
||||
{t("policies.detail.actions.editSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-policies__detail-status">
|
||||
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
|
||||
{isPaused ? "Paused" : "Active"}
|
||||
{isPaused ? t("policies.status.paused") : t("policies.status.active")}
|
||||
</StatusBadge>
|
||||
<span className="portal-policies__detail-meta">
|
||||
Runs on {state.runOn ?? "upload"} · output{" "}
|
||||
{state.outputMode === "new_file"
|
||||
? "as a new file"
|
||||
: "as a new version"}
|
||||
{t("policies.detail.meta", {
|
||||
event: state.runOn ?? "upload",
|
||||
output:
|
||||
state.outputMode === "new_file"
|
||||
? t("policies.detail.outputAsNewFile")
|
||||
: t("policies.detail.outputAsNewVersion"),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">Enforces</h3>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.detail.enforces")}
|
||||
</h3>
|
||||
<Card padding="default">
|
||||
{enforceItems.length > 0 ? (
|
||||
<div className="portal-policies__enforce-flow">
|
||||
@@ -144,12 +153,13 @@ export function PolicyDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
<p className="portal-policies__enforce-note">
|
||||
{config.scopeLabel} · originals stay untouched, the enforced version
|
||||
is saved alongside.
|
||||
{t("policies.detail.enforceNote", { scope: config.scopeLabel })}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">Recent activity</h3>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.detail.recentActivity")}
|
||||
</h3>
|
||||
{activity.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activity.map((item, i) => (
|
||||
@@ -179,26 +189,34 @@ export function PolicyDetailPanel({
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No activity yet"
|
||||
description="Documents will appear here once this policy runs."
|
||||
title={t("policies.detail.emptyActivity.title")}
|
||||
description={t("policies.detail.emptyActivity.description")}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padding="none" className="portal-policies__detail-stats">
|
||||
<StatTile
|
||||
label="Docs enforced"
|
||||
label={t("policies.stats.docsEnforced")}
|
||||
value={stats.enforced.toLocaleString()}
|
||||
/>
|
||||
<StatTile label="Data processed" value={stats.dataProcessed} />
|
||||
<StatTile label="Active" value={stats.activeFor} />
|
||||
<StatTile
|
||||
label={t("policies.stats.dataProcessed")}
|
||||
value={stats.dataProcessed}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("policies.stats.activeFor")}
|
||||
value={stats.activeFor}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{state.scopeTypes.length > 0 && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title="Scoped"
|
||||
description={`Limited to: ${state.scopeTypes.join(", ")}`}
|
||||
title={t("policies.detail.scoped.title")}
|
||||
description={t("policies.detail.scoped.description", {
|
||||
types: state.scopeTypes.join(", "),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -101,6 +102,7 @@ function PolicySetupWizardBody({
|
||||
onClose: () => void;
|
||||
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
|
||||
@@ -158,7 +160,7 @@ function PolicySetupWizardBody({
|
||||
async function submit() {
|
||||
if (submitting) return;
|
||||
if (enabledTools.length === 0) {
|
||||
setError("Enable at least one tool in the workflow first.");
|
||||
setError(t("policies.wizard.errors.noTools"));
|
||||
setStep("workflow");
|
||||
return;
|
||||
}
|
||||
@@ -182,7 +184,7 @@ function PolicySetupWizardBody({
|
||||
});
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
setError("Couldn't save the policy. Please try again.");
|
||||
setError(t("policies.wizard.errors.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,15 +204,15 @@ function PolicySetupWizardBody({
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{isEdit
|
||||
? `Edit ${category.label} policy`
|
||||
: `Set up ${category.label} policy`}
|
||||
? t("policies.wizard.title.edit", { category: category.label })
|
||||
: t("policies.wizard.title.setUp", { category: category.label })}
|
||||
</span>
|
||||
}
|
||||
subtitle={config.summary}
|
||||
footer={
|
||||
<div className="portal-policies__wizard-foot">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
{t("policies.wizard.actions.cancel")}
|
||||
</Button>
|
||||
{step === "workflow" ? (
|
||||
<Button
|
||||
@@ -218,7 +220,7 @@ function PolicySetupWizardBody({
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => setStep("settings")}
|
||||
>
|
||||
Continue
|
||||
{t("policies.wizard.actions.continue")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -228,10 +230,12 @@ function PolicySetupWizardBody({
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => setStep("workflow")}
|
||||
>
|
||||
Back
|
||||
{t("policies.wizard.actions.back")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit} loading={submitting}>
|
||||
{isEdit ? "Save changes" : "Enable policy"}
|
||||
{isEdit
|
||||
? t("policies.wizard.actions.saveChanges")
|
||||
: t("policies.wizard.actions.enablePolicy")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -240,12 +244,12 @@ function PolicySetupWizardBody({
|
||||
>
|
||||
<Tabs
|
||||
variant="underline"
|
||||
ariaLabel="Setup steps"
|
||||
ariaLabel={t("policies.wizard.tabs.ariaLabel")}
|
||||
activeKey={step}
|
||||
onChange={(k) => setStep(k as Step)}
|
||||
items={[
|
||||
{ key: "workflow", label: "Workflow" },
|
||||
{ key: "settings", label: "Settings" },
|
||||
{ key: "workflow", label: t("policies.wizard.tabs.workflow") },
|
||||
{ key: "settings", label: t("policies.wizard.tabs.settings") },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -260,8 +264,7 @@ function PolicySetupWizardBody({
|
||||
{step === "workflow" && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
The sequence of tools this policy runs on each document. Each tool
|
||||
is a Stirling endpoint; toggle the ones this policy should enforce.
|
||||
{t("policies.wizard.workflow.description")}
|
||||
</p>
|
||||
{tools.map((tl) => (
|
||||
<Card key={tl.operation} padding="tight">
|
||||
@@ -290,7 +293,9 @@ function PolicySetupWizardBody({
|
||||
<div className="portal-policies__wizard-section">
|
||||
{config.fields.length > 0 && (
|
||||
<>
|
||||
<h3 className="portal-policies__wizard-heading">Settings</h3>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.wizard.settings.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__fields">
|
||||
{config.fields.map((field) => (
|
||||
<PolicyFieldRow
|
||||
@@ -306,7 +311,9 @@ function PolicySetupWizardBody({
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">Sources</h3>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.wizard.sources.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__sources">
|
||||
{POLICY_SOURCES.map((src) => (
|
||||
<button
|
||||
@@ -335,27 +342,33 @@ function PolicySetupWizardBody({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">Document types</h3>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.wizard.docTypes.heading")}
|
||||
</h3>
|
||||
{!docTypesEnabled ? (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title="All document types"
|
||||
description="Set up an Ingestion (classification) policy to narrow this to specific document types."
|
||||
title={t("policies.wizard.docTypes.allTitle")}
|
||||
description={t("policies.wizard.docTypes.allDescription")}
|
||||
/>
|
||||
) : (
|
||||
<Card padding="tight">
|
||||
<div className="portal-policies__doctypes-head">
|
||||
<span>
|
||||
{scopeTypes.length === 0
|
||||
? "All document types"
|
||||
: `${scopeTypes.length} selected`}
|
||||
? t("policies.wizard.docTypes.allTitle")
|
||||
: t("policies.wizard.docTypes.selected", {
|
||||
count: scopeTypes.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-policies__link"
|
||||
onClick={() => setScopeNarrow((v) => !v)}
|
||||
>
|
||||
{scopeNarrow ? "Clear" : "Narrow"}
|
||||
{scopeNarrow
|
||||
? t("policies.wizard.docTypes.clear")
|
||||
: t("policies.wizard.docTypes.narrow")}
|
||||
</button>
|
||||
</div>
|
||||
{scopeNarrow && (
|
||||
@@ -375,11 +388,13 @@ function PolicySetupWizardBody({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">Output & run</h3>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.wizard.output.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__fields">
|
||||
<FormField
|
||||
label="Run on"
|
||||
helperText="When the policy fires: on upload, or before export."
|
||||
label={t("policies.wizard.output.runOn.label")}
|
||||
helperText={t("policies.wizard.output.runOn.helper")}
|
||||
>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
@@ -388,12 +403,18 @@ function PolicySetupWizardBody({
|
||||
setRunOn(e.target.value as "upload" | "export")
|
||||
}
|
||||
options={[
|
||||
{ value: "upload", label: "Upload" },
|
||||
{ value: "export", label: "Export" },
|
||||
{
|
||||
value: "upload",
|
||||
label: t("policies.wizard.output.runOn.upload"),
|
||||
},
|
||||
{
|
||||
value: "export",
|
||||
label: t("policies.wizard.output.runOn.export"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Output as">
|
||||
<FormField label={t("policies.wizard.output.outputAs.label")}>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputMode}
|
||||
@@ -409,12 +430,18 @@ function PolicySetupWizardBody({
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "new_version", label: "New version" },
|
||||
{ value: "new_file", label: "New file" },
|
||||
{
|
||||
value: "new_version",
|
||||
label: t("policies.wizard.output.outputAs.newVersion"),
|
||||
},
|
||||
{
|
||||
value: "new_file",
|
||||
label: t("policies.wizard.output.outputAs.newFile"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Filename rule">
|
||||
<FormField label={t("policies.wizard.output.filenameRule.label")}>
|
||||
<div className="portal-policies__name-row">
|
||||
<Select
|
||||
inputSize="sm"
|
||||
@@ -425,10 +452,23 @@ function PolicySetupWizardBody({
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ value: "prefix", label: "Prefix" },
|
||||
{ value: "suffix", label: "Suffix" },
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("policies.wizard.output.filenameRule.prefix"),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("policies.wizard.output.filenameRule.suffix"),
|
||||
},
|
||||
...(outputMode === "new_file"
|
||||
? [{ value: "auto-number", label: "Auto-number" }]
|
||||
? [
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.autoNumber",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
@@ -436,15 +476,17 @@ function PolicySetupWizardBody({
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
placeholder="Text to add (optional)"
|
||||
placeholder={t(
|
||||
"policies.wizard.output.filenameRule.placeholder",
|
||||
)}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Reviewer email"
|
||||
helperText="Low-confidence enforcements are routed here for review."
|
||||
label={t("policies.wizard.output.reviewerEmail.label")}
|
||||
helperText={t("policies.wizard.output.reviewerEmail.helper")}
|
||||
>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
@@ -10,6 +11,7 @@ import { pct } from "@portal/components/sources/format";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
export function AgentPanel({ d }: { d: AgentDetail }) {
|
||||
const { t } = useTranslation();
|
||||
const errorTone =
|
||||
d.errorRate >= 0.05
|
||||
? "danger"
|
||||
@@ -19,22 +21,31 @@ export function AgentPanel({ d }: { d: AgentDetail }) {
|
||||
return (
|
||||
<div className="portal-sources__detail">
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile label="Model" value={<code>{d.model}</code>} />
|
||||
<StatTile label="Calls / 24h" value={d.calls24h.toLocaleString()} />
|
||||
<StatTile
|
||||
label="Error rate"
|
||||
label={t("sources.agent.model")}
|
||||
value={<code>{d.model}</code>}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.agent.calls24h")}
|
||||
value={d.calls24h.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.agent.errorRate")}
|
||||
value={
|
||||
<StatusBadge tone={errorTone} size="sm">
|
||||
{pct(d.errorRate)}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
<StatTile label="Escalations / 24h" value={d.escalations24h} />
|
||||
<StatTile
|
||||
label={t("sources.agent.escalations24h")}
|
||||
value={d.escalations24h}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__bar-row">
|
||||
<div className="portal-sources__bar-head">
|
||||
<span>Mean confidence</span>
|
||||
<span>{t("sources.agent.meanConfidence")}</span>
|
||||
<strong>{pct(d.confidence)}</strong>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -42,13 +53,13 @@ export function AgentPanel({ d }: { d: AgentDetail }) {
|
||||
color={
|
||||
d.confidence >= 0.93 ? "var(--color-green)" : "var(--color-amber)"
|
||||
}
|
||||
label="Mean output confidence"
|
||||
label={t("sources.agent.meanOutputConfidence")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">
|
||||
Assigned pipelines
|
||||
{t("sources.agent.assignedPipelines")}
|
||||
</span>
|
||||
<div className="portal-sources__chips">
|
||||
{d.assignedPipelines.map((p) => (
|
||||
@@ -60,7 +71,9 @@ export function AgentPanel({ d }: { d: AgentDetail }) {
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">Scopes</span>
|
||||
<span className="portal-sources__detail-heading">
|
||||
{t("sources.agent.scopes")}
|
||||
</span>
|
||||
<div className="portal-sources__chips">
|
||||
{d.scopes.map((s) => (
|
||||
<Chip key={s} tone="neutral" size="sm">
|
||||
@@ -74,10 +87,10 @@ export function AgentPanel({ d }: { d: AgentDetail }) {
|
||||
POST /v1/sources/{id}/pause — currently inert demo controls. */}
|
||||
<div className="portal-sources__detail-actions">
|
||||
<Button size="sm" variant="outline">
|
||||
View eval runs
|
||||
{t("sources.agent.viewEvalRuns")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
Pause agent
|
||||
{t("sources.agent.pauseAgent")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, ProgressBar, StatTile } from "@shared/components";
|
||||
import type { ApiClientDetail } from "@portal/api/sources";
|
||||
import { pct } from "@portal/components/sources/format";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
export function ApiClientPanel({ d }: { d: ApiClientDetail }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-sources__detail">
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile label="Secret key" value={<code>{d.maskedKey}</code>} />
|
||||
<StatTile label="Rate limit" value={d.rateLimit} />
|
||||
<StatTile label="Created by" value={d.createdBy} />
|
||||
<StatTile label="Last rotated" value={d.lastRotated} />
|
||||
<StatTile
|
||||
label={t("sources.apiClient.secretKey")}
|
||||
value={<code>{d.maskedKey}</code>}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.apiClient.rateLimit")}
|
||||
value={d.rateLimit}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.apiClient.createdBy")}
|
||||
value={d.createdBy}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.apiClient.lastRotated")}
|
||||
value={d.lastRotated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__bar-row">
|
||||
<div className="portal-sources__bar-head">
|
||||
<span>Rate-limit window</span>
|
||||
<strong>{pct(d.rateUsedPct)} used</strong>
|
||||
<span>{t("sources.apiClient.rateLimitWindow")}</span>
|
||||
<strong>
|
||||
{t("sources.apiClient.usedPct", { pct: pct(d.rateUsedPct) })}
|
||||
</strong>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={d.rateUsedPct}
|
||||
thresholded
|
||||
label="Rate-limit usage"
|
||||
label={t("sources.apiClient.rateLimitUsage")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">Top endpoints</span>
|
||||
<span className="portal-sources__detail-heading">
|
||||
{t("sources.apiClient.topEndpoints")}
|
||||
</span>
|
||||
<div className="portal-sources__endpoints">
|
||||
{d.endpoints.map((e) => (
|
||||
<div key={e.path} className="portal-sources__endpoint">
|
||||
@@ -39,7 +57,9 @@ export function ApiClientPanel({ d }: { d: ApiClientDetail }) {
|
||||
</Chip>
|
||||
<code className="portal-sources__endpoint-path">{e.path}</code>
|
||||
<span className="portal-sources__endpoint-calls">
|
||||
{e.calls24h.toLocaleString()} / 24h
|
||||
{t("sources.apiClient.callsPer24h", {
|
||||
count: e.calls24h.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -50,10 +70,10 @@ export function ApiClientPanel({ d }: { d: ApiClientDetail }) {
|
||||
DELETE /v1/sources/{id} — currently inert demo controls. */}
|
||||
<div className="portal-sources__detail-actions">
|
||||
<Button size="sm" variant="outline" accent="amber">
|
||||
Rotate key
|
||||
{t("sources.apiClient.rotateKey")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" accent="red">
|
||||
Revoke
|
||||
{t("sources.apiClient.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, CodeBlock, Modal, StatTile } from "@shared/components";
|
||||
import { type Source, SOURCE_TYPE_META } from "@portal/api/sources";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const WIZARD_STEPS = ["Choose type", "Configure", "Review & connect"] as const;
|
||||
const WIZARD_STEP_COUNT = 3;
|
||||
|
||||
const CONNECT_SNIPPET = `curl https://api.stirlingpdf.com/v1/extract \\
|
||||
-H "Authorization: Bearer sk_live_••••" \\
|
||||
@@ -20,9 +21,16 @@ interface ConnectWizardProps {
|
||||
* closes without provisioning — wiring it to the backend creates the source.
|
||||
*/
|
||||
export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [type, setType] = useState<Source["type"]>("agent");
|
||||
|
||||
const wizardSteps = [
|
||||
t("sources.wizard.steps.chooseType"),
|
||||
t("sources.wizard.steps.configure"),
|
||||
t("sources.wizard.steps.review"),
|
||||
];
|
||||
|
||||
function close() {
|
||||
onClose();
|
||||
// Reset for the next open, after the close transition has finished.
|
||||
@@ -32,7 +40,7 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const isLast = step === WIZARD_STEPS.length - 1;
|
||||
const isLast = step === WIZARD_STEP_COUNT - 1;
|
||||
|
||||
function advance() {
|
||||
if (isLast) {
|
||||
@@ -49,8 +57,12 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="lg"
|
||||
title="Connect a source"
|
||||
subtitle={`Step ${step + 1} of ${WIZARD_STEPS.length} · ${WIZARD_STEPS[step]}`}
|
||||
title={t("sources.wizard.title")}
|
||||
subtitle={t("sources.wizard.subtitle", {
|
||||
current: step + 1,
|
||||
total: WIZARD_STEP_COUNT,
|
||||
label: wizardSteps[step],
|
||||
})}
|
||||
footer={
|
||||
<div className="portal-sources__wizard-footer">
|
||||
<Button
|
||||
@@ -58,20 +70,22 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
|
||||
size="sm"
|
||||
onClick={() => (step === 0 ? close() : setStep((s) => s - 1))}
|
||||
>
|
||||
{step === 0 ? "Cancel" : "Back"}
|
||||
{step === 0 ? t("sources.wizard.cancel") : t("sources.wizard.back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={advance}
|
||||
trailingIcon={!isLast ? <span aria-hidden>→</span> : undefined}
|
||||
>
|
||||
{isLast ? "Connect source" : "Continue"}
|
||||
{isLast
|
||||
? t("sources.actions.connectSource")
|
||||
: t("sources.wizard.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ol className="portal-sources__steps" aria-hidden>
|
||||
{WIZARD_STEPS.map((label, i) => (
|
||||
{wizardSteps.map((label, i) => (
|
||||
<li
|
||||
key={label}
|
||||
className={
|
||||
@@ -114,14 +128,13 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
|
||||
{step === 1 && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<p className="portal-sources__wizard-lead">
|
||||
Configure your <strong>{SOURCE_TYPE_META[type].label}</strong>.
|
||||
Point it at Stirling and attach a default pipeline — every document
|
||||
this source ingests runs through it automatically.
|
||||
{t("sources.wizard.configureLead.before")}{" "}
|
||||
<strong>{SOURCE_TYPE_META[type].label}</strong>
|
||||
{t("sources.wizard.configureLead.after")}
|
||||
</p>
|
||||
<CodeBlock code={CONNECT_SNIPPET} caption="quickstart.sh" />
|
||||
<p className="portal-sources__wizard-note">
|
||||
Scopes, rate limits and IP allowlists can be tuned after the source
|
||||
is connected.
|
||||
{t("sources.wizard.configureNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -129,15 +142,24 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
|
||||
{step === 2 && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<p className="portal-sources__wizard-lead">
|
||||
Ready to connect a new{" "}
|
||||
<strong>{SOURCE_TYPE_META[type].label}</strong>. It starts paused so
|
||||
you can verify the first few documents before going live.
|
||||
{t("sources.wizard.reviewLead.before")}{" "}
|
||||
<strong>{SOURCE_TYPE_META[type].label}</strong>
|
||||
{t("sources.wizard.reviewLead.after")}
|
||||
</p>
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile label="Type" value={SOURCE_TYPE_META[type].label} />
|
||||
<StatTile label="Default pipeline" value="Redact & Flatten" />
|
||||
<StatTile label="Initial state" value="Paused" />
|
||||
<StatTile label="Region" value="us-east-1" />
|
||||
<StatTile
|
||||
label={t("sources.wizard.type")}
|
||||
value={SOURCE_TYPE_META[type].label}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.wizard.defaultPipeline")}
|
||||
value={t("sources.wizard.defaultPipelineValue")}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.wizard.initialState")}
|
||||
value={t("sources.wizard.initialStateValue")}
|
||||
/>
|
||||
<StatTile label={t("sources.wizard.region")} value="us-east-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { SourcesResponse } from "@portal/api/sources";
|
||||
|
||||
@@ -6,11 +7,11 @@ import type { SourcesResponse } from "@portal/api/sources";
|
||||
* current value. They stay client-side so the strip's structure is stable
|
||||
* across loading / empty / ready states; only values + deltas flow from the API.
|
||||
*/
|
||||
const KPI_LABELS = [
|
||||
"Agents active",
|
||||
"Scenarios",
|
||||
"Eval pass rate (7d)",
|
||||
"Docs / 24h",
|
||||
const KPI_LABEL_KEYS = [
|
||||
"sources.kpi.agentsActive",
|
||||
"sources.kpi.scenarios",
|
||||
"sources.kpi.evalPassRate",
|
||||
"sources.kpi.docs24h",
|
||||
] as const;
|
||||
|
||||
interface KpiStripProps {
|
||||
@@ -19,14 +20,15 @@ interface KpiStripProps {
|
||||
}
|
||||
|
||||
export function KpiStrip({ data, loading }: KpiStripProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABELS.map((label, i) => {
|
||||
{KPI_LABEL_KEYS.map((labelKey, i) => {
|
||||
const k = loading ? undefined : data?.kpis[i];
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
label={label}
|
||||
key={labelKey}
|
||||
label={t(labelKey)}
|
||||
value={k?.value ?? "—"}
|
||||
delta={k?.delta}
|
||||
deltaDirection={k?.deltaDirection}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Source, SOURCE_TYPE_META } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import "@portal/views/Sources.css";
|
||||
@@ -9,6 +10,7 @@ interface SourceDetailCardProps {
|
||||
|
||||
/** Expanded type-specific detail for the selected table row. */
|
||||
export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = SOURCE_TYPE_META[source.type];
|
||||
return (
|
||||
<section className="portal-sources__expanded">
|
||||
@@ -22,14 +24,17 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {
|
||||
<div>
|
||||
<h2 className="portal-sources__expanded-title">{source.name}</h2>
|
||||
<span className="portal-sources__expanded-sub">
|
||||
{meta.label} · owned by {source.owner}
|
||||
{t("sources.detail.ownedBy", {
|
||||
type: meta.label,
|
||||
owner: source.owner,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-sources__expanded-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close detail"
|
||||
aria-label={t("sources.detail.closeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, StatusBadge, Table, type TableColumn } from "@shared/components";
|
||||
import {
|
||||
type Source,
|
||||
@@ -19,11 +20,12 @@ export function SourcesTable({
|
||||
expandedId,
|
||||
onRowClick,
|
||||
}: SourcesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<Source>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Source",
|
||||
header: t("sources.table.source"),
|
||||
render: (s) => {
|
||||
const meta = SOURCE_TYPE_META[s.type];
|
||||
return (
|
||||
@@ -46,7 +48,7 @@ export function SourcesTable({
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
header: t("sources.table.status"),
|
||||
render: (s) => (
|
||||
<StatusBadge
|
||||
tone={SOURCE_STATUS_TONE[s.status]}
|
||||
@@ -59,26 +61,26 @@ export function SourcesTable({
|
||||
},
|
||||
{
|
||||
key: "docs24h",
|
||||
header: "Docs / 24h",
|
||||
header: t("sources.table.docs24h"),
|
||||
align: "right",
|
||||
render: (s) => s.docs24h.toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "docs30d",
|
||||
header: "Docs / 30d",
|
||||
header: t("sources.table.docs30d"),
|
||||
align: "right",
|
||||
render: (s) => s.docs30d.toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "lastEvent",
|
||||
header: "Last event",
|
||||
header: t("sources.table.lastEvent"),
|
||||
render: (s) => (
|
||||
<span className="portal-sources__muted">{s.lastEvent}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
header: "Owner",
|
||||
header: t("sources.table.owner"),
|
||||
render: (s) => <span className="portal-sources__muted">{s.owner}</span>,
|
||||
},
|
||||
{
|
||||
@@ -98,7 +100,7 @@ export function SourcesTable({
|
||||
),
|
||||
},
|
||||
],
|
||||
[expandedId],
|
||||
[expandedId, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatTile, StatusBadge } from "@shared/components";
|
||||
import type { WebhookDetail } from "@portal/api/sources";
|
||||
import { pct } from "@portal/components/sources/format";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
export function WebhookPanel({ d }: { d: WebhookDetail }) {
|
||||
const { t } = useTranslation();
|
||||
const rateTone =
|
||||
d.successRate >= 0.99
|
||||
? "success"
|
||||
@@ -14,24 +16,27 @@ export function WebhookPanel({ d }: { d: WebhookDetail }) {
|
||||
<div className="portal-sources__detail">
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label="Endpoint URL"
|
||||
label={t("sources.webhook.endpointUrl")}
|
||||
value={<code className="portal-sources__url">{d.url}</code>}
|
||||
/>
|
||||
<StatTile label="Auth type" value={d.authType} />
|
||||
<StatTile label={t("sources.webhook.authType")} value={d.authType} />
|
||||
<StatTile
|
||||
label="Success rate"
|
||||
label={t("sources.webhook.successRate")}
|
||||
value={
|
||||
<StatusBadge tone={rateTone} size="sm">
|
||||
{pct(d.successRate)}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
<StatTile label="Retries / 24h" value={d.retries24h} />
|
||||
<StatTile
|
||||
label={t("sources.webhook.retries24h")}
|
||||
value={d.retries24h}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">
|
||||
Recent deliveries
|
||||
{t("sources.webhook.recentDeliveries")}
|
||||
</span>
|
||||
<div className="portal-sources__endpoints">
|
||||
{d.recentDeliveries.map((r, i) => (
|
||||
@@ -54,10 +59,10 @@ export function WebhookPanel({ d }: { d: WebhookDetail }) {
|
||||
GET /v1/sources/{id}/signing-secret — currently inert demo controls. */}
|
||||
<div className="portal-sources__detail-actions">
|
||||
<Button size="sm" variant="outline">
|
||||
Send test event
|
||||
{t("sources.webhook.sendTestEvent")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
View signing secret
|
||||
{t("sources.webhook.viewSigningSecret")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { PlanOption } from "@portal/api/usage";
|
||||
import { PlanCard } from "@portal/components/usage/PlanCard";
|
||||
@@ -13,13 +14,14 @@ export function AvailablePlans({
|
||||
current: Tier;
|
||||
onSelect: (plan: PlanOption) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="portal-usage__plans-block">
|
||||
<header className="portal-usage__section-head">
|
||||
<h2 className="portal-usage__section-title">Plans</h2>
|
||||
<p className="portal-usage__section-sub">
|
||||
Move up or down at any time — changes take effect next cycle.
|
||||
</p>
|
||||
<h2 className="portal-usage__section-title">
|
||||
{t("usage.plans.title")}
|
||||
</h2>
|
||||
<p className="portal-usage__section-sub">{t("usage.plans.subtitle")}</p>
|
||||
</header>
|
||||
<div className="portal-usage__plans-grid">
|
||||
{plans.map((plan) => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
EmptyState,
|
||||
@@ -24,16 +25,17 @@ const STATUS_TONE: Record<InvoiceStatus, StatusTone> = {
|
||||
refunded: "neutral",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<InvoiceStatus, string> = {
|
||||
paid: "Paid",
|
||||
due: "Due",
|
||||
pending: "Pending",
|
||||
refunded: "Refunded",
|
||||
};
|
||||
|
||||
/** Invoice / line-item history for the current and prior billing cycles. */
|
||||
export function BillingHistoryTable() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
|
||||
const statusLabel: Record<InvoiceStatus, string> = {
|
||||
paid: t("usage.history.status.paid"),
|
||||
due: t("usage.history.status.due"),
|
||||
pending: t("usage.history.status.pending"),
|
||||
refunded: t("usage.history.status.refunded"),
|
||||
};
|
||||
const state = useAsync<BillingHistoryRow[]>(
|
||||
() => fetchBillingHistory(tier),
|
||||
[tier],
|
||||
@@ -44,7 +46,7 @@ export function BillingHistoryTable() {
|
||||
const columns: TableColumn<BillingHistoryRow>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: "Date",
|
||||
header: t("usage.history.columns.date"),
|
||||
render: (r) => (
|
||||
<span className="portal-usage__hist-date">
|
||||
{formatBillingDate(r.date)}
|
||||
@@ -54,19 +56,19 @@ export function BillingHistoryTable() {
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Description",
|
||||
header: t("usage.history.columns.description"),
|
||||
render: (r) => r.description,
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: "Docs",
|
||||
header: t("usage.history.columns.docs"),
|
||||
align: "right",
|
||||
render: (r) => (r.docs > 0 ? r.docs.toLocaleString() : "—"),
|
||||
width: "8rem",
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: "Amount",
|
||||
header: t("usage.history.columns.amount"),
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<span
|
||||
@@ -85,11 +87,11 @@ export function BillingHistoryTable() {
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
header: t("usage.history.columns.status"),
|
||||
align: "right",
|
||||
render: (r) => (
|
||||
<StatusBadge tone={STATUS_TONE[r.status]} size="sm">
|
||||
{STATUS_LABEL[r.status]}
|
||||
{statusLabel[r.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
width: "8rem",
|
||||
@@ -99,9 +101,11 @@ export function BillingHistoryTable() {
|
||||
return (
|
||||
<section className="portal-usage__hist-block">
|
||||
<header className="portal-usage__section-head">
|
||||
<h2 className="portal-usage__section-title">Billing history</h2>
|
||||
<h2 className="portal-usage__section-title">
|
||||
{t("usage.history.title")}
|
||||
</h2>
|
||||
<p className="portal-usage__section-sub">
|
||||
Line items from the current and prior billing cycles.
|
||||
{t("usage.history.subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -116,8 +120,8 @@ export function BillingHistoryTable() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No billing history"
|
||||
description="Charges and credits appear here once your first cycle closes."
|
||||
title={t("usage.history.empty.title")}
|
||||
description={t("usage.history.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -127,7 +131,7 @@ export function BillingHistoryTable() {
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No line items"
|
||||
empty={t("usage.history.emptyRows")}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage";
|
||||
@@ -10,6 +11,7 @@ export function BillingKpiStrip({
|
||||
}: {
|
||||
summary: BillingSummary | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
|
||||
// Overage is meaningless on free (gated) / enterprise (committed) — surface
|
||||
@@ -17,47 +19,56 @@ export function BillingKpiStrip({
|
||||
const overageCard =
|
||||
tier === "free"
|
||||
? {
|
||||
label: "Remaining in plan",
|
||||
label: t("usage.kpi.remainingInPlan.label"),
|
||||
value: summary
|
||||
? `${(summary.includedDocs - summary.docsThisPeriod).toLocaleString()}`
|
||||
: "—",
|
||||
description: "docs before cap",
|
||||
description: t("usage.kpi.remainingInPlan.description"),
|
||||
}
|
||||
: tier === "enterprise"
|
||||
? {
|
||||
label: "Commit utilisation",
|
||||
label: t("usage.kpi.commitUtilisation.label"),
|
||||
value: summary
|
||||
? `${Math.round((summary.docsThisPeriod / summary.includedDocs) * 100)}%`
|
||||
: "—",
|
||||
description: "of committed volume",
|
||||
description: t("usage.kpi.commitUtilisation.description"),
|
||||
}
|
||||
: {
|
||||
label: `Overage ($${OVERAGE_RATE.toFixed(2)}/doc)`,
|
||||
label: t("usage.kpi.overage.label", {
|
||||
rate: OVERAGE_RATE.toFixed(2),
|
||||
}),
|
||||
value: summary ? USD.format(summary.overageCost) : "—",
|
||||
description: summary
|
||||
? `${summary.overageDocs.toLocaleString()} docs past cap`
|
||||
? t("usage.kpi.overage.description", {
|
||||
count: summary.overageDocs,
|
||||
docs: summary.overageDocs.toLocaleString(),
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricCard
|
||||
label="Docs this period"
|
||||
label={t("usage.kpi.docsThisPeriod.label")}
|
||||
value={summary ? summary.docsThisPeriod.toLocaleString() : "—"}
|
||||
description={
|
||||
summary
|
||||
? `of ${summary.includedDocs.toLocaleString()} included`
|
||||
? t("usage.kpi.docsThisPeriod.description", {
|
||||
included: summary.includedDocs.toLocaleString(),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Cost this month"
|
||||
label={t("usage.kpi.costThisMonth.label")}
|
||||
value={summary ? USD.format(summary.costThisMonth) : "—"}
|
||||
description={
|
||||
summary && summary.monthlyFee > 0
|
||||
? `incl. ${USD.format(summary.monthlyFee)} platform`
|
||||
? t("usage.kpi.costThisMonth.description", {
|
||||
fee: USD.format(summary.monthlyFee),
|
||||
})
|
||||
: tier === "free"
|
||||
? "free plan"
|
||||
? t("usage.kpi.costThisMonth.freePlan")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -67,9 +78,13 @@ export function BillingKpiStrip({
|
||||
description={overageCard.description}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Next billing date"
|
||||
label={t("usage.kpi.nextBillingDate.label")}
|
||||
value={summary ? formatBillingDate(summary.nextBillingDate) : "—"}
|
||||
description={tier === "free" ? "resets monthly" : "auto-charge"}
|
||||
description={
|
||||
tier === "free"
|
||||
? t("usage.kpi.nextBillingDate.resetsMonthly")
|
||||
: t("usage.kpi.nextBillingDate.autoCharge")
|
||||
}
|
||||
/>
|
||||
</MetricStrip>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -44,6 +45,7 @@ export function CurrentPlanCard({
|
||||
summary: BillingSummary;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const usedRatio = summary.docsThisPeriod / summary.includedDocs;
|
||||
|
||||
@@ -51,7 +53,9 @@ export function CurrentPlanCard({
|
||||
<Card padding="loose" className="portal-usage__plan-current">
|
||||
<div className="portal-usage__plan-current-head">
|
||||
<div>
|
||||
<span className="portal-usage__plan-eyebrow">Current plan</span>
|
||||
<span className="portal-usage__plan-eyebrow">
|
||||
{t("usage.currentPlan.eyebrow")}
|
||||
</span>
|
||||
<h2 className="portal-usage__plan-name">{summary.planName}</h2>
|
||||
</div>
|
||||
<StatusBadge
|
||||
@@ -65,10 +69,10 @@ export function CurrentPlanCard({
|
||||
size="sm"
|
||||
>
|
||||
{tier === "free"
|
||||
? "Free"
|
||||
? t("usage.currentPlan.badge.free")
|
||||
: tier === "pro"
|
||||
? "Pay-as-you-go"
|
||||
: "Committed"}
|
||||
? t("usage.currentPlan.badge.pro")
|
||||
: t("usage.currentPlan.badge.enterprise")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
@@ -88,18 +92,24 @@ export function CurrentPlanCard({
|
||||
value={usedRatio}
|
||||
thresholded
|
||||
height={8}
|
||||
label="Free plan usage"
|
||||
label={t("usage.currentPlan.free.progressLabel")}
|
||||
/>
|
||||
</div>
|
||||
{summary.capReached ? (
|
||||
<Banner tone="danger" title="You've hit your free plan cap">
|
||||
New documents are paused until next cycle. Upgrade to keep
|
||||
processing without interruption.
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("usage.currentPlan.free.capReached.title")}
|
||||
>
|
||||
{t("usage.currentPlan.free.capReached.body")}
|
||||
</Banner>
|
||||
) : (
|
||||
<Banner tone="warning" title="Approaching your free plan cap">
|
||||
You're at {Math.round(usedRatio * 100)}% of 500 docs/month.
|
||||
Upgrade to pay-as-you-go to avoid a pause.
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t("usage.currentPlan.free.approaching.title")}
|
||||
>
|
||||
{t("usage.currentPlan.free.approaching.body", {
|
||||
pct: Math.round(usedRatio * 100),
|
||||
})}
|
||||
</Banner>
|
||||
)}
|
||||
</>
|
||||
@@ -108,19 +118,22 @@ export function CurrentPlanCard({
|
||||
{tier === "pro" && (
|
||||
<div className="portal-usage__breakdown">
|
||||
<BreakdownRow
|
||||
label="Platform fee"
|
||||
label={t("usage.currentPlan.pro.platformFee")}
|
||||
value={USD.format(summary.monthlyFee)}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Included docs"
|
||||
label={t("usage.currentPlan.pro.includedDocs")}
|
||||
value={`${summary.includedDocs.toLocaleString()}`}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label={`Overage · ${summary.overageDocs.toLocaleString()} docs @ $${OVERAGE_RATE.toFixed(2)}`}
|
||||
label={t("usage.currentPlan.pro.overage", {
|
||||
docs: summary.overageDocs.toLocaleString(),
|
||||
rate: OVERAGE_RATE.toFixed(2),
|
||||
})}
|
||||
value={USD.format(summary.overageCost)}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Projected this month"
|
||||
label={t("usage.currentPlan.pro.projected")}
|
||||
value={USD.format(summary.costThisMonth)}
|
||||
emphasis
|
||||
/>
|
||||
@@ -130,19 +143,25 @@ export function CurrentPlanCard({
|
||||
{tier === "enterprise" && (
|
||||
<div className="portal-usage__breakdown">
|
||||
<BreakdownRow
|
||||
label="Committed volume"
|
||||
value={`${summary.includedDocs.toLocaleString()} docs/mo`}
|
||||
label={t("usage.currentPlan.enterprise.committedVolume")}
|
||||
value={t("usage.currentPlan.enterprise.committedVolumeValue", {
|
||||
docs: summary.includedDocs.toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Drawn this period"
|
||||
value={`${summary.docsThisPeriod.toLocaleString()} docs`}
|
||||
label={t("usage.currentPlan.enterprise.drawnThisPeriod")}
|
||||
value={t("usage.currentPlan.enterprise.drawnThisPeriodValue", {
|
||||
docs: summary.docsThisPeriod.toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Effective rate"
|
||||
value={`$${summary.overageRate.toFixed(3)} / doc`}
|
||||
label={t("usage.currentPlan.enterprise.effectiveRate")}
|
||||
value={t("usage.currentPlan.enterprise.effectiveRateValue", {
|
||||
rate: summary.overageRate.toFixed(3),
|
||||
})}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Monthly draw"
|
||||
label={t("usage.currentPlan.enterprise.monthlyDraw")}
|
||||
value={USD.format(summary.monthlyFee)}
|
||||
emphasis
|
||||
/>
|
||||
@@ -156,16 +175,18 @@ export function CurrentPlanCard({
|
||||
accent={tier === "free" ? "blue" : "purple"}
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{tier === "free" ? "Upgrade plan" : "Talk to sales"}
|
||||
{tier === "free"
|
||||
? t("usage.currentPlan.actions.upgrade")
|
||||
: t("usage.currentPlan.actions.talkToSales")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" accent="purple" onClick={onUpgrade}>
|
||||
Adjust commitment
|
||||
{t("usage.currentPlan.actions.adjustCommitment")}
|
||||
</Button>
|
||||
)}
|
||||
{/* TODO(backend): GET /v1/billing/invoices?format=pdf — bundle + download invoice PDFs. */}
|
||||
<Button variant="ghost" size="md">
|
||||
Download invoices
|
||||
{t("usage.currentPlan.actions.downloadInvoices")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, StatusBadge } from "@shared/components";
|
||||
import type { PlanOption } from "@portal/api/usage";
|
||||
import "@portal/views/Usage.css";
|
||||
@@ -12,6 +13,7 @@ export function PlanCard({
|
||||
isCurrent: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const accent = plan.tier === "enterprise" ? "purple" : "blue";
|
||||
return (
|
||||
<Card
|
||||
@@ -26,7 +28,7 @@ export function PlanCard({
|
||||
<h3 className="portal-usage__plan-card-name">{plan.name}</h3>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
Current
|
||||
{t("usage.planCard.current")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
@@ -56,10 +58,10 @@ export function PlanCard({
|
||||
onClick={onSelect}
|
||||
>
|
||||
{isCurrent
|
||||
? "Your plan"
|
||||
? t("usage.planCard.yourPlan")
|
||||
: plan.tier === "enterprise"
|
||||
? "Contact sales"
|
||||
: "Choose plan"}
|
||||
? t("usage.planCard.contactSales")
|
||||
: t("usage.planCard.choosePlan")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -16,6 +17,7 @@ import "@portal/views/Usage.css";
|
||||
* enterprise render explanatory cards instead of the interactive slider.
|
||||
*/
|
||||
export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [enabled, setEnabled] = useState(summary.spendCap !== null);
|
||||
const [cap, setCap] = useState(summary.spendCap ?? 1_000);
|
||||
@@ -23,10 +25,11 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
if (tier === "free") {
|
||||
return (
|
||||
<Card padding="loose" className="portal-usage__cap-card">
|
||||
<h2 className="portal-usage__section-title">Spend cap</h2>
|
||||
<h2 className="portal-usage__section-title">
|
||||
{t("usage.spendCap.free.title")}
|
||||
</h2>
|
||||
<p className="portal-usage__section-sub">
|
||||
The free plan can't accrue spend — your usage is hard-capped at 500
|
||||
docs/month. Upgrade to pay-as-you-go to set a monthly spend cap.
|
||||
{t("usage.spendCap.free.description")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
@@ -35,16 +38,21 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
if (tier === "enterprise") {
|
||||
return (
|
||||
<Card padding="loose" className="portal-usage__cap-card">
|
||||
<h2 className="portal-usage__section-title">Spend controls</h2>
|
||||
<h2 className="portal-usage__section-title">
|
||||
{t("usage.spendCap.enterprise.title")}
|
||||
</h2>
|
||||
<p className="portal-usage__section-sub">
|
||||
Spend is governed by your committed-volume contract. Overage terms and
|
||||
alert thresholds are managed with your account team.
|
||||
{t("usage.spendCap.enterprise.description")}
|
||||
</p>
|
||||
<div className="portal-usage__cap-meta">
|
||||
<StatusBadge tone="purple" size="sm">
|
||||
Committed contract
|
||||
{t("usage.spendCap.enterprise.badge")}
|
||||
</StatusBadge>
|
||||
<span>Overage billed at ${summary.overageRate.toFixed(3)}/doc</span>
|
||||
<span>
|
||||
{t("usage.spendCap.enterprise.overage", {
|
||||
rate: summary.overageRate.toFixed(3),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -59,9 +67,11 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
<Card padding="loose" className="portal-usage__cap-card">
|
||||
<div className="portal-usage__cap-card-head">
|
||||
<div>
|
||||
<h2 className="portal-usage__section-title">Monthly spend cap</h2>
|
||||
<h2 className="portal-usage__section-title">
|
||||
{t("usage.spendCap.pro.title")}
|
||||
</h2>
|
||||
<p className="portal-usage__section-sub">
|
||||
Pause processing automatically when spend reaches your limit.
|
||||
{t("usage.spendCap.pro.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -69,7 +79,9 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
size="sm"
|
||||
onClick={() => setEnabled((v) => !v)}
|
||||
>
|
||||
{enabled ? "Disable cap" : "Enable cap"}
|
||||
{enabled
|
||||
? t("usage.spendCap.pro.disable")
|
||||
: t("usage.spendCap.pro.enable")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +99,10 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
</div>
|
||||
<div className="portal-usage__cap-row">
|
||||
<span>
|
||||
Projected {USD.format(projected)} of {USD.format(cap)} cap
|
||||
{t("usage.spendCap.pro.projected", {
|
||||
projected: USD.format(projected),
|
||||
cap: USD.format(cap),
|
||||
})}
|
||||
</span>
|
||||
<span className="portal-usage__cap-pct">
|
||||
{Math.round(capRatio * 100)}%
|
||||
@@ -97,7 +112,7 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
|
||||
value={capRatio}
|
||||
thresholded
|
||||
height={8}
|
||||
label="Spend against cap"
|
||||
label={t("usage.spendCap.pro.progressLabel")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Button, Modal } from "@shared/components";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { PlanOption } from "@portal/api/usage";
|
||||
@@ -18,22 +20,23 @@ interface UpgradeCopy {
|
||||
* enterprise user is routed to their account team for bespoke terms.
|
||||
*/
|
||||
function upgradeCopy(
|
||||
t: TFunction,
|
||||
currentTier: Tier,
|
||||
target: PlanOption | null,
|
||||
): UpgradeCopy {
|
||||
// Cap-reached: free user pushed to pay-as-you-go.
|
||||
if (currentTier === "free") {
|
||||
return {
|
||||
title: "Upgrade to keep processing",
|
||||
subtitle: "Pay-as-you-go · $0.05 / doc",
|
||||
body: "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs.",
|
||||
title: t("usage.upgrade.free.title"),
|
||||
subtitle: t("usage.upgrade.free.subtitle"),
|
||||
body: t("usage.upgrade.free.body"),
|
||||
bullets: [
|
||||
"Lift the 500 doc/month cap immediately",
|
||||
"25,000 docs included, then $0.05/doc",
|
||||
"Unlimited pipelines, agents, and sources",
|
||||
"Set a monthly spend cap to stay in control",
|
||||
t("usage.upgrade.free.bullets.0"),
|
||||
t("usage.upgrade.free.bullets.1"),
|
||||
t("usage.upgrade.free.bullets.2"),
|
||||
t("usage.upgrade.free.bullets.3"),
|
||||
],
|
||||
cta: "Switch to pay-as-you-go",
|
||||
cta: t("usage.upgrade.free.cta"),
|
||||
ctaAccent: "blue",
|
||||
};
|
||||
}
|
||||
@@ -42,44 +45,44 @@ function upgradeCopy(
|
||||
if (currentTier === "pro") {
|
||||
if (target?.tier === "enterprise") {
|
||||
return {
|
||||
title: "Move to a committed plan",
|
||||
subtitle: "Enterprise · committed annual volume",
|
||||
body: "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM.",
|
||||
title: t("usage.upgrade.proToEnterprise.title"),
|
||||
subtitle: t("usage.upgrade.proToEnterprise.subtitle"),
|
||||
body: t("usage.upgrade.proToEnterprise.body"),
|
||||
bullets: [
|
||||
"Lower effective rate vs metered overage",
|
||||
"Dedicated & on-prem region options",
|
||||
"SSO, audit-log export, signed DPA",
|
||||
"Named CSM and 99.99% SLA",
|
||||
t("usage.upgrade.proToEnterprise.bullets.0"),
|
||||
t("usage.upgrade.proToEnterprise.bullets.1"),
|
||||
t("usage.upgrade.proToEnterprise.bullets.2"),
|
||||
t("usage.upgrade.proToEnterprise.bullets.3"),
|
||||
],
|
||||
cta: "Talk to sales",
|
||||
cta: t("usage.upgrade.proToEnterprise.cta"),
|
||||
ctaAccent: "purple",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "You're already on pay-as-you-go",
|
||||
subtitle: "Considering a committed plan?",
|
||||
body: "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate.",
|
||||
title: t("usage.upgrade.pro.title"),
|
||||
subtitle: t("usage.upgrade.pro.subtitle"),
|
||||
body: t("usage.upgrade.pro.body"),
|
||||
bullets: [
|
||||
"Predictable monthly spend",
|
||||
"Lower effective per-doc rate at volume",
|
||||
"Volume discounts kick in past 1M docs/mo",
|
||||
t("usage.upgrade.pro.bullets.0"),
|
||||
t("usage.upgrade.pro.bullets.1"),
|
||||
t("usage.upgrade.pro.bullets.2"),
|
||||
],
|
||||
cta: "Explore committed pricing",
|
||||
cta: t("usage.upgrade.pro.cta"),
|
||||
ctaAccent: "purple",
|
||||
};
|
||||
}
|
||||
|
||||
// Bespoke-enterprise: route to account team.
|
||||
return {
|
||||
title: "Adjust your commitment",
|
||||
subtitle: "Enterprise · bespoke terms",
|
||||
body: "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you.",
|
||||
title: t("usage.upgrade.enterprise.title"),
|
||||
subtitle: t("usage.upgrade.enterprise.subtitle"),
|
||||
body: t("usage.upgrade.enterprise.body"),
|
||||
bullets: [
|
||||
"Re-model committed volume up or down",
|
||||
"Add dedicated or on-prem regions",
|
||||
"Adjust SLA, DPA, and overage terms",
|
||||
t("usage.upgrade.enterprise.bullets.0"),
|
||||
t("usage.upgrade.enterprise.bullets.1"),
|
||||
t("usage.upgrade.enterprise.bullets.2"),
|
||||
],
|
||||
cta: "Contact your CSM",
|
||||
cta: t("usage.upgrade.enterprise.cta"),
|
||||
ctaAccent: "purple",
|
||||
};
|
||||
}
|
||||
@@ -96,7 +99,8 @@ export function UpgradeModal({
|
||||
currentTier: Tier;
|
||||
target: PlanOption | null;
|
||||
}) {
|
||||
const copy = upgradeCopy(currentTier, target);
|
||||
const { t } = useTranslation();
|
||||
const copy = upgradeCopy(t, currentTier, target);
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -107,7 +111,7 @@ export function UpgradeModal({
|
||||
footer={
|
||||
<div className="portal-usage__modal-actions">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Not now
|
||||
{t("usage.upgrade.notNow")}
|
||||
</Button>
|
||||
{/* TODO(backend): POST /v1/billing/plan-change { tier } (or hand off to
|
||||
sales) — for now the CTA just dismisses the modal. */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState, Skeleton } from "@shared/components";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchBillingUsage, type UsageSeriesResponse } from "@portal/api/usage";
|
||||
@@ -7,6 +8,7 @@ import "@portal/components/UsageAreaChart.css";
|
||||
|
||||
/** 30-day docs-processed area chart, with the period total and prior-period delta. */
|
||||
export function UsageChart() {
|
||||
const { t } = useTranslation();
|
||||
const state = useAsync<UsageSeriesResponse>(() => fetchBillingUsage(), []);
|
||||
const { data: usage } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
@@ -33,8 +35,8 @@ export function UsageChart() {
|
||||
if (!usage || usage.points.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No usage yet"
|
||||
description="Once documents are processed, your 30-day usage appears here."
|
||||
title={t("usage.chart.empty.title")}
|
||||
description={t("usage.chart.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -25,6 +26,7 @@ interface AccessControlsProps {
|
||||
* Toggles hold local state only; persisting them is a backend wiring task.
|
||||
*/
|
||||
export function AccessControls({ access }: AccessControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mfaEnforced, setMfaEnforced] = useState(access.mfaEnforced ?? false);
|
||||
const [shortSessions, setShortSessions] = useState(false);
|
||||
|
||||
@@ -36,9 +38,11 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
return (
|
||||
<section className="portal-users__access">
|
||||
<header className="portal-users__section-head">
|
||||
<h2 className="portal-users__section-title">Access & security</h2>
|
||||
<h2 className="portal-users__section-title">
|
||||
{t("users.access.title")}
|
||||
</h2>
|
||||
<p className="portal-users__section-sub">
|
||||
Seats, authentication and provisioning for your organization.
|
||||
{t("users.access.subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -46,20 +50,25 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
{/* Seats — shown on every tier. */}
|
||||
<Card padding="default">
|
||||
<div className="portal-users__access-card-head">
|
||||
<h3 className="portal-users__access-card-title">Seats</h3>
|
||||
<h3 className="portal-users__access-card-title">
|
||||
{t("users.access.seats.title")}
|
||||
</h3>
|
||||
<span className="portal-users__muted">
|
||||
{seatsLabel(access.seatsUsed, access.seatLimit)}
|
||||
</span>
|
||||
</div>
|
||||
{access.seatLimit === null ? (
|
||||
<p className="portal-users__access-note">
|
||||
Your plan includes unlimited seats.
|
||||
{t("users.access.seats.unlimited")}
|
||||
</p>
|
||||
) : (
|
||||
<ProgressBar
|
||||
value={seatPct}
|
||||
thresholded
|
||||
label={`${access.seatsUsed} of ${access.seatLimit} seats used`}
|
||||
label={t("users.access.seats.usedLabel", {
|
||||
used: access.seatsUsed,
|
||||
limit: access.seatLimit,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -67,7 +76,9 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
{/* Pro+: MFA + sessions self-service. */}
|
||||
{access.mfaAvailable && (
|
||||
<Card padding="default">
|
||||
<h3 className="portal-users__access-card-title">Authentication</h3>
|
||||
<h3 className="portal-users__access-card-title">
|
||||
{t("users.access.auth.title")}
|
||||
</h3>
|
||||
<div className="portal-users__toggle-rows">
|
||||
<div className="portal-users__toggle-row">
|
||||
<ToggleSwitch
|
||||
@@ -76,11 +87,11 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
setMfaEnforced(v);
|
||||
// TODO(backend): PATCH /v1/users/access { mfaEnforced }
|
||||
}}
|
||||
label="Require MFA"
|
||||
label={t("users.access.auth.requireMfa.label")}
|
||||
description={
|
||||
access.mfaEnforced
|
||||
? "Enforced org-wide on this plan."
|
||||
: "Members must set up a second factor to sign in."
|
||||
? t("users.access.auth.requireMfa.enforced")
|
||||
: t("users.access.auth.requireMfa.description")
|
||||
}
|
||||
disabled={access.mfaEnforced}
|
||||
/>
|
||||
@@ -92,8 +103,13 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
setShortSessions(v);
|
||||
// TODO(backend): PATCH /v1/users/access { sessionTimeout }
|
||||
}}
|
||||
label="Short-lived sessions"
|
||||
description={`Sign members out after inactivity (currently ${access.sessionTimeout}).`}
|
||||
label={t("users.access.auth.shortSessions.label")}
|
||||
description={t(
|
||||
"users.access.auth.shortSessions.description",
|
||||
{
|
||||
timeout: access.sessionTimeout,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,25 +120,30 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
{access.sso && (
|
||||
<Card padding="default">
|
||||
<div className="portal-users__access-card-head">
|
||||
<h3 className="portal-users__access-card-title">SSO / SAML</h3>
|
||||
<h3 className="portal-users__access-card-title">
|
||||
{t("users.access.sso.title")}
|
||||
</h3>
|
||||
<StatusBadge
|
||||
tone={access.sso.status === "connected" ? "success" : "neutral"}
|
||||
size="sm"
|
||||
>
|
||||
{access.sso.status === "connected"
|
||||
? "Connected"
|
||||
: "Not configured"}
|
||||
? t("users.access.sso.connected")
|
||||
: t("users.access.sso.notConfigured")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="portal-users__access-stats">
|
||||
<StatTile label="Provider" value={access.sso.provider} />
|
||||
<StatTile
|
||||
label="Domains"
|
||||
label={t("users.access.sso.provider")}
|
||||
value={access.sso.provider}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("users.access.sso.domains")}
|
||||
value={access.sso.domains.join(", ") || "—"}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage connection
|
||||
{t("users.access.sso.manage")}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
@@ -132,22 +153,29 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
<Card padding="default">
|
||||
<div className="portal-users__access-card-head">
|
||||
<h3 className="portal-users__access-card-title">
|
||||
SCIM provisioning
|
||||
{t("users.access.scim.title")}
|
||||
</h3>
|
||||
<StatusBadge
|
||||
tone={access.scim.enabled ? "success" : "neutral"}
|
||||
size="sm"
|
||||
>
|
||||
{access.scim.enabled ? "Active" : "Off"}
|
||||
{access.scim.enabled
|
||||
? t("users.access.scim.active")
|
||||
: t("users.access.scim.off")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="portal-users__access-stats">
|
||||
<StatTile label="Directory" value={access.scim.directory} />
|
||||
<StatTile label="Last sync" value={access.scim.lastSync} />
|
||||
<StatTile
|
||||
label={t("users.access.scim.directory")}
|
||||
value={access.scim.directory}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("users.access.scim.lastSync")}
|
||||
value={access.scim.lastSync}
|
||||
/>
|
||||
</div>
|
||||
<p className="portal-users__access-note">
|
||||
Members are created, updated and deactivated automatically from
|
||||
your identity provider.
|
||||
{t("users.access.scim.note")}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
@@ -157,11 +185,11 @@ export function AccessControls({ access }: AccessControlsProps) {
|
||||
{access.upgradeHint && (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="Unlock team access controls"
|
||||
title={t("users.access.upgrade.title")}
|
||||
description={access.upgradeHint}
|
||||
action={
|
||||
<Button size="sm" accent="purple">
|
||||
Upgrade plan
|
||||
{t("users.access.upgrade.action")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, FormField, Input, Modal, Select } from "@shared/components";
|
||||
import { type RoleId, ROLES } from "@portal/api/users";
|
||||
import "@portal/views/Users.css";
|
||||
@@ -23,13 +24,14 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
* sending — wiring the submit to the backend dispatches the invitation.
|
||||
*/
|
||||
export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<RoleId>(DEFAULT_ROLE);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const emailValid = EMAIL_RE.test(email.trim());
|
||||
const error =
|
||||
touched && !emailValid ? "Enter a valid email address" : undefined;
|
||||
touched && !emailValid ? t("users.invite.emailError") : undefined;
|
||||
|
||||
function close() {
|
||||
onClose();
|
||||
@@ -54,24 +56,24 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="sm"
|
||||
title="Invite member"
|
||||
subtitle="They'll receive an email to join your organization."
|
||||
title={t("common.inviteMember")}
|
||||
subtitle={t("users.invite.subtitle")}
|
||||
footer={
|
||||
<div className="portal-users__modal-footer">
|
||||
<Button variant="ghost" size="sm" onClick={close}>
|
||||
Cancel
|
||||
{t("users.invite.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit} disabled={touched && !emailValid}>
|
||||
Send invite
|
||||
{t("users.invite.send")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-users__invite-body">
|
||||
<FormField label="Email" error={error} required>
|
||||
<FormField label={t("users.invite.email")} error={error} required>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="teammate@acme.com"
|
||||
placeholder={t("users.invite.emailPlaceholder")}
|
||||
value={email}
|
||||
invalid={!!error}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
@@ -79,8 +81,8 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Role"
|
||||
helperText="Determines what the member can do once they join."
|
||||
label={t("users.invite.role")}
|
||||
helperText={t("users.invite.roleHelper")}
|
||||
>
|
||||
<Select
|
||||
options={ROLE_SELECT_OPTIONS}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -34,11 +35,12 @@ export function MembersTable({
|
||||
onSuspend,
|
||||
onRemove,
|
||||
}: MembersTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<Member>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Member",
|
||||
header: t("users.table.member"),
|
||||
render: (m) => (
|
||||
<div className="portal-users__member-cell">
|
||||
<Avatar
|
||||
@@ -59,7 +61,7 @@ export function MembersTable({
|
||||
},
|
||||
{
|
||||
key: "role",
|
||||
header: "Role",
|
||||
header: t("users.table.role"),
|
||||
render: (m) => (
|
||||
<Chip tone={ROLE_TONE[m.role]} size="sm">
|
||||
{ROLE_LABEL[m.role]}
|
||||
@@ -68,7 +70,7 @@ export function MembersTable({
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
header: t("users.table.status"),
|
||||
render: (m) => (
|
||||
<StatusBadge
|
||||
tone={MEMBER_STATUS_TONE[m.status]}
|
||||
@@ -81,7 +83,7 @@ export function MembersTable({
|
||||
},
|
||||
{
|
||||
key: "lastActive",
|
||||
header: "Last active",
|
||||
header: t("users.table.lastActive"),
|
||||
render: (m) => (
|
||||
<span className="portal-users__muted">{m.lastActive}</span>
|
||||
),
|
||||
@@ -99,14 +101,14 @@ export function MembersTable({
|
||||
<button
|
||||
type="button"
|
||||
className="portal-users__row-action"
|
||||
aria-label={`Actions for ${m.name}`}
|
||||
aria-label={t("users.table.actionsFor", { name: m.name })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Change role</Menu.Label>
|
||||
<Menu.Label>{t("users.table.changeRole")}</Menu.Label>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<Menu.Item
|
||||
key={role}
|
||||
@@ -118,17 +120,19 @@ export function MembersTable({
|
||||
))}
|
||||
<Menu.Divider />
|
||||
{m.status !== "suspended" && (
|
||||
<Menu.Item onClick={() => onSuspend(m)}>Suspend</Menu.Item>
|
||||
<Menu.Item onClick={() => onSuspend(m)}>
|
||||
{t("users.table.suspend")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item color="red" onClick={() => onRemove(m)}>
|
||||
Remove from org
|
||||
{t("users.table.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onChangeRole, onSuspend, onRemove],
|
||||
[t, onChangeRole, onSuspend, onRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip } from "@shared/components";
|
||||
import type { Role } from "@portal/api/users";
|
||||
import "@portal/views/Users.css";
|
||||
@@ -8,14 +9,14 @@ interface RolesGridProps {
|
||||
|
||||
/** Reference catalogue of the org roles and what each one can do. */
|
||||
export function RolesGrid({ roles }: RolesGridProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="portal-users__roles">
|
||||
<header className="portal-users__section-head">
|
||||
<h2 className="portal-users__section-title">Roles</h2>
|
||||
<p className="portal-users__section-sub">
|
||||
Every role exists on every plan — what each one can do is fixed across
|
||||
the org.
|
||||
</p>
|
||||
<h2 className="portal-users__section-title">
|
||||
{t("users.roles.title")}
|
||||
</h2>
|
||||
<p className="portal-users__section-sub">{t("users.roles.subtitle")}</p>
|
||||
</header>
|
||||
<div className="portal-users__roles-grid">
|
||||
{roles.map((role) => (
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { UsersResponse } from "@portal/api/users";
|
||||
import { seatsLabel } from "@portal/components/users/format";
|
||||
|
||||
/**
|
||||
* KPI labels are product copy — they describe what each metric IS, not its
|
||||
* value. They stay client-side so the strip's structure is stable across
|
||||
* loading / empty / ready states; only the values flow from the API.
|
||||
* Stable identifiers for the KPI tiles. They keep the strip's structure
|
||||
* constant across loading / empty / ready states; only the values flow from
|
||||
* the API and the displayed labels come from the locale.
|
||||
*/
|
||||
const KPI_LABELS = ["Members", "Pending invites", "Seats used"] as const;
|
||||
const KPI_KEYS = ["members", "pendingInvites", "seatsUsed"] as const;
|
||||
|
||||
interface UsersSummaryStripProps {
|
||||
data: UsersResponse | null;
|
||||
@@ -15,20 +16,23 @@ interface UsersSummaryStripProps {
|
||||
}
|
||||
|
||||
export function UsersSummaryStrip({ data, loading }: UsersSummaryStripProps) {
|
||||
const { t } = useTranslation();
|
||||
const summary = loading ? undefined : data?.summary;
|
||||
|
||||
const values: Record<(typeof KPI_LABELS)[number], string | number> = {
|
||||
Members: summary?.totalMembers ?? "—",
|
||||
"Pending invites": summary?.pendingInvites ?? "—",
|
||||
"Seats used": summary
|
||||
? seatsLabel(summary.seatsUsed, summary.seatLimit)
|
||||
: "—",
|
||||
const values: Record<(typeof KPI_KEYS)[number], string | number> = {
|
||||
members: summary?.totalMembers ?? "—",
|
||||
pendingInvites: summary?.pendingInvites ?? "—",
|
||||
seatsUsed: summary ? seatsLabel(summary.seatsUsed, summary.seatLimit) : "—",
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABELS.map((label) => (
|
||||
<MetricCard key={label} label={label} value={values[label]} />
|
||||
{KPI_KEYS.map((key) => (
|
||||
<MetricCard
|
||||
key={key}
|
||||
label={t(`users.summary.${key}`)}
|
||||
value={values[key]}
|
||||
/>
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Portal i18n setup. Shares the editor's system via @shared/i18n: the same
|
||||
* TOML backend, the same language list, and the same Crowdin-managed
|
||||
* `public/locales/{lng}/translation.toml` layout. US English is the source.
|
||||
*
|
||||
* Imported once for its side effect (see portal/main.tsx) before the app
|
||||
* renders. The portal is a separate bundle, so it configures its own i18next
|
||||
* default instance.
|
||||
*/
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import TomlBackend from "@shared/i18n/tomlBackend";
|
||||
import { supportedLanguages, rtlLanguages } from "@shared/i18n/languages";
|
||||
|
||||
void i18n
|
||||
.use(TomlBackend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en-US",
|
||||
supportedLngs: Object.keys(supportedLanguages),
|
||||
load: "currentOnly",
|
||||
nonExplicitSupportedLngs: false,
|
||||
interpolation: {
|
||||
// React already escapes values, so i18next must not double-escape.
|
||||
escapeValue: false,
|
||||
},
|
||||
backend: {
|
||||
loadPath: (lngs: string[], namespaces: string[]) => {
|
||||
const basePath = import.meta.env.BASE_URL || "/";
|
||||
const cleanBasePath = basePath.endsWith("/")
|
||||
? basePath.slice(0, -1)
|
||||
: basePath;
|
||||
return `${cleanBasePath}/locales/${lngs[0]}/${namespaces[0]}.toml`;
|
||||
},
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "navigator", "htmlTag"],
|
||||
caches: ["localStorage"],
|
||||
convertDetectedLanguage: (lng: string) => (lng === "en" ? "en-US" : lng),
|
||||
},
|
||||
react: {
|
||||
useSuspense: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Mirror the document direction/lang to the active language.
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
document.documentElement.dir = rtlLanguages.includes(lng) ? "rtl" : "ltr";
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, EmptyState, Skeleton } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -10,6 +11,7 @@ import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialo
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
export function AgentBuilder() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<AgentsResponse>(() => fetchAgents(tier), [tier]);
|
||||
const { data, loading } = state;
|
||||
@@ -30,18 +32,14 @@ export function AgentBuilder() {
|
||||
<div className="portal-agents">
|
||||
<header className="portal-agents__head">
|
||||
<div>
|
||||
<h1 className="portal-agents__title">Agent Builder</h1>
|
||||
<p className="portal-agents__sub">
|
||||
Design, test and ship the AI agents that classify, extract from and
|
||||
route your documents. Define scenarios, fence tool access, run a
|
||||
golden set, and publish a version.
|
||||
</p>
|
||||
<h1 className="portal-agents__title">{t("agentBuilder.title")}</h1>
|
||||
<p className="portal-agents__sub">{t("agentBuilder.subtitle")}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setBootstrapOpen(true)}
|
||||
leadingIcon={<span aria-hidden>⇪</span>}
|
||||
>
|
||||
Bootstrap from document
|
||||
{t("agentBuilder.bootstrapFromDocument")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
@@ -62,11 +60,11 @@ export function AgentBuilder() {
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title="No agents yet"
|
||||
description="Bootstrap an agent from a sample document to seed its scenarios and extraction schema, then refine and publish."
|
||||
title={t("agentBuilder.empty.title")}
|
||||
description={t("agentBuilder.empty.description")}
|
||||
actions={
|
||||
<Button onClick={() => setBootstrapOpen(true)}>
|
||||
Bootstrap from document
|
||||
{t("agentBuilder.bootstrapFromDocument")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, EmptyState, Skeleton } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -14,6 +15,7 @@ import { ComponentDetailModal } from "@portal/components/catalogue/ComponentDeta
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
export function Components() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<ComponentsResponse>(
|
||||
() => fetchComponents(tier),
|
||||
@@ -36,11 +38,11 @@ export function Components() {
|
||||
<div className="portal-components">
|
||||
<header className="portal-components__head">
|
||||
<div>
|
||||
<h1 className="portal-components__title">Components</h1>
|
||||
<h1 className="portal-components__title">
|
||||
{t("componentsView.title")}
|
||||
</h1>
|
||||
<p className="portal-components__sub">
|
||||
Embeddable SDK widgets you drop into your own app — a viewer, an
|
||||
e-sign flow, an AI review panel. Each is metered per action. Click a
|
||||
card for install, usage and props.
|
||||
{t("componentsView.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -50,8 +52,8 @@ export function Components() {
|
||||
{tier === "free" && hasLocked && (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="Some components need a paid plan"
|
||||
description="GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title={t("componentsView.lockedBanner.title")}
|
||||
description={t("componentsView.lockedBanner.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -65,8 +67,8 @@ export function Components() {
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title="No components available"
|
||||
description="The component catalogue could not be loaded. Try again shortly."
|
||||
title={t("componentsView.empty.title")}
|
||||
description={t("componentsView.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
@@ -59,6 +60,7 @@ function DocsContentPane({
|
||||
}
|
||||
|
||||
export function DeveloperDocs() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [active, setActive] = useState("quickstart");
|
||||
|
||||
@@ -78,8 +80,8 @@ export function DeveloperDocs() {
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="Docs unavailable"
|
||||
description="The documentation index could not be loaded."
|
||||
title={t("docs.nav.empty.title")}
|
||||
description={t("docs.nav.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{nav && nav.length > 0 && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user