mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 09:25:21 +08:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
|
||||
@@ -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: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
|
||||
@@ -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: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '查看存储占用和可清理文件',
|
||||
|
||||
@@ -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: '查看儲存占用和可清理檔案',
|
||||
|
||||
Reference in New Issue
Block a user