fix(operation-trace): speed up audit reads and align i18n locales

Performance of the operation traceability surface:

- Cache the retention / row-budget / dedupe policy per Workspace and
  invalidate it on governance change, so the hot write path no longer
  issues three metadata round trips per recorded operation.
- Resolve the governance payload with one metadata query instead of four.
- Turn the dedupe probe into an existence check (LIMIT 1) instead of a
  full COUNT over the dedupe window.
- Defer the row-budget COUNT to every N inserts; prune still enforces the
  exact budget, and the maintenance loop keeps it bounded.
- Panel: fetch only the records page when paging or filtering instead of
  re-requesting governance and the filter catalogue every time.
- Export: include the effective filters (and limit) in the artifact so a
  downloaded CSV is self-describing.

Fixes and cleanup:

- Drop the unused OperationLogPruneReport import that broke `tsc`.
- i18n: remove the 9 redundant blank lines unique to en-US and format the
  six non-compliant locale files with the project Prettier config; all
  eight locales keep identical key sets, key order and placeholders.
This commit is contained in:
TyperBody
2026-09-26 02:11:42 +08:00
parent 4b9a1590df
commit 78baabde96
25 changed files with 3893 additions and 13 deletions
@@ -32,6 +32,7 @@ import {
Github,
Zap,
FilePlus2,
History,
Sparkles,
Server,
Puzzle,
@@ -2220,6 +2221,19 @@ export default function HomeSidebar({
<span>{t('models.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Operation traceability is an audit surface: only roles holding
audit.view (owner / admin) get a direct sidebar entry. */}
{currentWorkspace?.permissions.includes('audit.view') && (
<SidebarMenuItem data-sidebar-guide="operation-trace">
<SidebarMenuButton
onClick={() => openSettings('operationTrace')}
tooltip={t('operationTrace.title')}
>
<History className="size-4 text-blue-500" />
<span>{t('operationTrace.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
{/* API-key management is available only to authorized Workspace roles. */}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
HardDrive,
History,
KeyRound,
Settings,
Sparkles,
@@ -29,12 +30,14 @@ import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/Ap
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
import OperationTracePanel from '@/app/home/components/workspace-settings/OperationTracePanel';
import { useCurrentWorkspace } from '@/app/infra/http';
// The set of settings sections shown in the unified dialog. The string values
// are also reused as the ?action= query param suffix so deep links keep working.
export type SettingsSection =
| 'workspace'
| 'operationTrace'
| 'account'
| 'apiIntegration'
| 'models'
@@ -45,6 +48,7 @@ export type SettingsSection =
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
workspace: 'showWorkspaceSettings',
operationTrace: 'showOperationTrace',
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
@@ -101,6 +105,13 @@ export default function SettingsDialog({
description: t('workspace.description'),
icon: <UsersRound className="size-4" />,
},
{
id: 'operationTrace',
label: t('settingsDialog.nav.operationTrace'),
title: t('operationTrace.title'),
description: t('operationTrace.description'),
icon: <History className="size-4" />,
},
{
id: 'models',
label: t('settingsDialog.nav.models'),
@@ -135,6 +146,9 @@ export default function SettingsDialog({
const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
// Operation traceability is an audit surface: it is limited to the roles
// that also hold the audit permission (owner / admin).
const canViewOperationTrace = canViewAudit;
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
@@ -142,19 +156,24 @@ export default function SettingsDialog({
if (item.id === 'storageAnalysis') {
return canViewStorageAnalysis;
}
if (item.id === 'operationTrace') {
return canViewOperationTrace;
}
return true;
});
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
(section === 'storageAnalysis' && !canViewStorageAnalysis) ||
(section === 'operationTrace' && !canViewOperationTrace);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [
canManageApiKeys,
canViewStorageAnalysis,
canViewOperationTrace,
open,
section,
onSectionChange,
@@ -253,6 +272,11 @@ export default function SettingsDialog({
active={open && section === 'workspace'}
/>
)}
{section === 'operationTrace' && canViewOperationTrace && (
<OperationTracePanel
active={open && section === 'operationTrace'}
/>
)}
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
@@ -0,0 +1,692 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ChevronLeft,
ChevronRight,
Fingerprint,
History,
Loader2,
RefreshCw,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@/components/ui/item';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
OperationChangeField,
OperationGovernance,
OperationLevel,
OperationLogFilters,
OperationLogPage,
OperationLogQuery,
OperationLogRecord,
} from '@/app/infra/entities/operation-log';
import { Download } from 'lucide-react';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
interface OperationTracePanelProps {
active: boolean;
}
const ALL_VALUE = '__all__';
const PAGE_SIZE = 20;
/**
* Machine sentinel the backend stores in place of a masked value. It mirrors
* ``settings.REDACTED_SENTINEL`` and is the only value the panel translates
* itself rather than resolving an i18n key from the API.
*/
const REDACTED_SENTINEL = '__redacted__';
/** Render one before → after pair in a compact, readable row. */
function ChangeRow({
change,
redactedLabel,
}: {
change: OperationChangeField;
redactedLabel: string;
}) {
const format = (value: unknown): string => {
if (value === null || value === undefined || value === '') return '—';
// The backend stores a machine sentinel for masked fields; the label is
// localized here so no interface text ships from the backend.
if (value === REDACTED_SENTINEL) return redactedLabel;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
return (
<div className="flex flex-wrap items-baseline gap-1.5 font-mono text-xs">
<span className="text-muted-foreground">{change.field}</span>
<span className="rounded bg-muted px-1.5 py-0.5 break-all">
{format(change.before)}
</span>
<span aria-hidden="true" className="text-muted-foreground">
→
</span>
<span className="rounded bg-primary/10 px-1.5 py-0.5 break-all">
{format(change.after)}
</span>
</div>
);
}
function outcomeVariant(
outcome: OperationLogRecord['outcome'],
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (outcome === 'error') return 'destructive';
if (outcome === 'denied') return 'outline';
return 'secondary';
}
export default function OperationTracePanel({
active,
}: OperationTracePanelProps) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const [governance, setGovernance] = useState<OperationGovernance | null>(
null,
);
const [filters, setFilters] = useState<OperationLogFilters | null>(null);
const [page, setPage] = useState<OperationLogPage | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [query, setQuery] = useState<OperationLogQuery>({});
const [retentionDays, setRetentionDays] = useState<number>(30);
const [maxRows, setMaxRows] = useState<number>(20000);
const [dedupeSeconds, setDedupeSeconds] = useState<number>(60);
const role = currentWorkspace?.membership.role ?? null;
const canConfigure = role === 'owner' || role === 'admin';
// Fetch only the records page. Paging through the log must not re-request
// the governance payload and the filter catalogue: those change rarely and
// the two extra round trips made every page click three times slower.
const loadPage = useCallback(
async (nextQuery: OperationLogQuery) => {
setLoading(true);
try {
const pageResponse = await backendClient.getOperationLogs({
limit: PAGE_SIZE,
offset: nextQuery.offset ?? 0,
...nextQuery,
});
setPage(pageResponse);
} catch {
toast.error(t('operationTrace.loadFailed'));
} finally {
setLoading(false);
}
},
[t],
);
const load = useCallback(
async (nextQuery: OperationLogQuery) => {
setLoading(true);
try {
const [governanceResponse, filtersResponse, pageResponse] =
await Promise.all([
backendClient.getOperationGovernance(),
backendClient.getOperationLogFilters(),
backendClient.getOperationLogs({
limit: PAGE_SIZE,
offset: nextQuery.offset ?? 0,
...nextQuery,
}),
]);
setGovernance(governanceResponse);
setFilters(filtersResponse);
setPage(pageResponse);
setRetentionDays(governanceResponse.retention_days);
setMaxRows(governanceResponse.max_rows);
setDedupeSeconds(governanceResponse.dedupe_window_seconds);
} catch {
toast.error(t('operationTrace.loadFailed'));
} finally {
setLoading(false);
}
},
[t],
);
// Track visibility so the first render after opening the panel performs the
// full load, while later query changes (filters, pagination) only refresh
// the records page.
const wasActiveRef = useRef(false);
useEffect(() => {
if (!active) {
wasActiveRef.current = false;
return;
}
if (!wasActiveRef.current) {
wasActiveRef.current = true;
void load(query);
return;
}
void loadPage(query);
// Reload the full payload only when the panel becomes visible; filter and
// pagination changes are handled by the records-only path above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, query]);
const offset = query.offset ?? 0;
const total = page?.total ?? 0;
const canGoBack = offset > 0;
const canGoForward = offset + PAGE_SIZE < total;
// Render the server page as-is. Record ids are unique and stable, the
// backend already collapses repeated observations at write time, and a
// client-side dedupe would hide legitimately distinct rows (making the
// total badge disagree with the list) whenever a page is re-fetched.
const visibleRecords = page?.records ?? [];
async function changeLevel(level: OperationLevel) {
if (!canConfigure) return;
setSaving(true);
try {
const updated = await backendClient.updateOperationGovernance({ level });
setGovernance(updated);
toast.success(t('operationTrace.levelUpdated'));
await load({ ...query, offset: 0 });
} catch {
toast.error(t('operationTrace.levelUpdateFailed'));
} finally {
setSaving(false);
}
}
async function downloadLogs() {
if (exporting) return;
setExporting(true);
try {
// Fetch through the HTTP client so the session token and the active
// Workspace header are applied. A bare ``window.open`` navigation would
// drop both and the audit endpoint would reject the request.
const url = backendClient.buildOperationLogExportURL({
action: query.action,
resource_type: query.resource_type,
actor: query.actor,
level: query.level,
since: query.since,
until: query.until,
});
const response = await backendClient.downloadFile(url);
const disposition = response.headers['content-disposition'] as
| string
| undefined;
const match = disposition?.match(/filename="?([^";\n]+)"?/);
const filename = match?.[1] ?? `operation-logs-${Date.now()}.csv`;
const blob = new Blob([response.data], {
type: 'text/csv;charset=utf-8;',
});
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(objectUrl);
} catch {
toast.error(t('operationTrace.exportFailed'));
} finally {
setExporting(false);
}
}
async function saveRetention() {
if (!canConfigure || !governance) return;
setSaving(true);
try {
// Saving the retention policy applies it immediately and keeps the
// maintenance loop enforcing it afterwards; there is no manual prune.
const updated = await backendClient.updateOperationGovernance({
level: governance.configured_level,
retention_days: retentionDays,
max_rows: maxRows,
dedupe_window_seconds: dedupeSeconds,
});
setGovernance(updated);
toast.success(t('operationTrace.retentionUpdated'));
await load({ ...query, offset: 0 });
} catch {
toast.error(t('operationTrace.retentionUpdateFailed'));
} finally {
setSaving(false);
}
}
const levelOptions = useMemo(
() => governance?.supported_levels ?? [],
[governance],
);
if (loading && !governance) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<>
<PanelToolbar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{t('operationTrace.title')}
</p>
</div>
{governance && (
<Badge
variant={governance.configured_level > 0 ? 'default' : 'outline'}
>
{t(`operationTrace.levelNames.${governance.configured_level_name}`)}
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => void load(query)}
disabled={loading}
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
{t('operationTrace.refresh')}
</Button>
</PanelToolbar>
<PanelBody className="space-y-6">
<section className="space-y-3">
<div className="flex items-center gap-2">
<SlidersHorizontal className="size-4" />
<h3 className="text-sm font-semibold">
{t('operationTrace.captureLevel')}
</h3>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{levelOptions.map((option) => {
const selected = governance?.configured_level === option.level;
return (
<button
key={option.level}
type="button"
disabled={!canConfigure || saving}
onClick={() => void changeLevel(option.level)}
className={`rounded-lg border p-3 text-left transition-colors disabled:opacity-60 ${
selected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted/50'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t(`${option.i18n_key}.label`)}
</span>
<Badge variant={selected ? 'default' : 'outline'}>
L{option.level}
</Badge>
</div>
</button>
);
})}
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold">
{t('operationTrace.retention')}
</h3>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">
{t('operationTrace.retentionDays')}
</span>
<Input
type="number"
className="w-28"
min={governance?.limits.min_retention_days ?? 1}
max={governance?.limits.max_retention_days ?? 3650}
value={retentionDays}
disabled={!canConfigure}
onChange={(event) =>
setRetentionDays(Number(event.target.value))
}
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">
{t('operationTrace.maxRows')}
</span>
<Input
type="number"
className="w-32"
min={governance?.limits.min_max_rows ?? 100}
max={governance?.limits.max_max_rows ?? 500000}
value={maxRows}
disabled={!canConfigure}
onChange={(event) => setMaxRows(Number(event.target.value))}
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">
{t('operationTrace.dedupeWindow')}
</span>
<Input
type="number"
className="w-28"
min={governance?.limits.min_dedupe_window_seconds ?? 0}
max={governance?.limits.max_dedupe_window_seconds ?? 3600}
value={dedupeSeconds}
disabled={!canConfigure}
onChange={(event) => setDedupeSeconds(Number(event.target.value))}
/>
</label>
<Button
size="sm"
onClick={() => void saveRetention()}
disabled={!canConfigure || saving}
>
{t('operationTrace.save')}
</Button>
</div>
</section>
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<History className="size-4" />
<h3 className="text-sm font-semibold">
{t('operationTrace.records')}
</h3>
<Badge variant="secondary">{total}</Badge>
{(page?.tampered_count ?? 0) > 0 && (
<Badge variant="destructive">
{t('operationTrace.tamperedCount', {
count: page?.tampered_count ?? 0,
})}
</Badge>
)}
</div>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Select
value={query.action ?? ALL_VALUE}
onValueChange={(value) =>
setQuery((prev) => ({
...prev,
action: value === ALL_VALUE ? undefined : value,
offset: 0,
}))
}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder={t('operationTrace.filterAction')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>
{t('operationTrace.filterAllActions')}
</SelectItem>
{(filters?.actions ?? []).map((action) => (
<SelectItem key={action} value={action}>
{t(`operationTrace.actions.${action}`, {
defaultValue: action,
})}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={query.resource_type ?? ALL_VALUE}
onValueChange={(value) =>
setQuery((prev) => ({
...prev,
resource_type: value === ALL_VALUE ? undefined : value,
offset: 0,
}))
}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue
placeholder={t('operationTrace.filterResource')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>
{t('operationTrace.filterAllResources')}
</SelectItem>
{(filters?.resource_types ?? []).map((resource) => (
<SelectItem key={resource} value={resource}>
{resource}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={() => void downloadLogs()}
disabled={exporting}
>
{exporting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Download className="size-3.5" />
)}
{t('operationTrace.export')}
</Button>
<Select
value={query.actor ?? ALL_VALUE}
onValueChange={(value) =>
setQuery((prev) => ({
...prev,
actor: value === ALL_VALUE ? undefined : value,
offset: 0,
}))
}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue placeholder={t('operationTrace.filterActor')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>
{t('operationTrace.filterAllActors')}
</SelectItem>
{(filters?.actors ?? []).map((actor) => (
<SelectItem
key={actor.account_uuid}
value={actor.account_uuid}
>
{actor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
{visibleRecords.length === 0 && (
<p className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">
{t('operationTrace.empty')}
</p>
)}
{visibleRecords.map((record) => (
<Item
key={record.id}
size="sm"
variant="muted"
className={`items-start rounded-lg ${
record.tampered ? 'border-destructive/60 bg-destructive/5' : ''
}`}
>
<ItemMedia variant="icon">
{record.tampered ? (
<ShieldAlert className="size-4 text-destructive" />
) : (
<History className="size-4" />
)}
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span>
{t(record.action_i18n_key, {
defaultValue: record.action ?? '',
})}
</span>
<Badge variant={outcomeVariant(record.outcome)}>
{t(`operationTrace.outcomes.${record.outcome}`)}
</Badge>
<Badge variant="outline">L{record.level}</Badge>
{record.resource_type && (
<Badge variant="secondary">{record.resource_type}</Badge>
)}
{record.tampered ? (
<Badge variant="destructive">
<ShieldAlert className="size-3" />
{t('operationTrace.tamperedBadge')}
</Badge>
) : (
<Badge variant="outline" className="text-emerald-600">
<ShieldCheck className="size-3" />
{t('operationTrace.verifiedBadge')}
</Badge>
)}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="font-medium">
{record.actor_name ??
record.actor_account_uuid ??
t('operationTrace.systemActor')}
</span>
<span aria-hidden="true">·</span>
<span>
{record.actor_role
? t(`workspace.roles.${record.actor_role}`)
: t('operationTrace.systemActor')}
</span>
{record.http_method && (
<>
<span aria-hidden="true">·</span>
<span className="font-mono">{record.http_method}</span>
</>
)}
{record.status_code !== null && (
<>
<span aria-hidden="true">·</span>
<span className="font-mono">{record.status_code}</span>
</>
)}
<span aria-hidden="true">·</span>
<span>
{record.created_at
? new Date(record.created_at).toLocaleString()
: ''}
</span>
</ItemDescription>
{record.summary && (
<p className="mt-1 text-xs text-muted-foreground">
{record.summary}
</p>
)}
{record.changes.length > 0 && (
<div className="mt-2 space-y-1 rounded-md bg-background/60 p-2">
{record.changes.map((change, index) => (
<ChangeRow
key={`${record.id}-${index}-${change.field}`}
change={change}
redactedLabel={t('operationTrace.redacted')}
/>
))}
</div>
)}
</ItemContent>
<ItemActions className="max-sm:hidden">
<div className="flex flex-col items-end gap-1">
<span className="text-xs text-muted-foreground">
{record.duration_ms}ms
</span>
{record.record_hash && (
<span
className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground"
title={record.record_hash}
>
<Fingerprint className="size-3" />
{record.record_hash.slice(0, 8)}
</span>
)}
</div>
</ItemActions>
</Item>
))}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t('operationTrace.pageInfo', {
from: total === 0 ? 0 : offset + 1,
to: Math.min(offset + PAGE_SIZE, total),
total,
})}
</span>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="outline"
disabled={!canGoBack || loading}
onClick={() =>
setQuery((prev) => ({
...prev,
offset: Math.max(offset - PAGE_SIZE, 0),
}))
}
aria-label={t('operationTrace.previousPage')}
>
<ChevronLeft className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
disabled={!canGoForward || loading}
onClick={() =>
setQuery((prev) => ({ ...prev, offset: offset + PAGE_SIZE }))
}
aria-label={t('operationTrace.nextPage')}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</section>
</PanelBody>
</>
);
}
@@ -0,0 +1,96 @@
/**
* Types for the operation traceability surface.
*
* The backend stores one append-only row per traced operation and answers
* "what was changed into what" through the ``changes`` diff. All
* human-readable strings arrive as i18n keys so the panel owns the copy.
*/
/** Capture levels, ordered by increasing scope. */
export type OperationLevel = 0 | 1 | 2;
export interface OperationLevelOption {
level: OperationLevel;
/** Frontend translation key, e.g. ``operationTrace.levels.read``. */
i18n_key: string;
}
export interface OperationLogLimits {
min_retention_days: number;
max_retention_days: number;
min_max_rows: number;
max_max_rows: number;
min_dedupe_window_seconds: number;
max_dedupe_window_seconds: number;
}
export interface OperationGovernance {
configured_level: OperationLevel;
configured_level_name: string;
retention_days: number;
max_rows: number;
/** Seconds during which an identical observation is collapsed into one row. */
dedupe_window_seconds: number;
supported_levels: OperationLevelOption[];
limits: OperationLogLimits;
}
export interface OperationChangeField {
field: string;
before: unknown;
after: unknown;
}
export interface OperationLogRecord {
id: number;
/** Whether the stored hash still matches the row content. */
integrity_ok: boolean;
/** Whether this row still links to its predecessor correctly. */
chain_ok: boolean;
/** Either check failed: the card is flagged as possibly tampered. */
tampered: boolean;
record_hash: string | null;
actor_account_uuid: string | null;
actor_name: string | null;
actor_role: string | null;
http_method: string | null;
action: string | null;
/** Translation key resolved by the panel, e.g. ``operationTrace.actions.view``. */
action_i18n_key: string;
resource_type: string | null;
resource_id: string | null;
level: OperationLevel;
level_name: string;
outcome: 'ok' | 'denied' | 'error';
status_code: number | null;
summary: string | null;
changes: OperationChangeField[];
duration_ms: number;
created_at: string | null;
}
export interface OperationLogPage {
records: OperationLogRecord[];
total: number;
limit: number;
offset: number;
/** How many records on this page failed hash or chain verification. */
tampered_count: number;
}
export interface OperationLogFilters {
actions: string[];
resource_types: string[];
actors: { account_uuid: string; name: string }[];
}
export interface OperationLogQuery {
limit?: number;
offset?: number;
action?: string;
resource_type?: string;
actor?: string;
level?: OperationLevel;
since?: string;
until?: string;
}
+49
View File
@@ -89,6 +89,13 @@ import type {
WorkspaceRole,
WorkspaceSpaceBilling,
} from '@/app/infra/entities/workspace';
import type {
OperationGovernance,
OperationLevel,
OperationLogFilters,
OperationLogPage,
OperationLogQuery,
} from '@/app/infra/entities/operation-log';
/**
* 后端服务客户端
@@ -1824,6 +1831,48 @@ export class BackendClient extends BaseHttpClient {
);
}
// ============ Workspace governance / operation traceability ============
public getOperationGovernance(): Promise<OperationGovernance> {
return this.get('/api/v1/settings/governance');
}
public updateOperationGovernance(request: {
level: OperationLevel;
retention_days?: number;
max_rows?: number;
dedupe_window_seconds?: number;
}): Promise<OperationGovernance> {
return this.put('/api/v1/settings/governance', request);
}
public getOperationLogs(
query: OperationLogQuery = {},
): Promise<OperationLogPage> {
return this.get('/api/v1/settings/operation-logs', query);
}
public getOperationLogFilters(): Promise<OperationLogFilters> {
return this.get('/api/v1/settings/operation-logs/filters');
}
/**
* Build the export URL so the browser can download the CSV attachment
* directly; axios would try to parse the response body as JSON.
*/
public buildOperationLogExportURL(query: OperationLogQuery = {}): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
params.set(key, String(value));
}
const suffix = params.toString();
return `${this.getBaseUrl()}/api/v1/settings/operation-logs/export${
suffix ? `?${suffix}` : ''
}`;
}
public setPassword(
newPassword: string,
currentPassword?: string,
+76 -8
View File
@@ -1,5 +1,4 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
assistant: {
modelHint: 'Switch assistant model for your next message',
@@ -99,7 +98,6 @@ const enUS = {
guidedTour: {
eventDebugDescription:
'Choose a supported event, enter test data, and review the output and tool results. Platform actions are simulated; models and other tools run as configured.',
pipeline: {
trigger: {
title: 'Choose which messages to answer',
@@ -727,7 +725,6 @@ const enUS = {
internalErrorHint:
'An unexpected error occurred. Check the backend logs using the reference below.',
errorReference: 'Error reference: {{id}}',
title: 'Bots',
description:
'Create and manage bots, which are the entry points for LangBot to connect with various platforms',
@@ -1127,13 +1124,11 @@ const enUS = {
pluginSettings: 'Plugin settings',
pluginSettingsDescription: 'Parameters declared by this plugin.',
selectToDebug: 'Select a plugin above to start debugging.',
debugOutput: 'Processor output',
debugDescription:
'Input events, plugin logs and action results for this test session.',
debugNotice:
'The plugin processes a test event. Platform actions use Mock and do not send real messages; other tools run as configured.',
create: 'Create plugin processor',
type: 'Plugin processor',
description:
@@ -1170,7 +1165,6 @@ const enUS = {
debugData: {
chatId: 'Chat ID',
feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)',
title: 'Event data',
form: 'Common fields',
json: 'Full JSON',
@@ -1633,7 +1627,6 @@ const enUS = {
runnerUsageAll: 'All',
runnerUsageAgent: 'Agent / Pipeline',
runnerUsageEvent: 'Plugin processor',
searchPlaceholder: 'Search plugins...',
searchPlaceholderCount:
'Search {{count}} extensions, capabilities, or use cases...',
@@ -2719,12 +2712,88 @@ const enUS = {
title: 'Settings',
nav: {
workspace: 'Workspace',
operationTrace: 'Traceability',
models: 'Models',
api: 'API',
storage: 'Storage',
account: 'Account',
},
},
operationTrace: {
title: 'Operation traceability',
description:
'Trace admin and owner changes and views, recording what was changed into what.',
captureLevel: 'Capture level',
retention: 'Retention',
retentionDays: 'Retention days',
maxRows: 'Max records',
dedupeWindow: 'Dedupe window (s)',
records: 'Operation records',
levelUpdated: 'Capture level updated',
levelUpdateFailed: 'Failed to update capture level',
retentionUpdated: 'Retention updated',
retentionUpdateFailed: 'Failed to update retention',
export: 'Download logs',
exportFailed: 'Failed to download operation logs',
empty: 'No operation records yet',
systemActor: 'System',
pageInfo: '{{from}}-{{to}} of {{total}}',
previousPage: 'Previous page',
nextPage: 'Next page',
filterAction: 'Action',
filterAllActions: 'All actions',
filterResource: 'Resource',
filterAllResources: 'All resources',
filterActor: 'Actor',
filterAllActors: 'All actors',
loadFailed: 'Failed to load traceability data',
refresh: 'Refresh',
save: 'Save',
redacted: 'Redacted',
tamperedBadge: 'Possibly tampered',
verifiedBadge: 'Verified',
tamperedCount: '{{count}} records failed verification',
levels: {
off: {
label: 'Tracing off',
},
mutation: {
label: 'Mutations only',
},
read: {
label: 'Everything (incl. views)',
},
},
levelNames: {
off: 'Off',
mutation: 'Mutations',
read: 'Everything',
},
outcomes: {
ok: 'Succeeded',
denied: 'Denied',
error: 'Failed',
},
actions: {
audit_log_view: 'View operation logs',
clear_prune: 'Prune records by policy',
settings_update: 'Update tracing settings',
settings_view: 'View tracing settings',
member_invite: 'Invite member',
member_role_update: 'Update member role',
member_remove: 'Remove member',
member_view: 'View members',
export: 'Export data',
execute: 'Execute task',
debug: 'Debug task',
publish: 'Publish resource',
create: 'Create resource',
update: 'Update resource',
delete: 'Delete resource',
view: 'View resource',
probe: 'Probe endpoint',
},
},
storageAnalysis: {
title: 'Storage Analysis',
description: 'Inspect storage usage and cleanup candidates',
@@ -3182,5 +3251,4 @@ const enUS = {
invalidPage: 'Invalid plugin page',
},
};
export default enUS;
+76
View File
@@ -2767,12 +2767,88 @@ const esES = {
title: 'Configuración',
nav: {
workspace: 'Workspace',
operationTrace: 'Trazabilidad',
models: 'Modelos',
api: 'API',
storage: 'Almacenamiento',
account: 'Cuenta',
},
},
operationTrace: {
title: 'Trazabilidad de operaciones',
description:
'Rastrea los cambios y las consultas de administradores y propietarios, registrando qué se cambió y a qué.',
captureLevel: 'Nivel de captura',
retention: 'Retención',
retentionDays: 'Días de retención',
maxRows: 'Registros máximos',
dedupeWindow: 'Ventana de deduplicación (s)',
records: 'Registros de operación',
levelUpdated: 'Nivel de captura actualizado',
levelUpdateFailed: 'No se pudo actualizar el nivel de captura',
retentionUpdated: 'Retención actualizada',
retentionUpdateFailed: 'No se pudo actualizar la retención',
export: 'Descargar registros',
exportFailed: 'No se pudieron descargar los registros',
empty: 'Aún no hay registros de operación',
systemActor: 'Sistema',
pageInfo: '{{from}}-{{to}} de {{total}}',
previousPage: 'Página anterior',
nextPage: 'Página siguiente',
filterAction: 'Acción',
filterAllActions: 'Todas las acciones',
filterResource: 'Recurso',
filterAllResources: 'Todos los recursos',
filterActor: 'Actor',
filterAllActors: 'Todos los actores',
loadFailed: 'No se pudieron cargar los datos de trazabilidad',
refresh: 'Actualizar',
save: 'Guardar',
redacted: 'Oculto',
tamperedBadge: 'Posible manipulación',
verifiedBadge: 'Verificado',
tamperedCount: '{{count}} registros fallaron la verificación',
levels: {
off: {
label: 'Sin trazabilidad',
},
mutation: {
label: 'Solo cambios',
},
read: {
label: 'Todo (incl. consultas)',
},
},
levelNames: {
off: 'Desactivado',
mutation: 'Solo cambios',
read: 'Todo',
},
outcomes: {
ok: 'Correcto',
denied: 'Denegado',
error: 'Fallido',
},
actions: {
audit_log_view: 'Ver registros de operación',
clear_prune: 'Depurar registros según la política',
settings_update: 'Actualizar ajustes de trazabilidad',
settings_view: 'Ver ajustes de trazabilidad',
member_invite: 'Invitar miembro',
member_role_update: 'Actualizar rol del miembro',
member_remove: 'Eliminar miembro',
member_view: 'Ver miembros',
export: 'Exportar datos',
execute: 'Ejecutar tarea',
debug: 'Depurar tarea',
publish: 'Publicar recurso',
create: 'Crear recurso',
update: 'Actualizar recurso',
delete: 'Eliminar recurso',
view: 'Ver recurso',
probe: 'Sondear endpoint',
},
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
description:
+76
View File
@@ -2730,12 +2730,88 @@ const jaJP = {
title: '設定',
nav: {
workspace: 'ワークスペース',
operationTrace: '操作トレーサビリティ',
models: 'モデル',
api: 'API',
storage: 'ストレージ',
account: 'アカウント',
},
},
operationTrace: {
title: '操作トレーサビリティ',
description:
'管理者とオーナーの変更・閲覧操作を追跡し、「何が何に変更されたか」を記録します。',
captureLevel: 'キャプチャレベル',
retention: '保持ポリシー',
retentionDays: '保持日数',
maxRows: '最大レコード数',
dedupeWindow: '重複排除ウィンドウ(秒)',
records: '操作レコード',
levelUpdated: 'キャプチャレベルを更新しました',
levelUpdateFailed: 'キャプチャレベルの更新に失敗しました',
retentionUpdated: '保持ポリシーを更新しました',
retentionUpdateFailed: '保持ポリシーの更新に失敗しました',
export: 'ログをダウンロード',
exportFailed: '操作ログのダウンロードに失敗しました',
empty: '操作レコードはまだありません',
systemActor: 'システム',
pageInfo: '{{from}}-{{to}} / 全 {{total}} 件',
previousPage: '前のページ',
nextPage: '次のページ',
filterAction: '操作',
filterAllActions: 'すべての操作',
filterResource: 'リソース',
filterAllResources: 'すべてのリソース',
filterActor: '実行者',
filterAllActors: 'すべての実行者',
loadFailed: 'トレーサビリティデータの読み込みに失敗しました',
refresh: '更新',
save: '保存',
redacted: 'マスク済み',
tamperedBadge: '改ざんの可能性',
verifiedBadge: '検証済み',
tamperedCount: '{{count}} 件が検証に失敗',
levels: {
off: {
label: 'トレース無効',
},
mutation: {
label: '変更のみ',
},
read: {
label: '最上位(閲覧含む)',
},
},
levelNames: {
off: '無効',
mutation: '変更のみ',
read: '最上位',
},
outcomes: {
ok: '成功',
denied: '拒否',
error: '失敗',
},
actions: {
audit_log_view: '操作ログを閲覧',
clear_prune: 'ポリシーで記録を整理',
settings_update: 'トレーサビリティ設定を変更',
settings_view: 'トレーサビリティ設定を閲覧',
member_invite: 'メンバーを招待',
member_role_update: 'メンバーのロールを変更',
member_remove: 'メンバーを削除',
member_view: 'メンバーを閲覧',
export: 'データをエクスポート',
execute: 'タスクを実行',
debug: 'タスクをデバッグ',
publish: 'リソースを公開',
create: 'リソースを作成',
update: 'リソースを変更',
delete: 'リソースを削除',
view: 'リソースを閲覧',
probe: 'エンドポイントを確認',
},
},
storageAnalysis: {
title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します',
+76
View File
@@ -2737,12 +2737,88 @@ const ruRU = {
title: 'Настройки',
nav: {
workspace: 'Workspace',
operationTrace: 'Трассировка',
models: 'Модели',
api: 'API',
storage: 'Хранилище',
account: 'Аккаунт',
},
},
operationTrace: {
title: 'Трассировка операций',
description:
'Отслеживание изменений и просмотров администраторов и владельцев с записью того, что и на что было изменено.',
captureLevel: 'Уровень записи',
retention: 'Хранение',
retentionDays: 'Дней хранения',
maxRows: 'Максимум записей',
dedupeWindow: 'Окно дедупликации (с)',
records: 'Записи операций',
levelUpdated: 'Уровень записи обновлён',
levelUpdateFailed: 'Не удалось обновить уровень записи',
retentionUpdated: 'Хранение обновлено',
retentionUpdateFailed: 'Не удалось обновить хранение',
export: 'Скачать журнал',
exportFailed: 'Не удалось скачать журнал операций',
empty: 'Записей операций пока нет',
systemActor: 'Система',
pageInfo: '{{from}}-{{to}} из {{total}}',
previousPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
filterAction: 'Действие',
filterAllActions: 'Все действия',
filterResource: 'Ресурс',
filterAllResources: 'Все ресурсы',
filterActor: 'Исполнитель',
filterAllActors: 'Все исполнители',
loadFailed: 'Не удалось загрузить данные трассировки',
refresh: 'Обновить',
save: 'Сохранить',
redacted: 'Скрыто',
tamperedBadge: 'Возможна подмена',
verifiedBadge: 'Проверено',
tamperedCount: 'Записей с ошибкой проверки: {{count}}',
levels: {
off: {
label: 'Трассировка выключена',
},
mutation: {
label: 'Только изменения',
},
read: {
label: 'Всё (включая просмотры)',
},
},
levelNames: {
off: 'Выкл',
mutation: 'Изменения',
read: 'Всё',
},
outcomes: {
ok: 'Успех',
denied: 'Отказано',
error: 'Ошибка',
},
actions: {
audit_log_view: 'Просмотр журнала операций',
clear_prune: 'Очистка записей по политике',
settings_update: 'Изменение настроек трассировки',
settings_view: 'Просмотр настроек трассировки',
member_invite: 'Приглашение участника',
member_role_update: 'Изменение роли участника',
member_remove: 'Удаление участника',
member_view: 'Просмотр участников',
export: 'Экспорт данных',
execute: 'Выполнение задачи',
debug: 'Отладка задачи',
publish: 'Публикация ресурса',
create: 'Создание ресурса',
update: 'Изменение ресурса',
delete: 'Удаление ресурса',
view: 'Просмотр ресурса',
probe: 'Проверка эндпоинта',
},
},
storageAnalysis: {
title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку',
+76
View File
@@ -2664,12 +2664,88 @@ const thTH = {
title: 'การตั้งค่า',
nav: {
workspace: 'Workspace',
operationTrace: 'การติดตาม',
models: 'โมเดล',
api: 'API',
storage: 'พื้นที่จัดเก็บ',
account: 'บัญชี',
},
},
operationTrace: {
title: 'การติดตามการดำเนินการ',
description:
'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ โดยบันทึกว่าอะไรเปลี่ยนเป็นอะไร',
captureLevel: 'ระดับการบันทึก',
retention: 'นโยบายการเก็บรักษา',
retentionDays: 'จำนวนวันเก็บรักษา',
maxRows: 'จำนวนบันทึกสูงสุด',
dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)',
records: 'บันทึกการดำเนินการ',
levelUpdated: 'อัปเดตระดับการบันทึกแล้ว',
levelUpdateFailed: 'อัปเดตระดับการบันทึกไม่สำเร็จ',
retentionUpdated: 'อัปเดตนโยบายการเก็บรักษาแล้ว',
retentionUpdateFailed: 'อัปเดตนโยบายการเก็บรักษาไม่สำเร็จ',
export: 'ดาวน์โหลดบันทึก',
exportFailed: 'ดาวน์โหลดบันทึกการดำเนินการไม่สำเร็จ',
empty: 'ยังไม่มีบันทึกการดำเนินการ',
systemActor: 'ระบบ',
pageInfo: '{{from}}-{{to}} จาก {{total}}',
previousPage: 'หน้าก่อนหน้า',
nextPage: 'หน้าถัดไป',
filterAction: 'การดำเนินการ',
filterAllActions: 'ทุกการดำเนินการ',
filterResource: 'ทรัพยากร',
filterAllResources: 'ทุกทรัพยากร',
filterActor: 'ผู้ดำเนินการ',
filterAllActors: 'ผู้ดำเนินการทั้งหมด',
loadFailed: 'โหลดข้อมูลการติดตามไม่สำเร็จ',
refresh: 'รีเฟรช',
save: 'บันทึก',
redacted: 'ปิดบังแล้ว',
tamperedBadge: 'อาจถูกแก้ไข',
verifiedBadge: 'ตรวจสอบผ่าน',
tamperedCount: 'มี {{count}} บันทึกที่ตรวจสอบไม่ผ่าน',
levels: {
off: {
label: 'ปิดการติดตาม',
},
mutation: {
label: 'เฉพาะการแก้ไข',
},
read: {
label: 'สูงสุด (รวมการดู)',
},
},
levelNames: {
off: 'ปิด',
mutation: 'เฉพาะแก้ไข',
read: 'สูงสุด',
},
outcomes: {
ok: 'สำเร็จ',
denied: 'ถูกปฏิเสธ',
error: 'ล้มเหลว',
},
actions: {
audit_log_view: 'ดูบันทึกการดำเนินการ',
clear_prune: 'ล้างบันทึกตามนโยบาย',
settings_update: 'แก้ไขการตั้งค่าการติดตาม',
settings_view: 'ดูการตั้งค่าการติดตาม',
member_invite: 'เชิญสมาชิก',
member_role_update: 'แก้ไขบทบาทสมาชิก',
member_remove: 'ลบสมาชิก',
member_view: 'ดูสมาชิก',
export: 'ส่งออกข้อมูล',
execute: 'รันงาน',
debug: 'ดีบักงาน',
publish: 'เผยแพร่ทรัพยากร',
create: 'สร้างทรัพยากร',
update: 'แก้ไขทรัพยากร',
delete: 'ลบทรัพยากร',
view: 'ดูทรัพยากร',
probe: 'ตรวจสอบปลายทาง',
},
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
+76
View File
@@ -2699,12 +2699,88 @@ const viVN = {
title: 'Cài đặt',
nav: {
workspace: 'Workspace',
operationTrace: 'Truy vết',
models: 'Mô hình',
api: 'API',
storage: 'Lưu trữ',
account: 'Tài khoản',
},
},
operationTrace: {
title: 'Truy vết thao tác',
description:
'Theo dõi thao tác sửa và xem của quản trị viên và chủ sở hữu, ghi lại "cái gì đã đổi thành cái gì".',
captureLevel: 'Mức ghi nhận',
retention: 'Chính sách lưu trữ',
retentionDays: 'Số ngày lưu',
maxRows: 'Số bản ghi tối đa',
dedupeWindow: 'Khoảng khử trùng lặp (giây)',
records: 'Bản ghi thao tác',
levelUpdated: 'Đã cập nhật mức ghi nhận',
levelUpdateFailed: 'Cập nhật mức ghi nhận thất bại',
retentionUpdated: 'Đã cập nhật chính sách lưu trữ',
retentionUpdateFailed: 'Cập nhật chính sách lưu trữ thất bại',
export: 'Tải nhật ký',
exportFailed: 'Tải nhật ký thao tác thất bại',
empty: 'Chưa có bản ghi thao tác',
systemActor: 'Hệ thống',
pageInfo: '{{from}}-{{to}} trên {{total}}',
previousPage: 'Trang trước',
nextPage: 'Trang sau',
filterAction: 'Thao tác',
filterAllActions: 'Tất cả thao tác',
filterResource: 'Tài nguyên',
filterAllResources: 'Tất cả tài nguyên',
filterActor: 'Người thực hiện',
filterAllActors: 'Tất cả người thực hiện',
loadFailed: 'Tải dữ liệu truy vết thất bại',
refresh: 'Làm mới',
save: 'Lưu',
redacted: 'Đã ẩn',
tamperedBadge: 'Có thể bị sửa',
verifiedBadge: 'Đã xác minh',
tamperedCount: '{{count}} bản ghi không vượt qua kiểm tra',
levels: {
off: {
label: 'Tắt truy vết',
},
mutation: {
label: 'Chỉ thao tác sửa',
},
read: {
label: 'Cao nhất (gồm xem)',
},
},
levelNames: {
off: 'Tắt',
mutation: 'Chỉ sửa',
read: 'Cao nhất',
},
outcomes: {
ok: 'Thành công',
denied: 'Bị từ chối',
error: 'Thất bại',
},
actions: {
audit_log_view: 'Xem nhật ký thao tác',
clear_prune: 'Dọn bản ghi theo chính sách',
settings_update: 'Cập nhật cài đặt truy vết',
settings_view: 'Xem cài đặt truy vết',
member_invite: 'Mời thành viên',
member_role_update: 'Cập nhật vai trò thành viên',
member_remove: 'Xóa thành viên',
member_view: 'Xem thành viên',
export: 'Xuất dữ liệu',
execute: 'Chạy tác vụ',
debug: 'Gỡ lỗi tác vụ',
publish: 'Xuất bản tài nguyên',
create: 'Tạo tài nguyên',
update: 'Cập nhật tài nguyên',
delete: 'Xóa tài nguyên',
view: 'Xem tài nguyên',
probe: 'Dò endpoint',
},
},
storageAnalysis: {
title: 'Phân tích lưu trữ',
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
+75
View File
@@ -2562,12 +2562,87 @@ const zhHans = {
title: '设置',
nav: {
workspace: '工作区',
operationTrace: '操作溯源',
models: '模型',
api: 'API',
storage: '存储',
account: '账户',
},
},
operationTrace: {
title: '操作溯源',
description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。',
captureLevel: '操作等级',
retention: '保留策略',
retentionDays: '保留天数',
maxRows: '最大记录数',
dedupeWindow: '去重窗口(秒)',
records: '操作记录',
levelUpdated: '操作等级已更新',
levelUpdateFailed: '操作等级更新失败',
retentionUpdated: '保留策略已更新',
retentionUpdateFailed: '保留策略更新失败',
export: '下载日志',
exportFailed: '下载操作日志失败',
empty: '暂无操作记录',
systemActor: '系统',
pageInfo: '第 {{from}}-{{to}} 条,共 {{total}} 条',
previousPage: '上一页',
nextPage: '下一页',
filterAction: '操作类型',
filterAllActions: '全部操作',
filterResource: '资源类型',
filterAllResources: '全部资源',
filterActor: '操作者',
filterAllActors: '全部操作者',
loadFailed: '加载操作溯源数据失败',
refresh: '刷新',
save: '保存',
redacted: '已脱敏',
tamperedBadge: '可能被篡改',
verifiedBadge: '校验通过',
tamperedCount: '{{count}} 条记录校验异常',
levels: {
off: {
label: '关闭溯源',
},
mutation: {
label: '仅修改操作',
},
read: {
label: '最高级(含查看)',
},
},
levelNames: {
off: '关闭',
mutation: '仅修改',
read: '最高级',
},
outcomes: {
ok: '成功',
denied: '被拒绝',
error: '失败',
},
actions: {
audit_log_view: '查看操作日志',
clear_prune: '按策略清理记录',
settings_update: '修改溯源设置',
settings_view: '查看溯源设置',
member_invite: '邀请成员',
member_role_update: '修改成员角色',
member_remove: '移除成员',
member_view: '查看成员',
export: '导出数据',
execute: '执行任务',
debug: '调试任务',
publish: '发布资源',
create: '创建资源',
update: '修改资源',
delete: '删除资源',
view: '查看资源',
probe: '探测接口',
},
},
storageAnalysis: {
title: '存储分析',
description: '查看存储占用和可清理文件',
+75
View File
@@ -2563,12 +2563,87 @@ const zhHant = {
title: '設定',
nav: {
workspace: 'Workspace',
operationTrace: '操作溯源',
models: '模型',
api: 'API',
storage: '儲存',
account: '帳戶',
},
},
operationTrace: {
title: '操作溯源',
description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。',
captureLevel: '操作等級',
retention: '保留策略',
retentionDays: '保留天數',
maxRows: '最大記錄數',
dedupeWindow: '去重視窗(秒)',
records: '操作記錄',
levelUpdated: '操作等級已更新',
levelUpdateFailed: '操作等級更新失敗',
retentionUpdated: '保留策略已更新',
retentionUpdateFailed: '保留策略更新失敗',
export: '下載日誌',
exportFailed: '下載操作日誌失敗',
empty: '暫無操作記錄',
systemActor: '系統',
pageInfo: '第 {{from}}-{{to}} 條,共 {{total}} 條',
previousPage: '上一頁',
nextPage: '下一頁',
filterAction: '操作類型',
filterAllActions: '全部操作',
filterResource: '資源類型',
filterAllResources: '全部資源',
filterActor: '操作者',
filterAllActors: '全部操作者',
loadFailed: '載入操作溯源資料失敗',
refresh: '重新整理',
save: '儲存',
redacted: '已遮罩',
tamperedBadge: '可能被竄改',
verifiedBadge: '驗證通過',
tamperedCount: '{{count}} 筆記錄驗證異常',
levels: {
off: {
label: '關閉溯源',
},
mutation: {
label: '僅修改操作',
},
read: {
label: '最高級(含查看)',
},
},
levelNames: {
off: '關閉',
mutation: '僅修改',
read: '最高級',
},
outcomes: {
ok: '成功',
denied: '被拒絕',
error: '失敗',
},
actions: {
audit_log_view: '查看操作日誌',
clear_prune: '依策略清理記錄',
settings_update: '修改溯源設定',
settings_view: '查看溯源設定',
member_invite: '邀請成員',
member_role_update: '修改成員角色',
member_remove: '移除成員',
member_view: '查看成員',
export: '匯出資料',
execute: '執行任務',
debug: '偵錯任務',
publish: '發佈資源',
create: '建立資源',
update: '修改資源',
delete: '刪除資源',
view: '查看資源',
probe: '探測介面',
},
},
storageAnalysis: {
title: '儲存分析',
description: '查看儲存占用和可清理檔案',