feat(operation-trace): workspace-wide integrity counters, drill-down and resource identity

- Count hash mismatches / broken links over the whole filtered history instead
  of only the returned page, so the badges no longer reset to zero on page 2.
- Classify failing record ids during the same scan and expose them through an
  integrity=all|issues|hash_mismatch|chain_broken listing filter.
- Report scanned_count / scan_truncated so an approximate (row-capped) summary
  is never mistaken for a clean one.
- Resolve the acted-on resource identity from route params or the request body,
  giving every resource family a "which plugin/skill/knowledge base" trace.
- Render resource types and the two counters through i18n, keeping all eight
  locale files key- and line-aligned.
This commit is contained in:
TyperBody
2026-09-26 03:23:24 +08:00
parent abca2e7bff
commit 6981b6643f
13 changed files with 470 additions and 29 deletions
@@ -387,6 +387,28 @@ class RouterGroup(abc.ABC):
if candidate is not None:
resource_id = str(candidate)
# Routes without an explicit resource_param still name the resource
# they act on, either as a URL parameter or inside the payload of an
# install-style endpoint. Resolving that identity here gives every
# resource family a "which plugin/skill/knowledge base" trace without
# touching each handler. The body is read defensively: an audit path
# must never fail (or block) because a request carries no JSON.
try:
# Imported lazily so the base controller never participates in an
# import cycle with the service layer at module load time.
from ..service import settings as settings_service
request_body: dict[str, typing.Any] | None = None
if str(quart.request.method or '').upper() not in ('GET', 'HEAD', 'OPTIONS'):
raw_body = await quart.request.get_json(silent=True)
if isinstance(raw_body, dict):
request_body = raw_body
identity = settings_service.resolve_resource_identity(kwargs, request_body)
if identity:
resource_id = identity
except Exception: # pragma: no cover - identity resolution is best effort
pass
# Handlers may publish request-local traceability facts when a
# static route declaration cannot describe the runtime diff, e.g.
# the previous and the new role of a member. ``quart.g`` is
@@ -169,6 +169,9 @@ class SettingsRouterGroup(group.RouterGroup):
level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None,
since=_parse_timestamp(args.get('since')),
until=_parse_timestamp(args.get('until')),
# Optional verification filter: lets the panel turn the three
# tamper counters into a drill-down instead of a dead badge.
integrity=args.get('integrity') or None,
)
return self.success(data=result)
+233 -7
View File
@@ -28,6 +28,8 @@ import enum
import hashlib
import hmac
import json
import logging
import time
import typing
import sqlalchemy
@@ -44,6 +46,8 @@ from ....utils import constants
from ..authz import WorkspaceRole
from ..context import PrincipalType, RequestContext
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Storage keys (WorkspaceMetadata.value is a short string column)
@@ -77,6 +81,30 @@ MAX_DEDUPE_WINDOW_SECONDS = 3600
MAX_PAGE_SIZE = 200
DEFAULT_PAGE_SIZE = 50
#: How many newest records the integrity summary reads before the numbers
#: beside the list are served from an approximate scan. Verifying a record means
#: recomputing its HMAC, so an unbounded scan on every page/refresh would make the
#: panel latency grow with the history size. ``MAX_MAX_ROWS`` (500k) is the
#: configured ceiling for a Workspace, so a full-table verification at that scale
#: is exactly what this bound avoids.
MAX_INTEGRITY_SCAN_ROWS = 20000
#: ``integrity`` query values accepted by :meth:`query_logs`. ``all`` keeps the
#: previous behaviour; the other two map to the two failure modes the panel
#: surfaces independently (hash mismatch vs. broken chain link).
INTEGRITY_FILTER_ALL = 'all'
INTEGRITY_FILTER_ISSUES = 'issues'
INTEGRITY_FILTER_HASH_MISMATCH = 'hash_mismatch'
INTEGRITY_FILTER_CHAIN_BROKEN = 'chain_broken'
_INTEGRITY_FILTERS = frozenset(
{
INTEGRITY_FILTER_ALL,
INTEGRITY_FILTER_ISSUES,
INTEGRITY_FILTER_HASH_MISMATCH,
INTEGRITY_FILTER_CHAIN_BROKEN,
}
)
#: Upper bound for a single export so a download cannot load the whole table.
MAX_EXPORT_ROWS = 10000
@@ -559,6 +587,59 @@ def changed_fields(
return changes
#: Ordered key groups that identify the resource a request acts on. Each group
#: is joined with ``/`` so ``author`` + ``plugin_name`` renders as
#: ``author/plugin_name`` — the same identity the UI already shows. Only these
#: keys are consulted: reading arbitrary payload fields would let a caller
#: inject unbounded, unattributed text into the audit trail.
_IDENTITY_KEY_GROUPS: typing.Final[tuple[tuple[str, ...], ...]] = (
('plugin_author', 'plugin_name'),
('author', 'plugin_name'),
('author', 'name'),
('skill_uuid',),
('knowledge_base_uuid',),
('server_uuid',),
('pipeline_uuid',),
('provider_uuid',),
('model_uuid',),
('account_uuid',),
('email',),
('name',),
('uuid',),
)
def resolve_resource_identity(
path_params: typing.Mapping[str, typing.Any] | None,
body: typing.Mapping[str, typing.Any] | None,
) -> str | None:
"""Best-effort identity of the resource one request acts on.
Path parameters describe the resource a route was registered for and are
therefore trusted; the request body is only consulted for install-style
endpoints that carry the identity in their payload (a marketplace install
names the plugin in the body, not in the URL). The result is bounded and
sensitive-looking keys are skipped so a secret can never be echoed back
through the trace.
"""
params = path_params or {}
payload = body or {}
for group in _IDENTITY_KEY_GROUPS:
parts: list[str] = []
for key in group:
value = params.get(key)
if value is None:
value = payload.get(key)
if value is None or value == '' or is_sensitive_field(key):
parts = []
break
parts.append(str(value))
if parts:
return typing.cast(str, truncate('/'.join(parts), _MAX_RESOURCE_ID_CHARS))
return None
def build_summary(rule: ActionRule, changes: list[dict[str, typing.Any]]) -> str:
"""Build a short change digest stored alongside the record.
@@ -1231,11 +1312,25 @@ class WorkspaceSettingsService:
level: int | None = None,
since: datetime.datetime | None = None,
until: datetime.datetime | None = None,
integrity: str | None = None,
) -> dict[str, typing.Any]:
"""Return one page of operation records plus a coarse summary."""
"""Return one page of operation records plus a Workspace-wide summary.
The three verification counters describe the whole filtered history, not
just the returned page: an operator opening page 2 must still see that
39 records are tampered. Only ``total`` and ``records`` follow the
pagination window.
``integrity`` optionally narrows the listing to the records that failed
verification, which lets the panel make its counters actionable instead
of decorative.
"""
resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
resolved_offset = max(int(offset or 0), 0)
resolved_integrity = (integrity or INTEGRITY_FILTER_ALL).strip().lower()
if resolved_integrity not in _INTEGRITY_FILTERS:
resolved_integrity = INTEGRITY_FILTER_ALL
try:
model = persistence_operation_log.WorkspaceOperationLog
@@ -1253,14 +1348,32 @@ class WorkspaceSettingsService:
if until is not None:
filters.append(model.created_at <= until)
# Verify the filtered history once per request. This replaces the
# previous per-page sum, which made the counters silently restart at
# zero on every page after the first.
summary = await self._integrity_summary(model, filters)
# ``total`` follows the active listing filter so the pagination badge
# and the pager stay consistent with what the operator asked to see.
visible_filters = list(filters)
if resolved_integrity == INTEGRITY_FILTER_ISSUES:
ids = summary['tampered_ids']
visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false())
elif resolved_integrity == INTEGRITY_FILTER_HASH_MISMATCH:
ids = summary['integrity_failed_ids']
visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false())
elif resolved_integrity == INTEGRITY_FILTER_CHAIN_BROKEN:
ids = summary['chain_failed_ids']
visible_filters.append(model.id.in_(ids) if ids else sqlalchemy.false())
total_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count()).select_from(model).where(*filters)
sqlalchemy.select(sqlalchemy.func.count()).select_from(model).where(*visible_filters)
)
total = int(total_result.scalar_one_or_none() or 0)
rows_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(model)
.where(*filters)
.where(*visible_filters)
.order_by(model.id.desc())
.limit(resolved_limit)
.offset(resolved_offset)
@@ -1287,10 +1400,12 @@ class WorkspaceSettingsService:
)
for index, row in enumerate(rows)
]
integrity_summary = summary['summary']
except Exception as exc: # pragma: no cover - defensive
self.ap.logger.debug(f'Operation log query skipped: {exc}')
total = 0
records = []
integrity_summary = self._empty_integrity_summary()
return {
'records': records,
@@ -1299,12 +1414,123 @@ class WorkspaceSettingsService:
'offset': resolved_offset,
# Report the two failure modes separately so the panel can tell a
# content edit (integrity) apart from a dropped link (chain) instead
# of collapsing both into a single "tampered" signal.
'tampered_count': sum(1 for record in records if record.get('tampered')),
'integrity_failed_count': sum(1 for record in records if not record.get('integrity_ok')),
'chain_failed_count': sum(1 for record in records if not record.get('chain_ok')),
# of collapsing both into a single "tampered" signal. The counters
# cover the whole filtered history, not only this page.
'tampered_count': integrity_summary['tampered'],
'integrity_failed_count': integrity_summary['integrity_failed'],
'chain_failed_count': integrity_summary['chain_failed'],
'scanned_count': integrity_summary['scanned'],
'scan_truncated': integrity_summary['truncated'],
'integrity_filter': resolved_integrity,
}
@staticmethod
def _empty_integrity_summary() -> dict[str, typing.Any]:
return {
'tampered': 0,
'integrity_failed': 0,
'chain_failed': 0,
'scanned': 0,
'truncated': False,
}
async def _integrity_summary(
self,
model: typing.Any,
filters: list[typing.Any],
) -> dict[str, typing.Any]:
"""Verify the filtered history and classify every failing record.
Returns the three counters, the number of rows actually verified and the
record id lists needed to narrow the listing to one failure mode. The
scan walks the records newest-first and stops at
:data:`MAX_INTEGRITY_SCAN_ROWS`, so both the counters and the id lists
always describe the same, well-defined slice of the history.
"""
started = time.monotonic()
try:
rows_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(model)
.where(*filters)
.order_by(model.id.desc())
.limit(MAX_INTEGRITY_SCAN_ROWS)
)
rows = list(rows_result.all())
oldest_id = rows[-1].id if rows else None
previous_row = None
verification_failed = False
if oldest_id is not None:
# Check the link of the oldest verified row against the row that
# precedes it, even when it sits outside the scan window: the
# baseline must still be read from the table, not guessed, or the
# boundary record would be reported as broken forever.
older_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(model)
.where(model.workspace_uuid == rows[0].workspace_uuid, model.id < oldest_id)
.order_by(model.id.desc())
.limit(1)
)
previous_row = older_result.first()
tampered_ids: list[int] = []
integrity_failed_ids: list[int] = []
chain_failed_ids: list[int] = []
for index, row in enumerate(rows):
predecessor = rows[index + 1] if index + 1 < len(rows) else previous_row
record = self._serialize_log(row, previous_row=predecessor)
if not record['integrity_ok']:
integrity_failed_ids.append(row.id)
if not record['chain_ok']:
chain_failed_ids.append(row.id)
if record['tampered']:
tampered_ids.append(row.id)
verification_failed = True
summary = {
'tampered': len(tampered_ids),
'integrity_failed': len(integrity_failed_ids),
'chain_failed': len(chain_failed_ids),
'scanned': len(rows),
'truncated': len(rows) >= MAX_INTEGRITY_SCAN_ROWS,
}
if verification_failed or summary['truncated']:
# Worth logging: either the store was edited underneath us, or the
# history outgrew the verification window and the counters became
# approximate.
logger.warning(
'Operation log integrity scan: %s tampered / %s hash / %s chain over %s rows (truncated=%s)',
summary['tampered'],
summary['integrity_failed'],
summary['chain_failed'],
summary['scanned'],
summary['truncated'],
)
elapsed_ms = int((time.monotonic() - started) * 1000)
if elapsed_ms >= 250:
logger.debug(
'Operation log integrity scan took %sms over %s rows',
elapsed_ms,
summary['scanned'],
)
return {
'summary': summary,
'tampered_ids': tampered_ids,
'integrity_failed_ids': integrity_failed_ids,
'chain_failed_ids': chain_failed_ids,
}
except Exception as exc: # pragma: no cover - defensive
# Never take the whole log panel down because verification failed:
# fall back to zeroed counters and an unfiltered listing.
logger.debug(f'Operation log integrity summary skipped: {exc}')
return {
'summary': self._empty_integrity_summary(),
'tampered_ids': [],
'integrity_failed_ids': [],
'chain_failed_ids': [],
}
def _serialize_log(self, row: typing.Any, *, previous_row: typing.Any | None = None) -> dict[str, typing.Any]:
"""Serialize one row and re-verify its tamper-evidence chain.
@@ -34,6 +34,7 @@ import {
import type {
OperationChangeField,
OperationGovernance,
OperationIntegrityFilter,
OperationLevel,
OperationLogFilters,
OperationLogPage,
@@ -206,6 +207,19 @@ export default function OperationTracePanel({
// total badge disagree with the list) whenever a page is re-fetched.
const visibleRecords = page?.records ?? [];
const integrityFilter: OperationIntegrityFilter = query.integrity ?? 'all';
// Clicking a counter turns it into a drill-down: the panel is the only place
// the operator can learn *which* records failed verification, so a badge that
// cannot be acted on would be a dead end.
function toggleIntegrityFilter(next: OperationIntegrityFilter) {
setQuery((prev) => ({
...prev,
integrity: prev.integrity === next ? undefined : next,
offset: 0,
}));
}
async function changeLevel(level: OperationLevel) {
if (!canConfigure) return;
setSaving(true);
@@ -235,6 +249,7 @@ export default function OperationTracePanel({
level: query.level,
since: query.since,
until: query.until,
integrity: query.integrity,
});
const response = await backendClient.downloadFile(url);
const disposition = response.headers['content-disposition'] as
@@ -450,22 +465,44 @@ export default function OperationTracePanel({
{t('operationTrace.records')}
</h3>
<Badge variant="secondary">{total}</Badge>
{(page?.tampered_count ?? 0) > 0 &&
(page?.integrity_failed_count ?? 0) > 0 && (
{/* The two failure modes are rendered independently: a record can
drop its chain link without corrupting its own hash, so gating
one badge on the other counter would hide a real mismatch.
The counters cover the whole filtered history, so they stay
stable while the operator pages through the records. */}
{(page?.integrity_failed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => toggleIntegrityFilter('hash_mismatch')}
className="cursor-pointer"
>
<Badge variant="destructive">
{t('operationTrace.integrityFailedCount', {
count: page?.integrity_failed_count ?? 0,
})}
</Badge>
)}
{(page?.tampered_count ?? 0) > 0 &&
(page?.chain_failed_count ?? 0) > 0 && (
</button>
)}
{(page?.chain_failed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => toggleIntegrityFilter('chain_broken')}
className="cursor-pointer"
>
<Badge variant="destructive">
{t('operationTrace.chainFailedCount', {
count: page?.chain_failed_count ?? 0,
})}
</Badge>
)}
</button>
)}
{page?.scan_truncated && (
<Badge variant="outline">
{t('operationTrace.scanTruncated', {
count: page?.scanned_count ?? 0,
})}
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
@@ -515,7 +552,12 @@ export default function OperationTracePanel({
</SelectItem>
{(filters?.resource_types ?? []).map((resource) => (
<SelectItem key={resource} value={resource}>
{resource}
{/* The API ships the raw resource family (``resource``,
``member``...); the label is resolved here so no
interface text leaks from the backend. */}
{t(`operationTrace.resourceTypes.${resource}`, {
defaultValue: resource,
})}
</SelectItem>
))}
</SelectContent>
@@ -553,7 +595,9 @@ export default function OperationTracePanel({
<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')}
{integrityFilter === 'all'
? t('operationTrace.empty')
: t('operationTrace.emptyFiltered')}
</p>
)}
{visibleRecords.map((record) => (
@@ -586,7 +630,11 @@ export default function OperationTracePanel({
</Badge>
<Badge variant="outline">L{record.level}</Badge>
{record.resource_type && (
<Badge variant="secondary">{record.resource_type}</Badge>
<Badge variant="secondary">
{t(`operationTrace.resourceTypes.${record.resource_type}`, {
defaultValue: record.resource_type,
})}
</Badge>
)}
{record.tampered ? (
<Badge variant="destructive">
+21 -1
View File
@@ -69,17 +69,35 @@ export interface OperationLogRecord {
created_at: string | null;
}
/** Verification filter accepted by the listing endpoint. */
export type OperationIntegrityFilter =
| 'all'
| 'issues'
| 'hash_mismatch'
| 'chain_broken';
export interface OperationLogPage {
records: OperationLogRecord[];
/** Records matching the filters and the active verification filter. */
total: number;
limit: number;
offset: number;
/** How many records on this page failed hash or chain verification. */
/**
* Workspace-wide counters over the filtered history, not just this page.
* They stay constant while paging so an operator opening page 2 still sees
* the total number of tampered records.
*/
tampered_count: number;
/** Records whose stored hash no longer matches their content. */
integrity_failed_count: number;
/** Records whose predecessor link is broken. */
chain_failed_count: number;
/** How many records the verification scan actually inspected. */
scanned_count: number;
/** The scan stopped at the row cap, so the counters are approximate. */
scan_truncated: boolean;
/** Which verification filter produced this page. */
integrity_filter: OperationIntegrityFilter;
}
export interface OperationLogFilters {
@@ -97,4 +115,6 @@ export interface OperationLogQuery {
level?: OperationLevel;
since?: string;
until?: string;
/** Narrow the listing to records that failed verification. */
integrity?: OperationIntegrityFilter;
}
+17 -2
View File
@@ -2721,8 +2721,7 @@ const enUS = {
},
operationTrace: {
title: 'Operation traceability',
description:
'Trace admin and owner changes and views, recording what was changed into what.',
description: 'Trace admin and owner edits and views, and what changed.',
captureLevel: 'Capture level',
retention: 'Retention',
retentionDays: 'Retention days',
@@ -2754,6 +2753,8 @@ const enUS = {
verifiedBadge: 'Verified',
integrityFailedCount: '{{count}} hash mismatches',
chainFailedCount: '{{count}} broken links',
scanTruncated: 'Only the latest {{count}} verified',
emptyFiltered: 'No records need verification under the current filters',
levels: {
off: {
label: 'Tracing off',
@@ -2770,6 +2771,20 @@ const enUS = {
mutation: 'Mutations',
read: 'Everything',
},
resourceTypes: {
workspace_settings: 'Workspace settings',
member: 'Member',
member_invitation: 'Member invitation',
operation_log: 'Operation log',
plugin: 'Extension',
plugin_page: 'Extension page',
skill: 'Skill',
knowledge_base: 'Knowledge base',
mcp_server: 'MCP server',
runtime: 'Runtime',
system: 'System',
resource: 'Resource',
},
outcomes: {
ok: 'Succeeded',
denied: 'Denied',
+17 -2
View File
@@ -2776,8 +2776,7 @@ const esES = {
},
operationTrace: {
title: 'Trazabilidad de operaciones',
description:
'Rastrea los cambios y las consultas de administradores y propietarios, registrando qué se cambió y a qué.',
description: 'Rastrea cambios y consultas de los administradores',
captureLevel: 'Nivel de captura',
retention: 'Retención',
retentionDays: 'Días de retención',
@@ -2809,6 +2808,8 @@ const esES = {
verifiedBadge: 'Verificado',
integrityFailedCount: '{{count}} hashes no coinciden',
chainFailedCount: '{{count}} enlaces rotos',
scanTruncated: 'Solo se verifican los últimos {{count}}',
emptyFiltered: 'Ningún registro requiere verificación',
levels: {
off: {
label: 'Sin trazabilidad',
@@ -2825,6 +2826,20 @@ const esES = {
mutation: 'Solo cambios',
read: 'Todo',
},
resourceTypes: {
workspace_settings: 'Ajustes del espacio de trabajo',
member: 'Miembro',
member_invitation: 'Invitación de miembro',
operation_log: 'Registro de operaciones',
plugin: 'Extensión',
plugin_page: 'Página de extensión',
skill: 'Habilidad',
knowledge_base: 'Base de conocimiento',
mcp_server: 'Servidor MCP',
runtime: 'Entorno de ejecución',
system: 'Sistema',
resource: 'Recurso',
},
outcomes: {
ok: 'Correcto',
denied: 'Denegado',
+17 -2
View File
@@ -2739,8 +2739,7 @@ const jaJP = {
},
operationTrace: {
title: '操作トレーサビリティ',
description:
'管理者とオーナーの変更・閲覧操作を追跡し、「何が何に変更されたか」を記録します。',
description: '管理者とオーナーの操作を追跡します。',
captureLevel: 'キャプチャレベル',
retention: '保持ポリシー',
retentionDays: '保持日数',
@@ -2772,6 +2771,8 @@ const jaJP = {
verifiedBadge: '検証済み',
integrityFailedCount: '{{count}} 件のハッシュ不一致',
chainFailedCount: '{{count}} 件のリンク断絶',
scanTruncated: '最新 {{count}} 件のみ検証',
emptyFiltered: '現在のフィルターでは検証が必要な記録はありません',
levels: {
off: {
label: 'トレース無効',
@@ -2788,6 +2789,20 @@ const jaJP = {
mutation: '変更のみ',
read: '最上位',
},
resourceTypes: {
workspace_settings: 'ワークスペース設定',
member: 'メンバー',
member_invitation: 'メンバー招待',
operation_log: '操作ログ',
plugin: '拡張機能',
plugin_page: '拡張ページ',
skill: 'スキル',
knowledge_base: 'ナレッジベース',
mcp_server: 'MCP サーバー',
runtime: 'ランタイム',
system: 'システム',
resource: 'リソース',
},
outcomes: {
ok: '成功',
denied: '拒否',
+17 -2
View File
@@ -2746,8 +2746,7 @@ const ruRU = {
},
operationTrace: {
title: 'Трассировка операций',
description:
'Отслеживание изменений и просмотров администраторов и владельцев с записью того, что и на что было изменено.',
description: 'Отслеживание изменений и просмотров администраторов',
captureLevel: 'Уровень записи',
retention: 'Хранение',
retentionDays: 'Дней хранения',
@@ -2779,6 +2778,8 @@ const ruRU = {
verifiedBadge: 'Проверено',
integrityFailedCount: 'Несовпадение хэша: {{count}}',
chainFailedCount: 'Разрыв связи: {{count}}',
scanTruncated: 'Проверены только последние {{count}}',
emptyFiltered: 'По текущим фильтрам нет записей, требующих проверки',
levels: {
off: {
label: 'Трассировка выключена',
@@ -2795,6 +2796,20 @@ const ruRU = {
mutation: 'Изменения',
read: 'Всё',
},
resourceTypes: {
workspace_settings: 'Настройки пространства',
member: 'Участник',
member_invitation: 'Приглашение участника',
operation_log: 'Журнал операций',
plugin: 'Расширение',
plugin_page: 'Страница расширения',
skill: 'Навык',
knowledge_base: 'База знаний',
mcp_server: 'MCP-сервер',
runtime: 'Среда выполнения',
system: 'Система',
resource: 'Ресурс',
},
outcomes: {
ok: 'Успех',
denied: 'Отказано',
+17 -2
View File
@@ -2673,8 +2673,7 @@ const thTH = {
},
operationTrace: {
title: 'การติดตามการดำเนินการ',
description:
'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ โดยบันทึกว่าอะไรเปลี่ยนเป็นอะไร',
description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ',
captureLevel: 'ระดับการบันทึก',
retention: 'นโยบายการเก็บรักษา',
retentionDays: 'จำนวนวันเก็บรักษา',
@@ -2706,6 +2705,8 @@ const thTH = {
verifiedBadge: 'ตรวจสอบผ่าน',
integrityFailedCount: 'แฮชไม่ตรงกัน {{count}} รายการ',
chainFailedCount: 'ลิงก์ขาด {{count}} รายการ',
scanTruncated: 'ตรวจสอบเฉพาะ {{count}} รายการล่าสุด',
emptyFiltered: 'ไม่มีรายการที่ต้องตรวจสอบภายใต้ตัวกรองปัจจุบัน',
levels: {
off: {
label: 'ปิดการติดตาม',
@@ -2722,6 +2723,20 @@ const thTH = {
mutation: 'เฉพาะแก้ไข',
read: 'สูงสุด',
},
resourceTypes: {
workspace_settings: 'การตั้งค่าพื้นที่ทำงาน',
member: 'สมาชิก',
member_invitation: 'คำเชิญสมาชิก',
operation_log: 'บันทึกการดำเนินการ',
plugin: 'ส่วนขยาย',
plugin_page: 'หน้าส่วนขยาย',
skill: 'ทักษะ',
knowledge_base: 'ฐานความรู้',
mcp_server: 'เซิร์ฟเวอร์ MCP',
runtime: 'รันไทม์',
system: 'ระบบ',
resource: 'ทรัพยากร',
},
outcomes: {
ok: 'สำเร็จ',
denied: 'ถูกปฏิเสธ',
+17 -2
View File
@@ -2708,8 +2708,7 @@ const viVN = {
},
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ì".',
description: 'Theo dõi thao tác sửa và xem của quản trị viên',
captureLevel: 'Mức ghi nhận',
retention: 'Chính sách lưu trữ',
retentionDays: 'Số ngày lưu',
@@ -2741,6 +2740,8 @@ const viVN = {
verifiedBadge: 'Đã xác minh',
integrityFailedCount: '{{count}} mã băm không khớp',
chainFailedCount: '{{count}} liên kết bị đứt',
scanTruncated: 'Chỉ xác minh {{count}} bản ghi mới nhất',
emptyFiltered: 'Không có bản ghi nào cần xác minh với bộ lọc hiện tại',
levels: {
off: {
label: 'Tắt truy vết',
@@ -2757,6 +2758,20 @@ const viVN = {
mutation: 'Chỉ sửa',
read: 'Cao nhất',
},
resourceTypes: {
workspace_settings: 'Cài đặt không gian làm việc',
member: 'Thành viên',
member_invitation: 'Lời mời thành viên',
operation_log: 'Nhật ký thao tác',
plugin: 'Tiện ích mở rộng',
plugin_page: 'Trang tiện ích',
skill: 'Kỹ năng',
knowledge_base: 'Cơ sở tri thức',
mcp_server: 'Máy chủ MCP',
runtime: 'Môi trường chạy',
system: 'Hệ thống',
resource: 'Tài nguyên',
},
outcomes: {
ok: 'Thành công',
denied: 'Bị từ chối',
+16
View File
@@ -2603,6 +2603,8 @@ const zhHans = {
verifiedBadge: '校验通过',
integrityFailedCount: '{{count}} 条哈希不匹配',
chainFailedCount: '{{count}} 条链路断裂',
scanTruncated: '仅校验最近 {{count}} 条',
emptyFiltered: '当前筛选下没有需要校验的记录',
levels: {
off: {
label: '关闭溯源',
@@ -2619,6 +2621,20 @@ const zhHans = {
mutation: '仅修改',
read: '最高级',
},
resourceTypes: {
workspace_settings: '工作区设置',
member: '成员',
member_invitation: '成员邀请',
operation_log: '操作日志',
plugin: '扩展',
plugin_page: '扩展页面',
skill: '技能',
knowledge_base: '知识库',
mcp_server: 'MCP 服务器',
runtime: '运行时',
system: '系统',
resource: '资源',
},
outcomes: {
ok: '成功',
denied: '被拒绝',
+16
View File
@@ -2604,6 +2604,8 @@ const zhHant = {
verifiedBadge: '驗證通過',
integrityFailedCount: '{{count}} 筆雜湊不符',
chainFailedCount: '{{count}} 筆鏈結中斷',
scanTruncated: '僅校驗最近 {{count}} 筆',
emptyFiltered: '目前篩選下沒有需要校驗的記錄',
levels: {
off: {
label: '關閉溯源',
@@ -2620,6 +2622,20 @@ const zhHant = {
mutation: '僅修改',
read: '最高級',
},
resourceTypes: {
workspace_settings: '工作區設定',
member: '成員',
member_invitation: '成員邀請',
operation_log: '操作日誌',
plugin: '擴充',
plugin_page: '擴充頁面',
skill: '技能',
knowledge_base: '知識庫',
mcp_server: 'MCP 伺服器',
runtime: '執行階段',
system: '系統',
resource: '資源',
},
outcomes: {
ok: '成功',
denied: '被拒絕',