Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08643822c8 | ||
|
|
68ec176719 | ||
|
|
d17c3f4fec | ||
|
|
783a51950f | ||
|
|
98d963699b |
+7
@@ -1 +1,8 @@
|
||||
declare module "*.css" {}
|
||||
|
||||
// Vite `?raw` suffix imports a file's contents as a string (used in preview.tsx
|
||||
// to load the English translation TOML synchronously).
|
||||
declare module "*?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
@@ -21,19 +21,33 @@ import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
// Load the real English copy so stories render human text, not raw keys. Bundled
|
||||
// synchronously via ?raw so it's present on the very first render (no async flash).
|
||||
// eslint-disable-next-line no-restricted-imports -- Storybook-only: read the public i18n asset; no @-alias covers editor/public/.
|
||||
import enTranslationToml from "../editor/public/locales/en-US/translation.toml?raw";
|
||||
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/tokens/base.css";
|
||||
|
||||
// Storybook-only: init react-i18next so t(key, fallback, vars) interpolates its
|
||||
// English fallback (there's no backend here to load locale files). Without this,
|
||||
// the default t() returns raw templates like "{{count}} people · led by {{owner}}".
|
||||
// Storybook-only: init react-i18next with the real English resources parsed from
|
||||
// the app's TOML, so t(key) renders the shipped copy (e.g. "No sources connected
|
||||
// yet") rather than the raw key. Falls back to an empty bundle if parsing ever
|
||||
// fails, so a malformed TOML can't take the whole Storybook down.
|
||||
function parseEnTranslation(): Record<string, unknown> {
|
||||
try {
|
||||
return parseToml(enTranslationToml) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
void i18next.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
resources: { en: { translation: {} } },
|
||||
resources: { en: { translation: parseEnTranslation() } },
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6783,6 +6783,8 @@ type = "Type"
|
||||
user = "User"
|
||||
|
||||
[portal.documents.queue.empty]
|
||||
connectSource = "Connect a source"
|
||||
createPipeline = "Create a pipeline"
|
||||
description = "As sources feed documents into your pipelines they'll appear here for review."
|
||||
title = "No documents in the queue"
|
||||
|
||||
@@ -6958,6 +6960,12 @@ sectionsAriaLabel = "Infrastructure sections"
|
||||
subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
|
||||
title = "Infrastructure"
|
||||
|
||||
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
|
||||
[portal.infrastructure.apiKeyPermission]
|
||||
admin = "Admin"
|
||||
read = "Read"
|
||||
write = "Write"
|
||||
|
||||
[portal.infrastructure.apiKeys]
|
||||
createKey = "Create key"
|
||||
heading = "API keys"
|
||||
@@ -6978,6 +6986,11 @@ usageToday = "Usage today"
|
||||
description = "Create a scoped key to start calling the Stirling API."
|
||||
title = "No API keys yet"
|
||||
|
||||
[portal.infrastructure.attestationLabel]
|
||||
attested = "Attested"
|
||||
inScope = "In scope"
|
||||
notApplicable = "N/A"
|
||||
|
||||
[portal.infrastructure.audit]
|
||||
filterAriaLabel = "Filter audit events by category"
|
||||
heading = "Audit logs"
|
||||
@@ -7034,12 +7047,28 @@ elevation = "Elevation"
|
||||
processing = "Processing"
|
||||
totalEvents = "Total events · 24h"
|
||||
|
||||
[portal.infrastructure.auditCatLabel]
|
||||
auth = "Auth"
|
||||
config = "Config"
|
||||
elevation = "Elevation"
|
||||
processing = "Processing"
|
||||
security = "Security"
|
||||
|
||||
[portal.infrastructure.auditStatusLabel]
|
||||
danger = "Error"
|
||||
info = "Info"
|
||||
success = "Success"
|
||||
warning = "Warning"
|
||||
|
||||
[portal.infrastructure.certLabel]
|
||||
certified = "Certified"
|
||||
inProgress = "In progress"
|
||||
notStarted = "Not started"
|
||||
|
||||
[portal.infrastructure.createKey]
|
||||
cancel = "Cancel"
|
||||
createKey = "Create key"
|
||||
done = "Done"
|
||||
ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
|
||||
ipAllowlistLabel = "IP allowlist"
|
||||
keyNameLabel = "Key name"
|
||||
keyNamePlaceholder = "e.g. Production · ingest"
|
||||
permissionsLabel = "Permissions"
|
||||
@@ -7050,6 +7079,12 @@ subtitleCreated = "Copy this secret now — it won't be shown again."
|
||||
title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
|
||||
[portal.infrastructure.deployLabel]
|
||||
live = "Live"
|
||||
queued = "Queued"
|
||||
rolledBack = "Rolled back"
|
||||
rolling = "Rolling out"
|
||||
|
||||
[portal.infrastructure.deployments]
|
||||
msValue = "{{value}} ms"
|
||||
throughputValue = "{{value}}/min"
|
||||
@@ -7085,6 +7120,16 @@ subheading = "Live health for every deployed Stirling region — latency, load,
|
||||
description = "Deployed regions appear here once your workspace is provisioned."
|
||||
title = "No regions deployed"
|
||||
|
||||
[portal.infrastructure.keyLabel]
|
||||
active = "Active"
|
||||
revoked = "Revoked"
|
||||
rotateSoon = "Rotate soon"
|
||||
|
||||
[portal.infrastructure.modelLabel]
|
||||
active = "Active"
|
||||
degraded = "Degraded"
|
||||
disabled = "Disabled"
|
||||
|
||||
[portal.infrastructure.models]
|
||||
heading = "Models"
|
||||
msValue = "{{value}} ms"
|
||||
@@ -7112,6 +7157,10 @@ status = "Status"
|
||||
type = "Type"
|
||||
version = "Version"
|
||||
|
||||
[portal.infrastructure.models.cost]
|
||||
perCall = "{{price}}/call"
|
||||
perThousand = "{{price}}/1k"
|
||||
|
||||
[portal.infrastructure.models.metrics]
|
||||
activeModels = "Active models"
|
||||
avgLatency = "Avg latency"
|
||||
@@ -7135,6 +7184,17 @@ modelForAria = "Model for {{operation}}"
|
||||
operation = "Operation"
|
||||
routedTo = "Routed to"
|
||||
|
||||
[portal.infrastructure.modelTypeLabel]
|
||||
classification = "Classification"
|
||||
extraction = "Extraction"
|
||||
llm = "LLM"
|
||||
ocr = "OCR"
|
||||
|
||||
[portal.infrastructure.regionLabel]
|
||||
degraded = "Degraded"
|
||||
down = "Down"
|
||||
healthy = "Healthy"
|
||||
|
||||
[portal.infrastructure.security.access.byok]
|
||||
description = "Supply a key from your own KMS. Stirling encrypts with it but can still read."
|
||||
label = "Bring your own key (BYOK)"
|
||||
@@ -7364,6 +7424,7 @@ run = "Run now"
|
||||
|
||||
[portal.pipelines.empty]
|
||||
action = "Create a pipeline"
|
||||
connectSource = "Connect a source"
|
||||
description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
|
||||
title = "No pipelines yet"
|
||||
|
||||
@@ -7809,6 +7870,56 @@ blurb = "Evaluate Stirling against your documents and workflows."
|
||||
gatingAction = "Build your quote"
|
||||
label = "Trial"
|
||||
|
||||
[portal.procurement.keyDocs]
|
||||
oneTimeFee = " · one-time {{amount}}"
|
||||
subtitle = "Everything for each stage of your rollout, in one place."
|
||||
title = "Key documents"
|
||||
|
||||
[portal.procurement.keyDocs.docs.baa]
|
||||
name = "Business Associate Agreement"
|
||||
sub = "HIPAA · available on request"
|
||||
|
||||
[portal.procurement.keyDocs.docs.bankTransfer]
|
||||
name = "Bank transfer instructions"
|
||||
sub = "Wire details for your AP team"
|
||||
|
||||
[portal.procurement.keyDocs.docs.coi]
|
||||
name = "Certificate of Insurance"
|
||||
sub = "Cyber + E&O · current policy"
|
||||
|
||||
[portal.procurement.keyDocs.docs.formalQuote]
|
||||
name = "Formal quote"
|
||||
sub = "Built to your volume, term, and service level"
|
||||
|
||||
[portal.procurement.keyDocs.docs.msa]
|
||||
name = "Master Services Agreement"
|
||||
sub = "One signature - MSA, order form, EULA, and DPA combined"
|
||||
|
||||
[portal.procurement.keyDocs.docs.purchaseOrder]
|
||||
name = "Purchase order"
|
||||
sub = "Issuing a PO? Upload it and we invoice against it"
|
||||
|
||||
[portal.procurement.keyDocs.docs.securityReview]
|
||||
name = "Custom security review"
|
||||
sub = "We complete your questionnaire and join your review call"
|
||||
|
||||
[portal.procurement.keyDocs.docs.soc2]
|
||||
name = "SOC 2 Type II report"
|
||||
sub = "Audited · NDA-gated"
|
||||
|
||||
[portal.procurement.keyDocs.docs.w9]
|
||||
name = "IRS Form W-9"
|
||||
sub = "Stirling PDF Inc."
|
||||
|
||||
[portal.procurement.keyDocs.groups]
|
||||
evaluation = "Supporting your evaluation"
|
||||
yourDeal = "Your deal"
|
||||
|
||||
[portal.procurement.keyDocs.status]
|
||||
action = "Action needed"
|
||||
available = "Download"
|
||||
request = "Request"
|
||||
|
||||
[portal.procurement.license]
|
||||
copied = "Copied"
|
||||
copy = "Copy key"
|
||||
@@ -8114,6 +8225,10 @@ summary = "Owns a team — manages its members' resources and shared configs."
|
||||
2 = "Portal access via the default policy"
|
||||
3 = "Everything Member can do"
|
||||
|
||||
[portal.users.seats]
|
||||
limited = "{{used}} / {{limit}}"
|
||||
unlimited = "{{used}} · Unlimited"
|
||||
|
||||
[portal.welcome]
|
||||
ariaLabel = "Welcome to Stirling PDF"
|
||||
badge = "Open-source"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -111,10 +111,10 @@ export function RecentActivity() {
|
||||
<div className="portal-activity__subject">{event.target}</div>
|
||||
<div className="portal-activity__detail-row">
|
||||
<span className="portal-activity__detail">
|
||||
{AUDIT_CAT_LABEL[event.category]} · {event.actor}
|
||||
{t(AUDIT_CAT_LABEL[event.category])} · {event.actor}
|
||||
</span>
|
||||
<StatusBadge tone={AUDIT_TONE[event.status]} size="sm">
|
||||
{AUDIT_STATUS_LABEL[event.status]}
|
||||
{t(AUDIT_STATUS_LABEL[event.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,9 @@ export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
|
||||
<StatTile
|
||||
label={t("portal.documents.overview.action")}
|
||||
value={
|
||||
doc.product === "Editor" || !doc.action ? "Editor" : doc.action
|
||||
doc.product === "Editor" || !doc.action
|
||||
? t("portal.documents.table.editorAction")
|
||||
: doc.action
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
render as baseRender,
|
||||
screen,
|
||||
type RenderResult,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ReviewDocument } from "@portal/api/documents";
|
||||
import { ReviewQueue } from "@portal/components/documents/ReviewQueue";
|
||||
|
||||
// Deterministic i18n: keys returned verbatim.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Isolate ReviewQueue's own branching: stub the heavy children so the test
|
||||
// doesn't need TierProvider (DocumentDrawer → useTier) or the real table body.
|
||||
vi.mock("@portal/components/documents/DocumentDrawer", () => ({
|
||||
DocumentDrawer: () => null,
|
||||
}));
|
||||
vi.mock("@portal/components/documents/ReviewQueueTable", () => ({
|
||||
ReviewQueueTable: () => null,
|
||||
}));
|
||||
|
||||
const render = (ui: ReactElement): RenderResult =>
|
||||
baseRender(
|
||||
<MemoryRouter>
|
||||
<MantineProvider>{ui}</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const DOC: ReviewDocument = {
|
||||
id: "doc-1",
|
||||
name: "Invoice.pdf",
|
||||
type: "PDF",
|
||||
classification: "Invoice",
|
||||
auto: true,
|
||||
note: null,
|
||||
product: "Editor",
|
||||
action: null,
|
||||
user: "you@acme.com",
|
||||
status: "processed",
|
||||
reviewer: null,
|
||||
source: "Claims intake",
|
||||
confidence: 0.98,
|
||||
fieldsExtracted: 5,
|
||||
time: "2 min ago",
|
||||
sensitive: false,
|
||||
extractions: [],
|
||||
audit: [],
|
||||
};
|
||||
|
||||
describe("ReviewQueue", () => {
|
||||
it("hides the filter toolbar and shows CTAs when there are no documents", () => {
|
||||
render(<ReviewQueue documents={[]} loading={false} />);
|
||||
|
||||
// Empty-state panel with both CTAs.
|
||||
expect(
|
||||
screen.getByText("portal.documents.queue.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.documents.queue.empty.createPipeline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.documents.queue.empty.connectSource"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// The filter pills (counters over the list) are gone in the empty state.
|
||||
expect(
|
||||
screen.queryByText("portal.documents.filters.all"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the filter toolbar when documents exist", () => {
|
||||
render(<ReviewQueue documents={[DOC]} loading={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("portal.documents.filters.all"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("portal.documents.queue.empty.title"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState, Input, Skeleton, Tabs, type TabItem } from "@app/ui";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
type TabItem,
|
||||
} from "@app/ui";
|
||||
import type { DocumentStatus, ReviewDocument } from "@portal/api/documents";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { DocumentsIcon } from "@portal/components/icons";
|
||||
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
|
||||
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
|
||||
|
||||
@@ -40,6 +51,7 @@ function SearchIcon() {
|
||||
*/
|
||||
export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<QueueFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -94,24 +106,28 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
|
||||
|
||||
return (
|
||||
<div className="portal-documents__queue">
|
||||
<div className="portal-documents__toolbar">
|
||||
<Tabs<QueueFilter>
|
||||
items={filterItems}
|
||||
activeKey={filter}
|
||||
onChange={setFilter}
|
||||
variant="pill"
|
||||
ariaLabel={t("portal.documents.filters.ariaLabel")}
|
||||
/>
|
||||
<Input
|
||||
className="portal-documents__search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("portal.documents.search")}
|
||||
aria-label={t("portal.documents.search")}
|
||||
leadingIcon={<SearchIcon />}
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
{/* The filter pills + search are counters over the list, so hide them when
|
||||
the list is empty — the empty state stands alone. */}
|
||||
{!isLoading && !isEmpty && (
|
||||
<div className="portal-documents__toolbar">
|
||||
<Tabs<QueueFilter>
|
||||
items={filterItems}
|
||||
activeKey={filter}
|
||||
onChange={setFilter}
|
||||
variant="pill"
|
||||
ariaLabel={t("portal.documents.filters.ariaLabel")}
|
||||
/>
|
||||
<Input
|
||||
className="portal-documents__search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("portal.documents.search")}
|
||||
aria-label={t("portal.documents.search")}
|
||||
leadingIcon={<SearchIcon />}
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-documents__table-skeleton" aria-hidden>
|
||||
@@ -123,8 +139,31 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
icon={<DocumentsIcon size={28} />}
|
||||
title={t("portal.documents.queue.empty.title")}
|
||||
description={t("portal.documents.queue.empty.description")}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`)
|
||||
}
|
||||
leftSection={
|
||||
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.documents.queue.empty.createPipeline")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
|
||||
}
|
||||
>
|
||||
{t("portal.documents.queue.empty.connectSource")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EditorInstance,
|
||||
} from "@portal/api/editorDeploy";
|
||||
|
||||
// Deployment target names are product/brand terms, not localised.
|
||||
const TARGET_LABEL: Record<EditorInstance["target"], string> = {
|
||||
cloud: "Cloud",
|
||||
docker: "Docker",
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
</span>
|
||||
<span className="portal-infra__key-head-right">
|
||||
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
|
||||
{KEY_LABEL[apiKey.status]}
|
||||
{t(KEY_LABEL[apiKey.status])}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className={"portal-infra__chevron" + (open ? " is-open" : "")}
|
||||
@@ -72,7 +72,10 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.permissions.map((p) => (
|
||||
<Chip key={p} size="sm">
|
||||
{p}
|
||||
{t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
</Chip>
|
||||
))}
|
||||
</dd>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AuditTab() {
|
||||
render: (e) => (
|
||||
<div className="portal-infra__event">
|
||||
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
|
||||
{AUDIT_CAT_LABEL[e.category]}
|
||||
{t(AUDIT_CAT_LABEL[e.category])}
|
||||
</StatusBadge>
|
||||
<span>{e.action}</span>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ export function AuditTab() {
|
||||
header: t("portal.infrastructure.audit.columns.status"),
|
||||
render: (e) => (
|
||||
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
|
||||
{AUDIT_STATUS_LABEL[e.status]}
|
||||
{t(AUDIT_STATUS_LABEL[e.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Deterministic i18n: keys returned verbatim, so assertions are stable.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
|
||||
|
||||
const K = "portal.infrastructure.createKey";
|
||||
|
||||
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CreateKeyModal open onClose={() => {}} {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateKeyModal", () => {
|
||||
it("gates the create button on a name and at least one permission", () => {
|
||||
renderModal({});
|
||||
const cta = screen.getByRole("button", { name: `${K}.createKey` });
|
||||
expect(cta).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
|
||||
target: { value: "Production ingest" },
|
||||
});
|
||||
expect(cta).toBeEnabled();
|
||||
});
|
||||
|
||||
it("reveals the generated secret with a store-it warning", () => {
|
||||
renderModal({});
|
||||
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
|
||||
target: { value: "Production ingest" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
|
||||
|
||||
expect(screen.getByText(`${K}.secretWarning`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("sk_live_demo_key_rotate_in_prod"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -27,13 +27,11 @@ export function CreateKeyModal({
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
|
||||
const [ips, setIps] = useState("");
|
||||
const [created, setCreated] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setPerms(["Read"]);
|
||||
setIps("");
|
||||
setCreated(false);
|
||||
}
|
||||
|
||||
@@ -50,8 +48,8 @@ export function CreateKeyModal({
|
||||
}
|
||||
|
||||
function createKey() {
|
||||
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
|
||||
// and render the one-time secret from the response instead of the fixture.
|
||||
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms } and render
|
||||
// the one-time secret from the response instead of the fixture.
|
||||
setCreated(true);
|
||||
}
|
||||
|
||||
@@ -126,24 +124,16 @@ export function CreateKeyModal({
|
||||
{PERMISSION_OPTS.map((p) => (
|
||||
<Checkbox
|
||||
key={p}
|
||||
label={p}
|
||||
label={t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
checked={perms.includes(p)}
|
||||
onChange={() => togglePerm(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
|
||||
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
|
||||
>
|
||||
<Input
|
||||
value={ips}
|
||||
onChange={(e) => setIps(e.target.value)}
|
||||
placeholder="52.14.0.0/16, 203.0.113.7/32"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
DEPLOY_LABEL,
|
||||
DEPLOY_TONE,
|
||||
pct,
|
||||
REGION_LABEL,
|
||||
REGION_TONE,
|
||||
titleCase,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
export function DeploymentsTab() {
|
||||
@@ -79,7 +79,7 @@ export function DeploymentsTab() {
|
||||
size="sm"
|
||||
pulse={r.status === "healthy"}
|
||||
>
|
||||
{titleCase(r.status)}
|
||||
{t(REGION_LABEL[r.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
@@ -164,7 +164,7 @@ export function DeploymentsTab() {
|
||||
header: t("portal.infrastructure.deployments.deployColumns.status"),
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
|
||||
{DEPLOY_LABEL[d.status]}
|
||||
{t(DEPLOY_LABEL[d.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ModelsTab() {
|
||||
header: t("portal.infrastructure.models.columns.type"),
|
||||
render: (m) => (
|
||||
<Chip accent={MODEL_TYPE_TONE[m.type]} size="sm">
|
||||
{MODEL_TYPE_LABEL[m.type]}
|
||||
{t(MODEL_TYPE_LABEL[m.type])}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
@@ -70,7 +70,7 @@ export function ModelsTab() {
|
||||
size="sm"
|
||||
pulse={m.status === "active"}
|
||||
>
|
||||
{MODEL_LABEL[m.status]}
|
||||
{t(MODEL_LABEL[m.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
@@ -101,7 +101,7 @@ export function ModelsTab() {
|
||||
align: "right",
|
||||
render: (m) => (
|
||||
<span className="portal-infra__mono">
|
||||
{modelCost(m.cost, m.costUnit)}
|
||||
{modelCost(t, m.cost, m.costUnit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -259,7 +259,7 @@ export function SecurityTab() {
|
||||
<div className="portal-infra__cert-head">
|
||||
<span className="portal-infra__cell-strong">{c.name}</span>
|
||||
<StatusBadge tone={CERT_TONE[c.status]} size="sm">
|
||||
{CERT_LABEL[c.status]}
|
||||
{t(CERT_LABEL[c.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="portal-infra__cert-detail">{c.detail}</p>
|
||||
@@ -283,7 +283,7 @@ export function SecurityTab() {
|
||||
<div className="portal-infra__cert-head">
|
||||
<span className="portal-infra__cell-strong">{a.name}</span>
|
||||
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
|
||||
{ATTESTATION_LABEL[a.status]}
|
||||
{t(ATTESTATION_LABEL[a.status])}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<Chip size="sm">{a.framework}</Chip>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ChipAccent, StatusTone } from "@app/ui";
|
||||
import type {
|
||||
ApiKeyStatus,
|
||||
@@ -30,6 +31,13 @@ export const REGION_TONE: Record<RegionStatus, StatusTone> = {
|
||||
down: "danger",
|
||||
};
|
||||
|
||||
// Label maps hold i18n keys, resolved via t(MAP[value]) at the render sites.
|
||||
export const REGION_LABEL: Record<RegionStatus, string> = {
|
||||
healthy: "portal.infrastructure.regionLabel.healthy",
|
||||
degraded: "portal.infrastructure.regionLabel.degraded",
|
||||
down: "portal.infrastructure.regionLabel.down",
|
||||
};
|
||||
|
||||
export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
|
||||
live: "success",
|
||||
rolling: "info",
|
||||
@@ -38,10 +46,10 @@ export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
|
||||
live: "Live",
|
||||
rolling: "Rolling out",
|
||||
"rolled-back": "Rolled back",
|
||||
queued: "Queued",
|
||||
live: "portal.infrastructure.deployLabel.live",
|
||||
rolling: "portal.infrastructure.deployLabel.rolling",
|
||||
"rolled-back": "portal.infrastructure.deployLabel.rolledBack",
|
||||
queued: "portal.infrastructure.deployLabel.queued",
|
||||
};
|
||||
|
||||
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
|
||||
@@ -51,9 +59,9 @@ export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
|
||||
active: "Active",
|
||||
revoked: "Revoked",
|
||||
"rotate-soon": "Rotate soon",
|
||||
active: "portal.infrastructure.keyLabel.active",
|
||||
revoked: "portal.infrastructure.keyLabel.revoked",
|
||||
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
|
||||
};
|
||||
|
||||
export const CERT_TONE: Record<CertStatus, StatusTone> = {
|
||||
@@ -63,11 +71,12 @@ export const CERT_TONE: Record<CertStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const CERT_LABEL: Record<CertStatus, string> = {
|
||||
certified: "Certified",
|
||||
"in-progress": "In progress",
|
||||
"not-started": "Not started",
|
||||
certified: "portal.infrastructure.certLabel.certified",
|
||||
"in-progress": "portal.infrastructure.certLabel.inProgress",
|
||||
"not-started": "portal.infrastructure.certLabel.notStarted",
|
||||
};
|
||||
|
||||
// Brand/acronym key-management modes are not localised.
|
||||
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
|
||||
managed: "Stirling-managed",
|
||||
byok: "BYOK",
|
||||
@@ -82,9 +91,9 @@ export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
|
||||
};
|
||||
|
||||
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
|
||||
attested: "Attested",
|
||||
"in-scope": "In scope",
|
||||
"not-applicable": "N/A",
|
||||
attested: "portal.infrastructure.attestationLabel.attested",
|
||||
"in-scope": "portal.infrastructure.attestationLabel.inScope",
|
||||
"not-applicable": "portal.infrastructure.attestationLabel.notApplicable",
|
||||
};
|
||||
|
||||
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
|
||||
@@ -102,18 +111,18 @@ export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
|
||||
|
||||
// Human labels for the status badge: danger/warning are tones, not outcomes (read "Error").
|
||||
export const AUDIT_STATUS_LABEL: Record<AuditStatus, string> = {
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
danger: "Error",
|
||||
info: "Info",
|
||||
success: "portal.infrastructure.auditStatusLabel.success",
|
||||
warning: "portal.infrastructure.auditStatusLabel.warning",
|
||||
danger: "portal.infrastructure.auditStatusLabel.danger",
|
||||
info: "portal.infrastructure.auditStatusLabel.info",
|
||||
};
|
||||
|
||||
export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
|
||||
auth: "Auth",
|
||||
config: "Config",
|
||||
elevation: "Elevation",
|
||||
processing: "Processing",
|
||||
security: "Security",
|
||||
auth: "portal.infrastructure.auditCatLabel.auth",
|
||||
config: "portal.infrastructure.auditCatLabel.config",
|
||||
elevation: "portal.infrastructure.auditCatLabel.elevation",
|
||||
processing: "portal.infrastructure.auditCatLabel.processing",
|
||||
security: "portal.infrastructure.auditCatLabel.security",
|
||||
};
|
||||
|
||||
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
|
||||
@@ -131,16 +140,16 @@ export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
|
||||
};
|
||||
|
||||
export const MODEL_LABEL: Record<ModelStatus, string> = {
|
||||
active: "Active",
|
||||
degraded: "Degraded",
|
||||
disabled: "Disabled",
|
||||
active: "portal.infrastructure.modelLabel.active",
|
||||
degraded: "portal.infrastructure.modelLabel.degraded",
|
||||
disabled: "portal.infrastructure.modelLabel.disabled",
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
|
||||
extraction: "Extraction",
|
||||
classification: "Classification",
|
||||
ocr: "OCR",
|
||||
llm: "LLM",
|
||||
extraction: "portal.infrastructure.modelTypeLabel.extraction",
|
||||
classification: "portal.infrastructure.modelTypeLabel.classification",
|
||||
ocr: "portal.infrastructure.modelTypeLabel.ocr",
|
||||
llm: "portal.infrastructure.modelTypeLabel.llm",
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
|
||||
@@ -150,6 +159,7 @@ export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
|
||||
llm: "warning",
|
||||
};
|
||||
|
||||
// Provider names are proper nouns, not localised.
|
||||
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
|
||||
stirling: "Stirling",
|
||||
openai: "OpenAI",
|
||||
@@ -158,8 +168,14 @@ export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
|
||||
};
|
||||
|
||||
/** Render a model's cost with the unit it's billed against. */
|
||||
export function modelCost(cost: number, unit: ModelCostUnit): string {
|
||||
if (cost === 0) return "Included";
|
||||
export function modelCost(
|
||||
t: TFunction,
|
||||
cost: number,
|
||||
unit: ModelCostUnit,
|
||||
): string {
|
||||
if (cost === 0) return t("portal.infrastructure.models.metrics.included");
|
||||
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
|
||||
return unit === "per-call" ? `${price}/call` : `${price}/1k`;
|
||||
return unit === "per-call"
|
||||
? t("portal.infrastructure.models.cost.perCall", { price })
|
||||
: t("portal.infrastructure.models.cost.perThousand", { price });
|
||||
}
|
||||
|
||||
@@ -77,70 +77,76 @@ function SideModal({
|
||||
// ── Key documents ────────────────────────────────────────────────────────────
|
||||
type DocStatus = "available" | "action" | "request";
|
||||
interface DocRow {
|
||||
name: string;
|
||||
sub: string;
|
||||
nameKey: string;
|
||||
subKey: string;
|
||||
status: DocStatus;
|
||||
fee?: number;
|
||||
}
|
||||
const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
|
||||
// Static demo catalogue for the pilot; the copy lives in the locale under
|
||||
// portal.procurement.keyDocs and is resolved via t() at render time.
|
||||
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
|
||||
{
|
||||
group: "Your deal",
|
||||
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
|
||||
docs: [
|
||||
{
|
||||
name: "Formal quote",
|
||||
sub: "Built to your volume, term, and service level",
|
||||
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Master Services Agreement",
|
||||
sub: "One signature — MSA, order form, EULA, and DPA combined",
|
||||
nameKey: "portal.procurement.keyDocs.docs.msa.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.msa.sub",
|
||||
status: "action",
|
||||
},
|
||||
{
|
||||
name: "Bank transfer instructions",
|
||||
sub: "Wire details for your AP team",
|
||||
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Purchase order",
|
||||
sub: "Issuing a PO? Upload it and we invoice against it",
|
||||
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
|
||||
status: "request",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Supporting your evaluation",
|
||||
groupKey: "portal.procurement.keyDocs.groups.evaluation",
|
||||
docs: [
|
||||
{
|
||||
name: "SOC 2 Type II report",
|
||||
sub: "Audited · NDA-gated",
|
||||
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
name: "Custom security review",
|
||||
sub: "We complete your questionnaire and join your review call",
|
||||
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
|
||||
status: "request",
|
||||
fee: 5000,
|
||||
},
|
||||
{
|
||||
name: "Business Associate Agreement",
|
||||
sub: "HIPAA · available on request",
|
||||
nameKey: "portal.procurement.keyDocs.docs.baa.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.baa.sub",
|
||||
status: "request",
|
||||
fee: 2500,
|
||||
},
|
||||
{ name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
|
||||
{
|
||||
name: "Certificate of Insurance",
|
||||
sub: "Cyber + E&O · current policy",
|
||||
nameKey: "portal.procurement.keyDocs.docs.w9.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.w9.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.coi.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.coi.sub",
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const STATUS_LABEL: Record<DocStatus, string> = {
|
||||
available: "Download",
|
||||
action: "Action needed",
|
||||
request: "Request",
|
||||
available: "portal.procurement.keyDocs.status.available",
|
||||
action: "portal.procurement.keyDocs.status.action",
|
||||
request: "portal.procurement.keyDocs.status.request",
|
||||
};
|
||||
|
||||
export function KeyDocumentsModal({
|
||||
@@ -150,31 +156,36 @@ export function KeyDocumentsModal({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Key documents"
|
||||
subtitle="Everything for each stage of your rollout, in one place."
|
||||
title={t("portal.procurement.keyDocs.title")}
|
||||
subtitle={t("portal.procurement.keyDocs.subtitle")}
|
||||
>
|
||||
{STAGE_DOCS.map((g) => (
|
||||
<div key={g.group} className="portal-docs__group">
|
||||
<div className="portal-docs__group-title">{g.group}</div>
|
||||
<div key={g.groupKey} className="portal-docs__group">
|
||||
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
|
||||
<ul className="portal-docs__list">
|
||||
{g.docs.map((d) => (
|
||||
<li key={d.name} className="portal-docs__row">
|
||||
<li key={d.nameKey} className="portal-docs__row">
|
||||
<div className="portal-docs__row-text">
|
||||
<span className="portal-docs__row-name">{d.name}</span>
|
||||
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
|
||||
<span className="portal-docs__row-sub">
|
||||
{d.sub}
|
||||
{d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""}
|
||||
{t(d.subKey)}
|
||||
{d.fee
|
||||
? t("portal.procurement.keyDocs.oneTimeFee", {
|
||||
amount: `$${d.fee.toLocaleString()}`,
|
||||
})
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="portal-docs__row-action"
|
||||
data-status={d.status}
|
||||
>
|
||||
{STATUS_LABEL[d.status]}
|
||||
{t(STATUS_LABEL[d.status])}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
|
||||
@@ -58,6 +59,7 @@ export function ProcurementModal({
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trapRef = useFocusTrap(open);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +88,7 @@ export function ProcurementModal({
|
||||
type="button"
|
||||
className="portal-procmodal__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AvatarTone, ChipAccent } from "@app/ui";
|
||||
import type { Member, RoleId } from "@portal/api/users";
|
||||
import { ROLE_TONE } from "@portal/api/users";
|
||||
@@ -23,8 +24,14 @@ export function chipAccentForRole(role: RoleId): ChipAccent {
|
||||
}
|
||||
|
||||
/** Seats used / limit as display copy; null limit → "Unlimited". */
|
||||
export function seatsLabel(used: number, limit: number | null): string {
|
||||
return limit === null ? `${used} · Unlimited` : `${used} / ${limit}`;
|
||||
export function seatsLabel(
|
||||
t: TFunction,
|
||||
used: number,
|
||||
limit: number | null,
|
||||
): string {
|
||||
return limit === null
|
||||
? t("portal.users.seats.unlimited", { used })
|
||||
: t("portal.users.seats.limited", { used, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Documents } from "@portal/views/Documents";
|
||||
|
||||
const meta: Meta<typeof Documents> = {
|
||||
title: "Portal/Views/Documents",
|
||||
component: Documents,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Documents>;
|
||||
|
||||
/** Seeded mock data: the filter pills + a populated review queue. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A fresh workspace with nothing processed yet. The filter-pill toolbar + search
|
||||
* stay hidden; the empty state drives the user to create a pipeline (primary) or
|
||||
* connect a source (secondary) — the two things that feed the queue.
|
||||
*/
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("*/api/v1/proprietary/ui-data/documents", () =>
|
||||
HttpResponse.json({
|
||||
summary: {
|
||||
totalInQueue: 0,
|
||||
processed: 0,
|
||||
errors: 0,
|
||||
processedToday: 0,
|
||||
},
|
||||
documents: [],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
@@ -30,20 +31,23 @@ function csvCell(value: string): string {
|
||||
}
|
||||
|
||||
/** Flatten the visible columns to CSV, matching the on-screen table. */
|
||||
function toCsv(docs: ReviewDocument[]): string {
|
||||
function toCsv(docs: ReviewDocument[], t: TFunction): string {
|
||||
const header = [
|
||||
"Document",
|
||||
"Product",
|
||||
"Pipeline / Action",
|
||||
"User",
|
||||
"Status",
|
||||
"Time",
|
||||
t("portal.documents.table.columns.document"),
|
||||
t("portal.documents.table.columns.product"),
|
||||
t("portal.documents.table.columns.action"),
|
||||
t("portal.documents.table.columns.user"),
|
||||
t("portal.documents.table.columns.status"),
|
||||
t("portal.documents.table.columns.time"),
|
||||
];
|
||||
const lines = [header.map(csvCell).join(",")];
|
||||
for (const d of docs) {
|
||||
const action = d.product === "Editor" || !d.action ? "Editor" : d.action;
|
||||
const action =
|
||||
d.product === "Editor" || !d.action
|
||||
? t("portal.documents.table.editorAction")
|
||||
: d.action;
|
||||
const status =
|
||||
DOCUMENT_STATUS_LABEL[d.status] +
|
||||
t(DOCUMENT_STATUS_LABEL[d.status]) +
|
||||
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : "");
|
||||
lines.push(
|
||||
[d.name, d.product, action, d.user || "", status, d.time]
|
||||
@@ -66,7 +70,7 @@ export function Documents() {
|
||||
const documents = state.data?.documents ?? [];
|
||||
|
||||
function exportCsv() {
|
||||
const blob = new Blob([toCsv(documents)], {
|
||||
const blob = new Blob([toCsv(documents, t)], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Pipelines } from "@portal/views/Pipelines";
|
||||
|
||||
const meta: Meta<typeof Pipelines> = {
|
||||
title: "Portal/Views/Pipelines",
|
||||
component: Pipelines,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Pipelines>;
|
||||
|
||||
/** Seeded mock data: the KPI strip plus a populated pipelines table. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A fresh workspace with no pipelines. The stat boxes stay hidden and the
|
||||
* empty-state panel drives the user to create a pipeline (primary) or connect a
|
||||
* source (secondary).
|
||||
*/
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/policies/overview", () =>
|
||||
HttpResponse.json({
|
||||
kpis: [
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
pipelines: [],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -84,4 +84,32 @@ describe("Pipelines view", () => {
|
||||
fireEvent.click(await screen.findByText("Redaction sweep"));
|
||||
expect(await screen.findByText("pipeline page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the KPI stat boxes when pipelines exist", async () => {
|
||||
renderView();
|
||||
await screen.findByText("Redaction sweep");
|
||||
expect(screen.getByText("portal.pipelines.kpi.total")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the stat boxes and shows create + connect-source CTAs when empty", async () => {
|
||||
fetchPipelines.mockResolvedValue({
|
||||
kpis: [
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
pipelines: [],
|
||||
});
|
||||
renderView();
|
||||
expect(
|
||||
await screen.findByText("portal.pipelines.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.empty.connectSource"),
|
||||
).toBeInTheDocument();
|
||||
// The KPI strip is gone: no stat-box labels over an empty page.
|
||||
expect(
|
||||
screen.queryByText("portal.pipelines.kpi.total"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PipelineView,
|
||||
} from "@portal/api/pipelines";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { PipelinesIcon } from "@portal/components/icons";
|
||||
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
|
||||
import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable";
|
||||
import "@portal/views/Pipelines.css";
|
||||
@@ -18,12 +19,18 @@ export function Pipelines() {
|
||||
const navigate = useNavigate();
|
||||
const state = useAsync<PipelinesOverviewResponse>(() => fetchPipelines(), []);
|
||||
const { data, loading } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
const pipelines = data?.pipelines ?? [];
|
||||
// Empty once the fetch settles with no pipelines (or fails → no data). Gates
|
||||
// both the KPI strip and the empty panel so no placeholder stat boxes sit
|
||||
// above an empty page.
|
||||
const showEmpty = !isLoading && pipelines.length === 0;
|
||||
|
||||
const openCreate = () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`);
|
||||
const connectSource = () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`);
|
||||
// A row opens that pipeline's own page (view / edit / run / delete live there).
|
||||
const openPipeline = (pipeline: PipelineView) =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`);
|
||||
@@ -47,7 +54,7 @@ export function Pipelines() {
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<KpiStrip data={data} loading={loading} />
|
||||
{!showEmpty && <KpiStrip data={data} loading={loading} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-pipelines__table-skeleton" aria-hidden>
|
||||
@@ -57,19 +64,30 @@ export function Pipelines() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
{showEmpty && (
|
||||
<EmptyState
|
||||
icon={<PipelinesIcon size={28} />}
|
||||
title={t("portal.pipelines.empty.title")}
|
||||
description={t("portal.pipelines.empty.description")}
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
{t("portal.pipelines.empty.action")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={
|
||||
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.pipelines.empty.action")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={connectSource}>
|
||||
{t("portal.pipelines.empty.connectSource")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isEmpty && pipelines.length > 0 && (
|
||||
{!isLoading && pipelines.length > 0 && (
|
||||
<PipelinesTable pipelines={pipelines} onRowClick={openPipeline} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
|
||||
const meta: Meta<typeof Policies> = {
|
||||
title: "Portal/Views/Policies",
|
||||
component: Policies,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Policies>;
|
||||
|
||||
/** Seeded mock data: the summary strip plus the configured catalogue. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A fresh workspace with no policies configured. The summary stat boxes stay
|
||||
* hidden; the catalogue cards remain, since each one is the CTA to configure
|
||||
* that policy category.
|
||||
*/
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/policies", () => HttpResponse.json([])),
|
||||
http.get("/api/v1/policies/runs", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -36,6 +36,10 @@ export function Policies() {
|
||||
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
||||
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
|
||||
// but the summary strip is pure stat boxes: hide it until at least one policy
|
||||
// is configured so a fresh workspace doesn't show a row of zeros.
|
||||
const hasPolicies = !!data && data.summary.active + data.summary.paused > 0;
|
||||
|
||||
const displayCatalogue: CatalogueEntry[] =
|
||||
catalogue.length > 0
|
||||
@@ -123,7 +127,7 @@ export function Policies() {
|
||||
|
||||
{pageError && <Banner tone="danger" description={pageError} />}
|
||||
|
||||
<CatalogueSummary data={data} loading={loading} />
|
||||
{hasPolicies && <CatalogueSummary data={data} loading={loading} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-policies__grid" aria-hidden>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
|
||||
const meta: Meta<typeof Sources> = {
|
||||
title: "Portal/Views/Sources",
|
||||
component: Sources,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Sources>;
|
||||
|
||||
/** Seeded mock data: the KPI strip plus a populated sources table. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A fresh workspace with no sources connected. The stat boxes stay hidden and
|
||||
* the empty-state panel drives the user to connect a source.
|
||||
*/
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/sources", () =>
|
||||
HttpResponse.json({
|
||||
kpis: [
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
sources: [],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -146,4 +146,31 @@ describe("Sources view", () => {
|
||||
expect.objectContaining({ id: "src-referenced", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the KPI stat boxes when sources exist", async () => {
|
||||
fetchSources.mockResolvedValue(RESPONSE);
|
||||
renderView();
|
||||
await screen.findByText("Claims intake");
|
||||
expect(screen.getByText("portal.sources.kpi.total")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the stat boxes and shows the connect CTA when empty", async () => {
|
||||
fetchSources.mockResolvedValue({
|
||||
kpis: [
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
sources: [],
|
||||
});
|
||||
renderView();
|
||||
// The empty-state panel renders.
|
||||
expect(
|
||||
await screen.findByText("portal.sources.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
// The KPI strip is gone: no stat-box labels over an empty page.
|
||||
expect(
|
||||
screen.queryByText("portal.sources.kpi.total"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { SourcesIcon } from "@portal/components/icons";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
createSource,
|
||||
@@ -29,7 +30,7 @@ export function Sources() {
|
||||
const [version, setVersion] = useState(0);
|
||||
const state = useAsync<SourcesResponse>(() => fetchSources(), [version]);
|
||||
const { data, loading } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
||||
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -43,6 +44,10 @@ export function Sources() {
|
||||
|
||||
const sources = data?.sources ?? [];
|
||||
const expanded = sources.find((s) => s.id === expandedId) ?? null;
|
||||
// Empty once the fetch settles with no sources (or fails → no data). Gates
|
||||
// both the KPI strip and the empty panel so no placeholder stat boxes sit
|
||||
// above an empty page.
|
||||
const showEmpty = !isLoading && sources.length === 0;
|
||||
|
||||
// The 30-day sparkline series lives off the list endpoint; fetch it for the one
|
||||
// expanded row only (empty while collapsed, so no request fires).
|
||||
@@ -146,7 +151,7 @@ export function Sources() {
|
||||
|
||||
{pageError && <Banner tone="danger" description={pageError} />}
|
||||
|
||||
<KpiStrip data={data} loading={loading} />
|
||||
{!showEmpty && <KpiStrip data={data} loading={loading} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
@@ -156,19 +161,23 @@ export function Sources() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
{showEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.sources.empty.title")}
|
||||
description={t("portal.sources.empty.description")}
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<span aria-hidden>+</span>}
|
||||
>
|
||||
{t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isEmpty && sources.length > 0 && (
|
||||
{!isLoading && sources.length > 0 && (
|
||||
<SourcesTable
|
||||
sources={sources}
|
||||
expandedId={expandedId}
|
||||
|
||||
@@ -175,8 +175,6 @@ ignore = [
|
||||
'certSign.collab.participant.certInvalid',
|
||||
'certSign.collab.signRequest.steps.invisible',
|
||||
'certSign.collab.signRequest.steps.visible',
|
||||
'certSign.collab.signRequest.text.colorLabel',
|
||||
'certSign.collab.signRequest.text.fontSizePlaceholder',
|
||||
'common.error',
|
||||
'compare.pixel.base',
|
||||
'config.account.profilePicture.cropper.zoom',
|
||||
@@ -205,7 +203,6 @@ ignore = [
|
||||
'getPdfInfo.summary.tech.javaScript',
|
||||
'getPdfInfo.summary.tech.multimedia',
|
||||
'home.devApi.title',
|
||||
'landing.heroTitle',
|
||||
'language.direction',
|
||||
'mobileScanner.flash',
|
||||
'no',
|
||||
@@ -482,7 +479,6 @@ ignore = [
|
||||
'admin.settings.connections.saml2.label',
|
||||
'admin.settings.security.xFrameOptions.label',
|
||||
'certSign.collab.participant.certInvalid',
|
||||
'certSign.collab.signRequest.text.fontSizePlaceholder',
|
||||
'convert.cbrToPdf',
|
||||
'convert.cbzToPdf',
|
||||
'convert.dpi',
|
||||
|
||||
@@ -29,7 +29,7 @@ python3 scripts/translations/auto_translate.py es-ES --no-cleanup
|
||||
**What it does:**
|
||||
1. Extracts untranslated entries from the language file
|
||||
2. Splits into batches (default 500 entries each)
|
||||
3. Translates each batch using GPT-5 with specialized prompts
|
||||
3. Translates each batch using GPT-5.6 with specialized prompts
|
||||
4. Validates placeholders are preserved
|
||||
5. Merges translated batches
|
||||
6. Applies translations to language file
|
||||
@@ -39,7 +39,7 @@ python3 scripts/translations/auto_translate.py es-ES --no-cleanup
|
||||
|
||||
**Time:** ~8-10 minutes per language with 1200+ untranslated entries
|
||||
|
||||
**Cost:** ~$2-4 per language using GPT-5 (or use `gpt-5-mini` for lower cost)
|
||||
**Cost:** ~$8-15 per language using GPT-5.5 (or use `gpt-5.6-luna` for ~$2-4 if your org has 5.6 access)
|
||||
|
||||
See [`auto_translate.py`](#auto_translatepy-automated-translation-pipeline) for full details.
|
||||
|
||||
@@ -248,7 +248,7 @@ python scripts/translations/compact_translator.py it-IT --output to_translate.js
|
||||
|
||||
### 5. `auto_translate.py` - Automated Translation Pipeline
|
||||
|
||||
**NEW: Fully automated translation workflow using GPT-5.**
|
||||
**NEW: Fully automated translation workflow using GPT-5.6.**
|
||||
|
||||
Combines all translation steps into a single command that handles everything from extraction to verification.
|
||||
|
||||
@@ -276,7 +276,7 @@ python3 scripts/translations/auto_translate.py es-ES --skip-verification
|
||||
|
||||
**Features:**
|
||||
- Fully automated end-to-end translation pipeline
|
||||
- Uses GPT-5 with specialized prompts for Stirling PDF
|
||||
- Uses GPT-5.6 with specialized prompts for Stirling PDF
|
||||
- Preserves all placeholders ({n}, {{variable}}, etc.)
|
||||
- Maintains consistent terminology
|
||||
- Validates translations automatically
|
||||
@@ -286,7 +286,7 @@ python3 scripts/translations/auto_translate.py es-ES --skip-verification
|
||||
**Pipeline Steps:**
|
||||
1. **Extract**: Finds all untranslated entries
|
||||
2. **Split**: Divides into manageable batches (default: 500 entries)
|
||||
3. **Translate**: Uses GPT-5 to translate each batch with specialized prompts
|
||||
3. **Translate**: Uses GPT-5.6 to translate each batch with specialized prompts
|
||||
4. **Validate**: Ensures placeholders are preserved
|
||||
5. **Merge**: Combines all translated batches
|
||||
6. **Apply**: Updates the language file
|
||||
@@ -304,7 +304,7 @@ python3 scripts/translations/auto_translate.py es-ES --skip-verification
|
||||
**Supported Languages:**
|
||||
All language codes from `frontend/editor/public/locales/` (e.g., es-ES, de-DE, fr-FR, zh-CN, ar-AR, etc.)
|
||||
|
||||
### 6. `batch_translator.py` - GPT-5 Translation Engine
|
||||
### 6. `batch_translator.py` - GPT-5.6 Translation Engine
|
||||
|
||||
Low-level translation script used by `auto_translate.py`. Can be used standalone for manual batch translation.
|
||||
|
||||
@@ -316,25 +316,27 @@ python3 scripts/translations/batch_translator.py my_batch.json --language es-ES
|
||||
# Translate multiple batches
|
||||
python3 scripts/translations/batch_translator.py batch_*.json --language de-DE --api-key YOUR_KEY
|
||||
|
||||
# Use different GPT model
|
||||
python3 scripts/translations/batch_translator.py batch.json --language fr-FR --model gpt-5-mini
|
||||
# Use a cheaper model
|
||||
python3 scripts/translations/batch_translator.py batch.json --language fr-FR --model gpt-5.6-luna
|
||||
|
||||
# Skip validation
|
||||
python3 scripts/translations/batch_translator.py batch.json --language it-IT --skip-validation
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Translates JSON batch files using OpenAI GPT-5
|
||||
- Translates JSON batch files using OpenAI GPT-5.6
|
||||
- Specialized system prompts for Stirling PDF translations
|
||||
- Automatic placeholder validation
|
||||
- Supports pattern matching for multiple files
|
||||
- Configurable model selection (gpt-5, gpt-5-mini, gpt-5-nano)
|
||||
- Configurable model selection (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna)
|
||||
- Per-batch and per-run token + cost reporting
|
||||
- Rate limiting with configurable delays
|
||||
|
||||
**Models:**
|
||||
- `gpt-5` (default): Best quality, $1.25/1M input, $10/1M output
|
||||
- `gpt-5-mini`: Balanced quality/cost
|
||||
- `gpt-5-nano`: Fastest, most economical
|
||||
- `gpt-5.5` (default): Previous flagship, strong translation quality, $5/1M input, $30/1M output
|
||||
- `gpt-5.6-sol`: Newest flagship reasoning, $5/1M input, $30/1M output (requires org 5.6 access)
|
||||
- `gpt-5.6-terra`: Balanced quality/cost, $2.50/1M input, $15/1M output (requires org 5.6 access)
|
||||
- `gpt-5.6-luna`: Fastest/cheapest, $1/1M input, $6/1M output (requires org 5.6 access)
|
||||
|
||||
### 7. `json_beautifier.py`
|
||||
Restructures and beautifies translation JSON files to match en-US structure exactly.
|
||||
|
||||
@@ -140,12 +140,14 @@ def extract_untranslated(language_code, batch_size=500, include_existing=False):
|
||||
return batch_files
|
||||
|
||||
|
||||
def translate_batches(batch_files, language_code, api_key, timeout=600):
|
||||
"""Translate all batch files using GPT-5."""
|
||||
def translate_batches(
|
||||
batch_files, language_code, api_key, timeout=600, model="gpt-5.5"
|
||||
):
|
||||
"""Translate all batch files using the given OpenAI model."""
|
||||
if not batch_files:
|
||||
return []
|
||||
|
||||
print(f"\n🤖 Translating {len(batch_files)} batches using GPT-5...")
|
||||
print(f"\n🤖 Translating {len(batch_files)} batches using {model}...")
|
||||
print(f"Timeout: {timeout}s ({timeout // 60} minutes) per batch")
|
||||
|
||||
translated_files = []
|
||||
@@ -154,7 +156,7 @@ def translate_batches(batch_files, language_code, api_key, timeout=600):
|
||||
print(f"\n[{i}/{len(batch_files)}] Translating {batch_file}...")
|
||||
|
||||
# Always pass API key since it's required
|
||||
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}"'
|
||||
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}'
|
||||
|
||||
# Run with timeout
|
||||
result = subprocess.run(
|
||||
@@ -308,6 +310,11 @@ Examples:
|
||||
action="store_true",
|
||||
help="Also retranslate existing keys that match English (default: only translate missing keys)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-5.5",
|
||||
help="OpenAI model (default: gpt-5.5; gpt-5.6-sol/terra/luna if your org has 5.6 access)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -322,6 +329,7 @@ Examples:
|
||||
print("=" * 60)
|
||||
print("Automated Translation Pipeline")
|
||||
print(f"Language: {args.language}")
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Batch Size: {args.batch_size} entries")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -341,7 +349,7 @@ Examples:
|
||||
|
||||
# Step 2: Translate all batches
|
||||
translated_files = translate_batches(
|
||||
batch_files, args.language, api_key, args.timeout
|
||||
batch_files, args.language, api_key, args.timeout, args.model
|
||||
)
|
||||
if translated_files is None:
|
||||
sys.exit(1)
|
||||
|
||||
@@ -22,11 +22,32 @@ except ImportError:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# USD per 1M tokens (input, output)
|
||||
MODEL_PRICING = {
|
||||
"gpt-5.5": (5.0, 30.0),
|
||||
"gpt-5.6-sol": (5.0, 30.0),
|
||||
"gpt-5.6-terra": (2.5, 15.0),
|
||||
"gpt-5.6-luna": (1.0, 6.0),
|
||||
"gpt-5": (1.25, 10.0),
|
||||
}
|
||||
|
||||
|
||||
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||
"""Estimate USD cost for a call; 0.0 if model pricing is unknown."""
|
||||
if model not in MODEL_PRICING:
|
||||
return 0.0
|
||||
in_price, out_price = MODEL_PRICING[model]
|
||||
return (prompt_tokens * in_price + completion_tokens * out_price) / 1_000_000
|
||||
|
||||
|
||||
class BatchTranslator:
|
||||
def __init__(self, api_key: str, model: str = "gpt-5"):
|
||||
def __init__(self, api_key: str, model: str = "gpt-5.5"):
|
||||
"""Initialize translator with OpenAI API key."""
|
||||
self.client = OpenAI(api_key=api_key)
|
||||
self.model = model
|
||||
self.total_prompt_tokens = 0
|
||||
self.total_completion_tokens = 0
|
||||
self.total_cost = 0.0
|
||||
|
||||
def get_translation_prompt(self, language_name: str, language_code: str) -> str:
|
||||
"""Generate the system prompt for translation."""
|
||||
@@ -79,6 +100,23 @@ CRITICAL RULES - MUST FOLLOW EXACTLY:
|
||||
|
||||
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object."""
|
||||
|
||||
def _record_usage(self, response) -> None:
|
||||
"""Accumulate token usage/cost and print a per-batch line."""
|
||||
usage = getattr(response, "usage", None)
|
||||
if usage is None:
|
||||
return
|
||||
|
||||
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
||||
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
||||
cost = estimate_cost(self.model, prompt_tokens, completion_tokens)
|
||||
|
||||
self.total_prompt_tokens += prompt_tokens
|
||||
self.total_completion_tokens += completion_tokens
|
||||
self.total_cost += cost
|
||||
|
||||
cost_note = f", ~${cost:.4f}" if cost else ""
|
||||
print(f" Tokens: {prompt_tokens:,} in / {completion_tokens:,} out{cost_note}")
|
||||
|
||||
def translate_batch(
|
||||
self, batch_data: dict, target_language: str, language_code: str
|
||||
) -> dict:
|
||||
@@ -90,7 +128,7 @@ Return ONLY the translated JSON. No markdown, no explanations, just the JSON obj
|
||||
print(f"Input size: {len(input_json)} characters")
|
||||
|
||||
try:
|
||||
# GPT-5 only supports temperature=1, so we don't include it
|
||||
# GPT-5.x models only support the default temperature, so we omit it
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
@@ -107,6 +145,8 @@ Return ONLY the translated JSON. No markdown, no explanations, just the JSON obj
|
||||
],
|
||||
)
|
||||
|
||||
self._record_usage(response)
|
||||
|
||||
translated_text = response.choices[0].message.content.strip()
|
||||
|
||||
# Remove markdown code blocks if present
|
||||
@@ -250,8 +290,8 @@ Examples:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-5",
|
||||
help="OpenAI model to use (default: gpt-5, options: gpt-5-mini, gpt-5-nano)",
|
||||
default="gpt-5.5",
|
||||
help="OpenAI model (default: gpt-5.5; gpt-5.6-sol/terra/luna if your org has 5.6 access)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-suffix",
|
||||
@@ -354,6 +394,15 @@ Examples:
|
||||
if failed > 0:
|
||||
print(f"Failed: {failed}/{len(input_files)}")
|
||||
|
||||
# Cost summary
|
||||
print("-" * 60)
|
||||
print(
|
||||
f"Total tokens: {translator.total_prompt_tokens:,} in / "
|
||||
f"{translator.total_completion_tokens:,} out"
|
||||
)
|
||||
if translator.total_cost:
|
||||
print(f"Estimated cost ({args.model}): ${translator.total_cost:.4f}")
|
||||
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ def translate_language(
|
||||
timeout: int,
|
||||
skip_verification: bool,
|
||||
include_existing: bool,
|
||||
model: str,
|
||||
) -> Tuple[str, bool, str]:
|
||||
"""
|
||||
Translate a single language.
|
||||
@@ -115,6 +116,8 @@ def translate_language(
|
||||
str(batch_size),
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
"--model",
|
||||
model,
|
||||
]
|
||||
|
||||
if skip_verification:
|
||||
@@ -139,11 +142,11 @@ def translate_language(
|
||||
safe_print(f"[{language}] ✓ Success")
|
||||
return (language, True, "Success")
|
||||
else:
|
||||
error_msg = (
|
||||
result.stderr.strip() or result.stdout.strip() or "Unknown error"
|
||||
)
|
||||
safe_print(f"[{language}] ✗ Failed: {error_msg[:100]}")
|
||||
return (language, False, error_msg[:200]) # Truncate long errors
|
||||
# Show the actual error (last line of output), not the header
|
||||
output = result.stderr.strip() or result.stdout.strip() or "Unknown error"
|
||||
error_msg = output.splitlines()[-1][:200]
|
||||
safe_print(f"[{language}] ✗ Failed: {error_msg}")
|
||||
return (language, False, error_msg)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
safe_print(f"[{language}] ✗ Timeout exceeded")
|
||||
@@ -178,6 +181,11 @@ Note: Requires OPENAI_API_KEY environment variable or --api-key argument.
|
||||
parser.add_argument(
|
||||
"--api-key", help="OpenAI API key (or set OPENAI_API_KEY env var)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-5.5",
|
||||
help="OpenAI model (default: gpt-5.5; gpt-5.6-sol/terra/luna if your org has 5.6 access)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
type=int,
|
||||
@@ -277,6 +285,7 @@ Note: Requires OPENAI_API_KEY environment variable or --api-key argument.
|
||||
print("Bulk Translation Configuration")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Languages to translate: {len(languages)}")
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Parallel threads: {args.parallel}")
|
||||
print(f"Batch size: {args.batch_size}")
|
||||
print(f"Timeout per batch: {args.timeout}s")
|
||||
@@ -308,6 +317,7 @@ Note: Requires OPENAI_API_KEY environment variable or --api-key argument.
|
||||
args.timeout,
|
||||
args.skip_verification,
|
||||
args.include_existing,
|
||||
args.model,
|
||||
): lang
|
||||
for lang in languages
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user