mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 15:37:27 +08:00
Merge pull request #2579 from langbot-app/feat/workspace-operation-traceability
Feat/workspace operation traceability
This commit is contained in:
@@ -4,6 +4,7 @@ import abc
|
||||
import typing
|
||||
import enum
|
||||
import quart
|
||||
import time
|
||||
import traceback
|
||||
import inspect
|
||||
import uuid
|
||||
@@ -94,12 +95,23 @@ class RouterGroup(abc.ABC):
|
||||
rule: str,
|
||||
auth_type: AuthType = AuthType.USER_TOKEN,
|
||||
permission: Permission | str | None = None,
|
||||
operation_log_meta: dict[str, typing.Any] | None = None,
|
||||
**options: typing.Any,
|
||||
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
|
||||
"""Register a route"""
|
||||
"""Register a route.
|
||||
|
||||
``operation_log_meta`` optionally enriches the traceability record for
|
||||
this route with trusted server-side facts. Supported keys:
|
||||
|
||||
* ``resource_param``: handler kwarg holding the target resource id,
|
||||
* ``changes``: a pre-computed before/after diff to persist,
|
||||
* ``detail``: extra structured context (sensitive keys are redacted).
|
||||
"""
|
||||
|
||||
if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
|
||||
raise ValueError('Account-token routes cannot declare Workspace permissions')
|
||||
if operation_log_meta is not None and not isinstance(operation_log_meta, dict):
|
||||
raise ValueError('operation_log_meta must be a dict')
|
||||
|
||||
def decorator(f: RouteCallable) -> RouteCallable:
|
||||
nonlocal rule
|
||||
@@ -223,6 +235,7 @@ class RouterGroup(abc.ABC):
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
|
||||
observed_at = time.monotonic()
|
||||
try:
|
||||
if request_context is not None:
|
||||
diagnostics.workspace(request_context)
|
||||
@@ -245,11 +258,40 @@ class RouterGroup(abc.ABC):
|
||||
# runtimes, uploads, or streamed clients.
|
||||
# Services that need atomic writes open a UoW.
|
||||
async with persistence_mgr.tenant_scope(request_context.workspace_uuid):
|
||||
return await f(*args, **kwargs)
|
||||
return await f(*args, **kwargs)
|
||||
return await f(*args, **kwargs)
|
||||
response = await f(*args, **kwargs)
|
||||
else:
|
||||
response = await f(*args, **kwargs)
|
||||
else:
|
||||
response = await f(*args, **kwargs)
|
||||
|
||||
if request_context is not None:
|
||||
# Trace after the handler released its tenant scope so
|
||||
# auditing never extends a business transaction. The
|
||||
# call is a no-op (and costs no database round trip)
|
||||
# when tracing is off for this Workspace.
|
||||
await self._record_operation(
|
||||
request_context,
|
||||
rule,
|
||||
options,
|
||||
kwargs,
|
||||
observed_at,
|
||||
response,
|
||||
meta=operation_log_meta,
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e: # 自动 500
|
||||
if request_context is not None:
|
||||
await self._record_operation(
|
||||
request_context,
|
||||
rule,
|
||||
options,
|
||||
kwargs,
|
||||
observed_at,
|
||||
None,
|
||||
failure=e,
|
||||
meta=operation_log_meta,
|
||||
)
|
||||
if isinstance(e, CodexProviderError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, AuthorizationError):
|
||||
@@ -301,6 +343,127 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
return decorator
|
||||
|
||||
async def _record_operation(
|
||||
self,
|
||||
ctx: RequestContext,
|
||||
route: str,
|
||||
options: dict[str, typing.Any],
|
||||
kwargs: dict[str, typing.Any],
|
||||
observed_at: float,
|
||||
response: typing.Any,
|
||||
*,
|
||||
failure: BaseException | None = None,
|
||||
meta: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
"""Best-effort traceability for one finished request.
|
||||
|
||||
This runs after the handler released its tenant scope. It never raises:
|
||||
an audit failure must not turn a successful business response into a
|
||||
500. The service performs its own cheap level check before touching the
|
||||
database, so a disabled Workspace pays nothing at steady state.
|
||||
"""
|
||||
|
||||
# Cheap global gate: a single attribute read on the application object.
|
||||
# While no Workspace has enabled traceability this is ``False`` and the
|
||||
# isolated subsystem stays entirely off the hot request path, with no
|
||||
# import, service lookup, JSON parse or database round trip.
|
||||
if not getattr(self.ap, 'operation_trace_active', False):
|
||||
return
|
||||
|
||||
service = getattr(self.ap, 'workspace_settings_service', None)
|
||||
if service is None:
|
||||
return
|
||||
|
||||
try:
|
||||
method = str(quart.request.method or options.get('methods', ['GET'])[0])
|
||||
status_code = self._extract_status_code(response, failure)
|
||||
if failure is not None:
|
||||
outcome = 'denied' if isinstance(failure, AuthorizationError) else 'error'
|
||||
elif status_code >= 500:
|
||||
outcome = 'error'
|
||||
elif status_code >= 400:
|
||||
outcome = 'denied'
|
||||
else:
|
||||
outcome = 'ok'
|
||||
|
||||
resolved_meta = meta or {}
|
||||
resource_id = None
|
||||
resource_param = resolved_meta.get('resource_param')
|
||||
if isinstance(resource_param, str):
|
||||
candidate = kwargs.get(resource_param)
|
||||
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:
|
||||
# Identity resolution lives in the isolated traceability service
|
||||
# and is reached through the handle, so the Core controller never
|
||||
# imports that subsystem.
|
||||
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 = 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
|
||||
# request-local, so concurrent requests never share these values.
|
||||
# The stored summary is derived by the service from ``changes`` and
|
||||
# the classified action, so handlers only publish the diff itself.
|
||||
changes = resolved_meta.get('changes')
|
||||
detail = resolved_meta.get('detail')
|
||||
request_changes = getattr(quart.g, 'operation_log_changes', None)
|
||||
request_detail = getattr(quart.g, 'operation_log_detail', None)
|
||||
request_resource_id = getattr(quart.g, 'operation_log_resource_id', None)
|
||||
if request_changes:
|
||||
changes = request_changes
|
||||
if request_detail:
|
||||
detail = request_detail
|
||||
if request_resource_id:
|
||||
resource_id = str(request_resource_id)
|
||||
|
||||
await service.record_request(
|
||||
ctx,
|
||||
method=method,
|
||||
route=route,
|
||||
status_code=status_code,
|
||||
outcome=outcome,
|
||||
duration_ms=int(max(time.monotonic() - observed_at, 0.0) * 1000),
|
||||
resource_id=resource_id,
|
||||
changes=changes or None,
|
||||
detail=detail or None,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - auditing is best effort
|
||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||
logger.debug(f'Operation trace skipped for {route}: {exc}')
|
||||
|
||||
@staticmethod
|
||||
def _extract_status_code(response: typing.Any, failure: BaseException | None) -> int:
|
||||
"""Resolve the HTTP status of a handler result without consuming it."""
|
||||
|
||||
if failure is not None:
|
||||
status = getattr(failure, 'status_code', None)
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
return 500
|
||||
if isinstance(response, tuple) and len(response) >= 2 and isinstance(response[1], int):
|
||||
return response[1]
|
||||
status = getattr(response, 'status_code', None)
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
return 200
|
||||
|
||||
async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]:
|
||||
account: typing.Any = None
|
||||
resolver = getattr(self.ap.user_service, 'get_authenticated_account', None)
|
||||
|
||||
@@ -2,6 +2,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ......operation_trace import service as settings_service
|
||||
from ... import group
|
||||
|
||||
|
||||
@@ -67,13 +68,33 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
# Name the knowledge base and, on update, record which settings moved
|
||||
# instead of only "a knowledge base was changed".
|
||||
quart.g.operation_log_resource_id = knowledge_base_uuid
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.knowledge_service.get_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
include_secret=True,
|
||||
)
|
||||
except Exception:
|
||||
previous = None
|
||||
await self.ap.knowledge_service.update_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
json_data,
|
||||
)
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
json_data if isinstance(json_data, dict) else {},
|
||||
ignore=('uuid', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('knowledge_base_update')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
return self.success(data={'uuid': knowledge_base_uuid})
|
||||
await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid)
|
||||
return self.success({})
|
||||
|
||||
@@ -4,6 +4,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ......operation_trace import service as settings_service
|
||||
from ....service.secrets import redact_secrets
|
||||
from ....service.pipeline_run import run_pipeline
|
||||
from ... import group
|
||||
@@ -88,15 +89,30 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
quart.g.operation_log_resource_id = pipeline_uuid
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
|
||||
except Exception:
|
||||
previous = None
|
||||
try:
|
||||
await self.ap.pipeline_service.update_pipeline(
|
||||
request_context,
|
||||
pipeline_uuid,
|
||||
await quart.request.json,
|
||||
json_data,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
json_data if isinstance(json_data, dict) else {},
|
||||
ignore=('uuid', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
else:
|
||||
await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid)
|
||||
return self.success()
|
||||
@@ -159,7 +175,41 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
quart.g.operation_log_resource_id = pipeline_uuid
|
||||
json_data = await quart.request.json
|
||||
# Capture "what was bound before" so the trace answers "what changed
|
||||
# into what": without this the log could only say an extension was
|
||||
# configured, never which binding was added or removed.
|
||||
try:
|
||||
previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
|
||||
except Exception:
|
||||
previous = None
|
||||
# The request uses ``bound_*`` keys while the stored preferences use
|
||||
# the canonical names, so both sides are normalized before diffing.
|
||||
requested = {
|
||||
canonical: json_data[field]
|
||||
for field, canonical in {
|
||||
'bound_plugins': 'plugins',
|
||||
'bound_mcp_servers': 'mcp_servers',
|
||||
'bound_skills': 'skills',
|
||||
'bound_mcp_resources': 'mcp_resources',
|
||||
'enable_all_plugins': 'enable_all_plugins',
|
||||
'enable_all_mcp_servers': 'enable_all_mcp_servers',
|
||||
'enable_all_skills': 'enable_all_skills',
|
||||
'mcp_resource_agent_read_enabled': 'mcp_resource_agent_read_enabled',
|
||||
}.items()
|
||||
if field in json_data
|
||||
}
|
||||
if isinstance(previous, dict):
|
||||
current = {
|
||||
**normalize_extension_preferences(previous.get('extensions_preferences')),
|
||||
**requested,
|
||||
}
|
||||
else:
|
||||
current = requested
|
||||
changes = settings_service.changed_fields(current, requested)
|
||||
if changes:
|
||||
quart.g.operation_log_changes = changes
|
||||
try:
|
||||
validate_extension_preferences(
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ from .....core import taskmgr
|
||||
from .....entity.persistence import plugin as persistence_plugin
|
||||
from ...authz import Permission
|
||||
from ...context import ExecutionContext, RequestContext
|
||||
from .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .....plugin.github import validate_github_plugin_install_info
|
||||
@@ -526,15 +527,33 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
plugin_name,
|
||||
plugin,
|
||||
)
|
||||
try:
|
||||
incoming_config = await quart.request.json
|
||||
except Exception:
|
||||
incoming_config = None
|
||||
try:
|
||||
config = restore_plugin_secret_placeholders(
|
||||
await quart.request.json,
|
||||
incoming_config,
|
||||
current_config,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||
|
||||
# Record which plugin was reconfigured and which keys actually moved.
|
||||
# The diff is computed over the *requested* payload (secrets restored
|
||||
# from the stored config) so a masked ``***`` round-trip never shows
|
||||
# up as a change; sensitive keys are redacted by ``changed_fields``.
|
||||
config_changes = settings_service.changed_fields(
|
||||
current_config if isinstance(current_config, dict) else {},
|
||||
config if isinstance(config, dict) else {},
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('plugin_config')
|
||||
quart.g.operation_log_resource_id = f'{author}/{plugin_name}'
|
||||
quart.g.operation_log_changes = config_changes
|
||||
if rule is not None and config_changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, config_changes)
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
@@ -853,6 +872,11 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
owner = install_info['owner']
|
||||
repo = install_info['repo']
|
||||
release_tag = install_info['release_tag']
|
||||
# Name the installed extension in the trace. GitHub installs carry
|
||||
# owner/repo in the body, which the audit identity resolver can also
|
||||
# read, but publishing it here keeps the trace correct regardless of
|
||||
# how the request was encoded.
|
||||
quart.g.operation_log_resource_id = f'{owner}/{repo}'
|
||||
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
@@ -893,6 +917,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
plugin_author = data.get('plugin_author', '')
|
||||
plugin_name = data.get('plugin_name', '')
|
||||
quart.g.operation_log_resource_id = f'{plugin_author}/{plugin_name}'
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
@@ -934,6 +959,9 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
file_bytes = file.read()
|
||||
form = await quart.request.form
|
||||
administrator_force = form.get('administrator_force', '').strip().lower() == 'true'
|
||||
# A local upload is multipart with no JSON body, so the archive
|
||||
# filename is the only identity available for the trace.
|
||||
quart.g.operation_log_resource_id = file.filename or 'local plugin'
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
data = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import quart
|
||||
from langbot.pkg.provider.modelmgr import errors as provider_errors
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ......operation_trace import service as settings_service
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
@@ -85,14 +86,28 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.llm_model_service.get_llm_model(request_context, model_uuid)
|
||||
except Exception:
|
||||
previous = None
|
||||
try:
|
||||
await self.ap.llm_model_service.update_llm_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
json_data,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
json_data if isinstance(json_data, dict) else {},
|
||||
ignore=('uuid', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -2,6 +2,7 @@ import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ......operation_trace import service as settings_service
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
@@ -155,10 +156,29 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.provider_service.get_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=True,
|
||||
)
|
||||
except Exception:
|
||||
previous = None
|
||||
try:
|
||||
await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
# Record which provider was reconfigured and which fields moved;
|
||||
# credential-looking keys are redacted by ``changed_fields``.
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
json_data if isinstance(json_data, dict) else {},
|
||||
ignore=('uuid', 'created_at', 'updated_at', 'llm_count', 'embedding_count', 'rerank_count'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import unquote
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ......operation_trace import service as settings_service
|
||||
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
|
||||
from ... import group
|
||||
|
||||
@@ -62,6 +63,10 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
|
||||
if server_data is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
# Name the MCP server in the trace, and for an update record which
|
||||
# fields actually moved so the card answers "what changed" rather
|
||||
# than only "the MCP config was touched".
|
||||
quart.g.operation_log_resource_id = server_name
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
@@ -70,6 +75,15 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
return self.http_status(403, exc.code, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
changes = settings_service.changed_fields(
|
||||
server_data,
|
||||
data if isinstance(data, dict) else {},
|
||||
ignore=('uuid',),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('mcp_config')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
else:
|
||||
await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid'])
|
||||
return self.success()
|
||||
|
||||
@@ -7,6 +7,7 @@ from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -72,14 +73,31 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
# The skill name is the resource identity for both verbs; without it
|
||||
# the card could only say "a skill was updated".
|
||||
quart.g.operation_log_resource_id = skill_name
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError):
|
||||
previous = None
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
changes = settings_service.changed_fields(
|
||||
previous if isinstance(previous, dict) else {},
|
||||
data if isinstance(data, dict) else {},
|
||||
ignore=('files', 'package_root', 'created_at', 'updated_at'),
|
||||
)
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get('skill_view')
|
||||
quart.g.operation_log_changes = changes
|
||||
if rule is not None and changes:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
return self.success(data={'skill': skill})
|
||||
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||
return self.success()
|
||||
@@ -167,6 +185,9 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
if not asset_url.endswith('skill.md') and not data.get('release_tag'):
|
||||
return self.http_status(400, -1, 'Missing required field: release_tag')
|
||||
|
||||
# Name the installed skill in the trace (owner/repo from the body).
|
||||
quart.g.operation_log_resource_id = f'{data.get("owner", "")}/{data.get("repo", "")}'
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||
return self.success(data={'skills': skill})
|
||||
@@ -210,6 +231,9 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
form = await quart.request.form
|
||||
# A zip upload is multipart with no JSON body, so the filename is
|
||||
# the only identity available for the trace.
|
||||
quart.g.operation_log_resource_id = file.filename or 'skill'
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_zip_upload(
|
||||
|
||||
@@ -12,6 +12,7 @@ from .....entity.persistence.workspace import WorkspaceSource
|
||||
from .....workspace.collaboration import WorkspaceMemberView
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
from .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -218,6 +219,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
created = await self._run_in_workspace_uow(workspace_uuid, create_invitation)
|
||||
invite_rule = settings_service.ACTION_RULES_BY_ACTION['member_invite']
|
||||
invite_changes = [
|
||||
{
|
||||
'field': 'invitation',
|
||||
'before': None,
|
||||
'after': f'{created.invitation.normalized_email}:{created.invitation.role}',
|
||||
}
|
||||
]
|
||||
quart.g.operation_log_changes = invite_changes
|
||||
quart.g.operation_log_resource_id = created.invitation.uuid
|
||||
quart.g.operation_log_detail = {'role': created.invitation.role}
|
||||
quart.g.operation_log_summary = settings_service.build_summary(invite_rule, invite_changes)
|
||||
delivery_service = self._invitation_delivery_service()
|
||||
link = delivery_service.build_invitation_link(created.token)
|
||||
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
|
||||
@@ -266,6 +279,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
request_context: RequestContext,
|
||||
) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
|
||||
# Capture the previous role before mutating so the operation trace
|
||||
# can answer "what was changed into what" for this member.
|
||||
before_role = await self._member_role(workspace_uuid, account_uuid)
|
||||
|
||||
if quart.request.method == 'DELETE':
|
||||
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
|
||||
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
|
||||
@@ -276,6 +294,17 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
member = await self._run_in_workspace_uow(workspace_uuid, remove_member)
|
||||
await self._publish_member_trace(
|
||||
rule_action='member_remove',
|
||||
account_uuid=account_uuid,
|
||||
changes=[
|
||||
{
|
||||
'field': 'member',
|
||||
'before': f'{account_uuid}:{before_role or "unknown"}',
|
||||
'after': 'removed',
|
||||
}
|
||||
],
|
||||
)
|
||||
return self.success(data={'account_uuid': member.account_uuid})
|
||||
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
@@ -290,6 +319,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
|
||||
member = await self._run_in_workspace_uow(workspace_uuid, update_member_role)
|
||||
account = await self.ap.user_service.get_user_by_uuid(member.account_uuid)
|
||||
|
||||
role_changes = [
|
||||
{'field': 'role', 'before': before_role, 'after': member.role},
|
||||
]
|
||||
await self._publish_member_trace(
|
||||
rule_action='member_role_update',
|
||||
account_uuid=account_uuid,
|
||||
changes=role_changes,
|
||||
detail={
|
||||
'member_email': account.normalized_email if account is not None else None,
|
||||
},
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'member': _membership_payload(
|
||||
@@ -300,6 +341,41 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
async def _member_role(self, workspace_uuid: str, account_uuid: str) -> str | None:
|
||||
"""Return a member's current role without failing the request."""
|
||||
|
||||
try:
|
||||
members = await self.ap.workspace_collaboration_service.list_members(
|
||||
workspace_uuid, quart.g.workspace_membership
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
for view in members:
|
||||
if view.membership.account_uuid == account_uuid:
|
||||
return view.membership.role
|
||||
return None
|
||||
|
||||
async def _publish_member_trace(
|
||||
self,
|
||||
*,
|
||||
rule_action: str,
|
||||
account_uuid: str,
|
||||
changes: list[dict[str, typing.Any]],
|
||||
detail: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
"""Publish request-local trace facts consumed by the route wrapper.
|
||||
|
||||
``quart.g`` is request-local, so concurrent member operations never
|
||||
share these values.
|
||||
"""
|
||||
|
||||
rule = settings_service.ACTION_RULES_BY_ACTION.get(rule_action)
|
||||
quart.g.operation_log_changes = changes
|
||||
quart.g.operation_log_resource_id = account_uuid
|
||||
quart.g.operation_log_detail = detail or {}
|
||||
if rule is not None:
|
||||
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
|
||||
|
||||
@staticmethod
|
||||
def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None:
|
||||
if workspace_uuid != request_context.workspace_uuid:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import typing
|
||||
|
||||
@@ -27,6 +28,13 @@ importutil.import_modules_in_pkg(groups_pipelines)
|
||||
importutil.import_modules_in_pkg(groups_knowledge)
|
||||
importutil.import_modules_in_pkg(groups_resources)
|
||||
|
||||
# The traceability subsystem is self-contained and auto-registers its routes
|
||||
# through ``group.group_class``. Importing it here (instead of from within the
|
||||
# ``groups`` package) keeps the feature decoupled: the Core controller package
|
||||
# contains no code that depends on it, only this single bootstrap import. It
|
||||
# runs after the package scan so its routes join the same registry.
|
||||
importlib.import_module('langbot.pkg.operation_trace.routes')
|
||||
|
||||
|
||||
class BoundedJSONRequest(quart.Request):
|
||||
"""Parse bounded HTTP JSON bodies outside the shared event loop."""
|
||||
|
||||
@@ -25,6 +25,10 @@ Do not claim the application has been tested: this experiment has no chat-test o
|
||||
After creation/configuration show the returned resource URL so the user can open the normal editor,
|
||||
upload documents and use its existing debug chat. If an operation failed or its result is unknown,
|
||||
do not repeat a write automatically; explain the result and ask the user to inspect the resource.
|
||||
When the list_operation_logs tool is available (owner / admin only), use it to answer "who changed or
|
||||
viewed what" questions with the recorded before/after fields instead of guessing. Always query it with
|
||||
a narrow filter (search / resource_type / actor) rather than an unfiltered dump, and never repeat the
|
||||
same empty query: if a filter matched nothing, widen it once instead of re-asking.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,129 @@ class ListResources(Arguments):
|
||||
kind: Literal['models', 'embedding_models', 'pipelines', 'knowledge_bases', 'knowledge_engines']
|
||||
|
||||
|
||||
#: Canonical resource types, mirrored from the trace service action table. The
|
||||
#: enum lets the model pick a value that exists instead of guessing a string
|
||||
#: that matches nothing.
|
||||
_RESOURCE_TYPES = (
|
||||
'bot',
|
||||
'adapter',
|
||||
'model_provider',
|
||||
'llm_model',
|
||||
'pipeline',
|
||||
'user',
|
||||
'workspace',
|
||||
'monitoring',
|
||||
'webhook',
|
||||
'api_key',
|
||||
'workspace_settings',
|
||||
'member',
|
||||
'member_invitation',
|
||||
'operation_log',
|
||||
'plugin',
|
||||
'plugin_page',
|
||||
'skill',
|
||||
'knowledge_base',
|
||||
'mcp_server',
|
||||
'runtime',
|
||||
'system',
|
||||
'resource',
|
||||
)
|
||||
|
||||
|
||||
class ListOperationLogs(Arguments):
|
||||
"""Read the Workspace operation trace (owner / admin only)."""
|
||||
|
||||
#: Optional free-text filter is mapped to a substring match so the caller
|
||||
#: can name a plugin, an account or a verb ("install") without knowing the
|
||||
#: stored action key. Prefer this over ``action`` when unsure of the key.
|
||||
search: str | None = Field(default=None, max_length=100)
|
||||
#: Exact stored action key, e.g. ``plugin_install`` (see the action list in
|
||||
#: the tool description). Only use it when the exact key is known.
|
||||
action: str | None = Field(default=None, max_length=64)
|
||||
resource_type: str | None = Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description='Optional resource type filter. Valid values: ' + ', '.join(_RESOURCE_TYPES) + '.',
|
||||
)
|
||||
actor: str | None = Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description='Optional actor filter: an account UUID or part of the actor name.',
|
||||
)
|
||||
limit: int = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
le=50,
|
||||
description='Maximum records to return. Keep it small (10-20) for a targeted answer.',
|
||||
)
|
||||
|
||||
|
||||
#: Forensics fields the settings panel needs but an answer does not; dropping
|
||||
#: them keeps a page of records far below the assistant's per-result budget.
|
||||
_COMPACT_CHANGE_FIELDS = 6
|
||||
_COMPACT_CHANGE_CHARS = 160
|
||||
|
||||
|
||||
def _brief(value, limit: int = _COMPACT_CHANGE_CHARS):
|
||||
"""Render a diff value as a bounded string (mirrors the trace service)."""
|
||||
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = value if isinstance(value, str) else str(value)
|
||||
return text if len(text) <= limit else text[: max(limit - 1, 0)] + '…'
|
||||
|
||||
|
||||
def _compact_operation_records(result: dict) -> dict:
|
||||
"""Project operation records onto the fields a "who did what" answer needs.
|
||||
|
||||
``query_logs`` returns the full panel payload -- hash chain, user agent,
|
||||
payload details -- which is roughly a kilobyte per row. A whole page of
|
||||
those overflows the assistant's per-result budget and arrives as a raw
|
||||
truncated preview the model cannot read, which is why an answer can end up
|
||||
claiming the data is unavailable. Only the actor, the action, the target,
|
||||
the timestamp and the verification verdicts are kept here; the heavy
|
||||
forensic columns stay available through the settings panel export.
|
||||
"""
|
||||
|
||||
def record(row: dict) -> dict:
|
||||
changes = [
|
||||
{
|
||||
'field': change.get('field'),
|
||||
'before': _brief(change.get('before')),
|
||||
'after': _brief(change.get('after')),
|
||||
}
|
||||
for change in (row.get('changes') or [])[:_COMPACT_CHANGE_FIELDS]
|
||||
]
|
||||
return {
|
||||
'id': row.get('id'),
|
||||
'created_at': row.get('created_at'),
|
||||
'actor': row.get('actor_name') or row.get('actor_account_uuid'),
|
||||
'actor_role': row.get('actor_role'),
|
||||
'action': row.get('action'),
|
||||
'action_i18n_key': row.get('action_i18n_key'),
|
||||
'resource_type': row.get('resource_type'),
|
||||
'resource_id': row.get('resource_id'),
|
||||
'summary': row.get('summary'),
|
||||
'changes': changes,
|
||||
'outcome': row.get('outcome'),
|
||||
'http_method': row.get('http_method'),
|
||||
'route': row.get('route'),
|
||||
'integrity_ok': row.get('integrity_ok'),
|
||||
'chain_ok': row.get('chain_ok'),
|
||||
'tampered': row.get('tampered'),
|
||||
}
|
||||
|
||||
records = result.get('records') or []
|
||||
return {
|
||||
'total': result.get('total', len(records)),
|
||||
'returned': len(records),
|
||||
'tampered_count': result.get('tampered_count', 0),
|
||||
'integrity_failed_count': result.get('integrity_failed_count', 0),
|
||||
'chain_failed_count': result.get('chain_failed_count', 0),
|
||||
'records': [record(row) for row in records],
|
||||
}
|
||||
|
||||
|
||||
class PipelineID(Arguments):
|
||||
pipeline_uuid: UUID
|
||||
|
||||
@@ -45,29 +168,72 @@ class CreateKnowledgeBase(CreatePipeline):
|
||||
retrieval_settings: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
#: Each entry is ``(schema, description, writes, extra_permission)``. ``writes``
|
||||
#: selects resource.manage vs resource.view; ``extra_permission`` narrows a tool
|
||||
#: further (e.g. the audit surface needs ``audit.view``, which only the owner
|
||||
#: and admin hold) and both are enforced identically when listing and calling.
|
||||
TOOLS = {
|
||||
'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False),
|
||||
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False),
|
||||
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False),
|
||||
'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True),
|
||||
'list_resources': (
|
||||
ListResources,
|
||||
'List existing Workspace resources. Discover IDs before using them.',
|
||||
False,
|
||||
None,
|
||||
),
|
||||
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None),
|
||||
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None),
|
||||
'list_operation_logs': (
|
||||
ListOperationLogs,
|
||||
'Search the Workspace operation trace (who changed or viewed what, with before/after fields). '
|
||||
'Owner and admin only. Always pass a narrow filter instead of dumping everything: use "search" '
|
||||
'for a substring such as a plugin name, an account or a verb (e.g. search="LangTARS" or '
|
||||
'search="install"); use the exact "action" key only when known - valid keys include '
|
||||
'plugin_install, plugin_uninstall, plugin_config, plugin_view, plugin_upgrade, skill_install, '
|
||||
'skill_uninstall, create, update, delete, view, export, execute, debug, publish, member_invite, '
|
||||
'member_role_update, member_remove, member_view, settings_update, settings_view, audit_log_view, '
|
||||
'pipeline_extensions_update, knowledge_base_update, knowledge_base_delete, mcp_config, mcp_delete. '
|
||||
'Combine resource_type and actor to answer "who installed X".',
|
||||
False,
|
||||
Permission.AUDIT_VIEW,
|
||||
),
|
||||
'create_pipeline': (
|
||||
CreatePipeline,
|
||||
'Create an unconnected Pipeline draft. Requires user confirmation.',
|
||||
True,
|
||||
None,
|
||||
),
|
||||
'configure_pipeline': (
|
||||
ConfigurePipeline,
|
||||
'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.',
|
||||
True,
|
||||
None,
|
||||
),
|
||||
'create_knowledge_base': (
|
||||
CreateKnowledgeBase,
|
||||
'Create a knowledge base using an installed engine. Read its schema first. Requires confirmation.',
|
||||
True,
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _required_permission(writes: bool, extra: Permission | None) -> Permission:
|
||||
"""Return the permission a tool needs beyond the base resource verbs."""
|
||||
|
||||
if extra is not None:
|
||||
return extra
|
||||
return Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW
|
||||
|
||||
|
||||
def _authorized(context, name: str) -> bool:
|
||||
_schema, _description, writes, extra = TOOLS[name]
|
||||
return _required_permission(writes, extra).value in context.workspace.permissions
|
||||
|
||||
|
||||
def validate_call(context, name: str, arguments: dict) -> Arguments:
|
||||
if name not in TOOLS:
|
||||
raise ValueError('Unknown management tool')
|
||||
schema, _, writes = TOOLS[name]
|
||||
require_permission(context, Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW)
|
||||
schema, _, writes, extra = TOOLS[name]
|
||||
require_permission(context, _required_permission(writes, extra))
|
||||
return schema.model_validate(arguments)
|
||||
|
||||
|
||||
@@ -80,8 +246,8 @@ def tool_definitions(context) -> list[LLMTool]:
|
||||
parameters=schema.model_json_schema(),
|
||||
func=execute_tool,
|
||||
)
|
||||
for name, (schema, description, writes) in TOOLS.items()
|
||||
if not writes or Permission.RESOURCE_MANAGE in context.workspace.permissions
|
||||
for name, (schema, description, _writes, _extra) in TOOLS.items()
|
||||
if _authorized(context, name)
|
||||
]
|
||||
|
||||
|
||||
@@ -101,6 +267,23 @@ async def execute_tool(ap, context, name: str, arguments: dict):
|
||||
fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id')
|
||||
resources = [{key: item[key] for key in fields if key in item} for item in resources]
|
||||
return {'total': len(resources), 'items': redact_secrets(resources)}
|
||||
if name == 'list_operation_logs':
|
||||
# Delegates to the operation-trace service, so the assistant shows the
|
||||
# same records and tamper verification as the settings panel, then
|
||||
# compacts them so a full page still fits the per-result budget.
|
||||
result = await ap.workspace_settings_service.query_logs(
|
||||
context.workspace_uuid,
|
||||
limit=args['limit'],
|
||||
search=args['search'],
|
||||
action=args['action'],
|
||||
resource_type=args['resource_type'],
|
||||
actor_account_uuid=args['actor'],
|
||||
)
|
||||
compact = _compact_operation_records(result)
|
||||
# Echo the applied filters so the model can see why a result is empty
|
||||
# and widen a filter instead of repeating an empty targeted query.
|
||||
compact['query'] = {key: args[key] for key in ('search', 'action', 'resource_type', 'actor') if args.get(key)}
|
||||
return compact
|
||||
if name == 'get_pipeline':
|
||||
return await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid'])
|
||||
if name == 'get_knowledge_schema':
|
||||
|
||||
@@ -36,6 +36,7 @@ from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
from ..operation_trace import service as workspace_settings_service
|
||||
from ..api.http.service import skill as skill_service
|
||||
from ..api.http.service import maintenance as maintenance_service
|
||||
from ..discover import engine as discover_engine
|
||||
@@ -198,6 +199,8 @@ class Application:
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
workspace_settings_service: workspace_settings_service.WorkspaceSettingsService = None
|
||||
|
||||
skill_service: skill_service.SkillService = None
|
||||
|
||||
skill_mgr: skill_mgr.SkillManager = None
|
||||
@@ -434,6 +437,22 @@ class Application:
|
||||
* 3600
|
||||
)
|
||||
|
||||
# Operation-log retention piggybacks on the shared maintenance loop
|
||||
# instead of starting another long-lived task, so enabling
|
||||
# traceability adds no scheduling cost of its own.
|
||||
operation_log_cleanup_cfg = self.instance_config.data.get('operation_log', {}).get('auto_cleanup', {})
|
||||
operation_log_enabled = (
|
||||
operation_log_cleanup_cfg.get('enabled', True) and self.workspace_settings_service is not None
|
||||
)
|
||||
operation_log_interval_seconds = (
|
||||
self._get_positive_float_config(
|
||||
operation_log_cleanup_cfg.get('check_interval_hours', 1),
|
||||
default=1,
|
||||
name='operation_log.auto_cleanup.check_interval_hours',
|
||||
)
|
||||
* 3600
|
||||
)
|
||||
|
||||
maintenance_intervals: dict[str, float] = {}
|
||||
if monitoring_enabled:
|
||||
maintenance_intervals['monitoring'] = monitoring_interval_seconds
|
||||
@@ -441,6 +460,8 @@ class Application:
|
||||
maintenance_intervals['storage'] = storage_interval_seconds
|
||||
if self.workspace_collaboration_service is not None:
|
||||
maintenance_intervals['invitations'] = 3600.0
|
||||
if operation_log_enabled:
|
||||
maintenance_intervals['operation_logs'] = operation_log_interval_seconds
|
||||
|
||||
if maintenance_intervals:
|
||||
|
||||
@@ -506,6 +527,28 @@ class Application:
|
||||
self.logger.warning(
|
||||
f'Storage maintenance failed for Workspace {context.workspace_uuid}: {exc}'
|
||||
)
|
||||
if 'operation_logs' in due:
|
||||
# Retention is per Workspace, so this must run
|
||||
# inside the binding loop; left outside it only
|
||||
# the last discovered Workspace would ever be
|
||||
# pruned while the others grew unbounded.
|
||||
try:
|
||||
report = await self.workspace_settings_service.prune(
|
||||
context.workspace_uuid,
|
||||
)
|
||||
removed = report['expired'] + report['trimmed']
|
||||
if removed > 0:
|
||||
self.logger.info(
|
||||
f'Operation log auto-cleanup for Workspace '
|
||||
f'{context.workspace_uuid}: removed {removed} records'
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.logger.warning(
|
||||
f'Operation log auto-cleanup failed for '
|
||||
f'Workspace {context.workspace_uuid}: {exc}'
|
||||
)
|
||||
if 'invitations' in due:
|
||||
try:
|
||||
await self.workspace_collaboration_service.cleanup_expired_invitations(
|
||||
|
||||
@@ -29,6 +29,7 @@ from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...operation_trace import service as operation_trace_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
from ...skill import manager as skill_mgr
|
||||
from ...api.http.service import maintenance as maintenance_service
|
||||
@@ -127,6 +128,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
mcp_service_inst = mcp_service.MCPService(ap)
|
||||
ap.mcp_service = mcp_service_inst
|
||||
|
||||
workspace_settings_service_inst = operation_trace_service.WorkspaceSettingsService(ap)
|
||||
ap.workspace_settings_service = workspace_settings_service_inst
|
||||
|
||||
apikey_service_inst = apikey_service.ApiKeyService(ap)
|
||||
ap.apikey_service = apikey_service_inst
|
||||
|
||||
@@ -158,6 +162,12 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
# Persistence is ready now. Open the cheap global traceability gate if
|
||||
# any Workspace already has tracing enabled, so a restart resumes
|
||||
# recording without ever checking the database on the request path.
|
||||
if ap.workspace_settings_service is not None:
|
||||
await ap.workspace_settings_service.prime_global_flag()
|
||||
|
||||
if deployment.multi_workspace_enabled:
|
||||
directory_projection_service = DirectoryProjectionService(
|
||||
ap,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Workspace operation log entity for member action traceability.
|
||||
|
||||
This table is the append-only fact source for "who did what to which
|
||||
resource" inside one Workspace. It is deliberately separate from
|
||||
:mod:`event_log` (Runner/runtime facts) and from monitoring tables
|
||||
(LLM/tool telemetry) so that administrative traceability can be enabled,
|
||||
queried and pruned without touching the hot runtime path.
|
||||
|
||||
Design constraints
|
||||
------------------
|
||||
* One row per recorded operation; no updates after insert (append-only).
|
||||
* ``changes`` holds the before/after diff as a compact JSON string so the
|
||||
log answers the question "what was changed into what".
|
||||
* Rows are tenant-owned: every row carries ``workspace_uuid`` and is
|
||||
covered by the shared Row Level Security contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
# Operation levels, ordered by increasing capture scope. They live in the
|
||||
# entity module so both the persistence layer and the HTTP layer can import
|
||||
# one single source of truth without a circular dependency.
|
||||
OPERATION_LEVEL_NONE = 0
|
||||
"""Record nothing (tracing disabled for the Workspace)."""
|
||||
|
||||
OPERATION_LEVEL_MUTATION = 1
|
||||
"""Record create / update / delete / role changes only."""
|
||||
|
||||
OPERATION_LEVEL_READ = 2
|
||||
"""Additionally record every read / view / list operation."""
|
||||
|
||||
OPERATION_LEVEL_NAMES = {
|
||||
OPERATION_LEVEL_NONE: 'off',
|
||||
OPERATION_LEVEL_MUTATION: 'mutation',
|
||||
OPERATION_LEVEL_READ: 'read',
|
||||
}
|
||||
|
||||
|
||||
class WorkspaceOperationLog(Base):
|
||||
"""One traceable administrative operation inside a Workspace."""
|
||||
|
||||
__tablename__ = 'workspace_operation_logs'
|
||||
|
||||
id = sqlalchemy.Column(
|
||||
sqlalchemy.BigInteger().with_variant(sqlalchemy.Integer, 'sqlite'), primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
"""Owning Workspace; also the tenant isolation key."""
|
||||
|
||||
# --- Actor: who performed the operation -----------------------------
|
||||
actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
|
||||
"""Account UUID of the actor, when the principal is an Account."""
|
||||
|
||||
actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Human readable actor identifier captured at write time."""
|
||||
|
||||
actor_role = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
|
||||
"""Workspace role the actor held while performing the operation."""
|
||||
|
||||
principal_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
|
||||
"""Principal kind (account, api_key, support_admin, system)."""
|
||||
|
||||
api_key_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""API key UUID when the operation was driven by an API key."""
|
||||
|
||||
auth_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
|
||||
"""Authentication mode used for the request."""
|
||||
|
||||
# --- Request correlation --------------------------------------------
|
||||
request_id = sqlalchemy.Column(sqlalchemy.String(128), nullable=True, index=True)
|
||||
"""Stable request id shared with logs and error responses."""
|
||||
|
||||
http_method = sqlalchemy.Column(sqlalchemy.String(12), nullable=True)
|
||||
"""HTTP verb of the traced request."""
|
||||
|
||||
route = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, index=True)
|
||||
"""Registered Core route identity (never a raw user-supplied URL)."""
|
||||
|
||||
# --- What happened ---------------------------------------------------
|
||||
action = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True)
|
||||
"""Normalized action verb (create, update, delete, view, ...)."""
|
||||
|
||||
resource_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True)
|
||||
"""Resource family the operation targeted (member, resource, ...)."""
|
||||
|
||||
resource_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Identifier of the targeted resource, when known."""
|
||||
|
||||
level = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='1', index=True)
|
||||
"""Capture level that produced this row (1 = mutation, 2 = read)."""
|
||||
|
||||
outcome = sqlalchemy.Column(sqlalchemy.String(16), nullable=False, server_default='ok')
|
||||
"""Result bucket: ok, denied or error."""
|
||||
|
||||
status_code = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
||||
"""HTTP status code when the operation surfaced one."""
|
||||
|
||||
# --- Payloads --------------------------------------------------------
|
||||
summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Short human readable description of the operation."""
|
||||
|
||||
changes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Before/after diff as a JSON string: what was changed into what."""
|
||||
|
||||
detail = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Additional structured context as a JSON string."""
|
||||
|
||||
# --- Tamper evidence (append-only hash chain) ------------------------
|
||||
record_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
"""Keyed hash over this row's canonical content.
|
||||
|
||||
Recomputed on read: a mismatch marks the card as tampered.
|
||||
"""
|
||||
|
||||
prev_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
"""Previous record's ``record_hash`` in the same Workspace.
|
||||
|
||||
Linking rows this way makes silent edits or deletions detectable.
|
||||
"""
|
||||
|
||||
dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
"""Hash used to collapse repeated read observations into one row."""
|
||||
|
||||
# --- Client fingerprint ---------------------------------------------
|
||||
client_ip = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
"""Best-effort client address."""
|
||||
|
||||
user_agent = sqlalchemy.Column(sqlalchemy.String(512), nullable=True)
|
||||
"""Client user agent, truncated before insert."""
|
||||
|
||||
duration_ms = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='0')
|
||||
"""Handler wall-clock duration in milliseconds."""
|
||||
|
||||
created_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
index=True,
|
||||
)
|
||||
"""When the operation was recorded."""
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index(
|
||||
'ix_workspace_operation_logs_workspace_created',
|
||||
'workspace_uuid',
|
||||
'created_at',
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_workspace_operation_logs_workspace_resource',
|
||||
'workspace_uuid',
|
||||
'resource_type',
|
||||
'created_at',
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_workspace_operation_logs_workspace_dedupe',
|
||||
'workspace_uuid',
|
||||
'dedupe_key',
|
||||
'created_at',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
'level IN (0, 1, 2)',
|
||||
name='ck_workspace_operation_logs_level',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"outcome IN ('ok', 'denied', 'error')",
|
||||
name='ck_workspace_operation_logs_outcome',
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Isolated operation-traceability subsystem.
|
||||
|
||||
The "成员操作日志溯源" (member operation traceability) feature lives entirely in
|
||||
this package so it can be decoupled from the hot request path:
|
||||
|
||||
* :mod:`service` owns the capture levels, action catalog, append-only writer,
|
||||
tamper-evidence chain, dedupe window and read/prune APIs.
|
||||
* :mod:`routes` exposes the governance / operation-log HTTP surface and is
|
||||
auto-discovered by the HTTP controller's package scan, so nothing in the
|
||||
Core controller package needs to import it.
|
||||
|
||||
How the decoupling keeps tracing off the hot path
|
||||
-------------------------------------------------
|
||||
Tracing is opt-in per Workspace. The Core route wrapper asks
|
||||
:func:`operation_trace.service.is_globally_enabled` (a single module-level
|
||||
boolean, no I/O) before it queues any trace work. While no Workspace has ever
|
||||
turned tracing on, that flag is ``False`` and the wrapper performs zero
|
||||
database round trips. The first time an operator enables tracing the service
|
||||
flips the flag, and the per-Workspace level check then decides whether a given
|
||||
request is recorded. Recording itself always runs *after* the handler released
|
||||
its tenant scope, so auditing never extends a business transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ['service', 'routes']
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Workspace governance settings routes.
|
||||
|
||||
Exposes the "成员操作日志溯源" (member operation traceability) surface:
|
||||
|
||||
* ``GET /api/v1/settings/governance`` read level + template table
|
||||
* ``PUT /api/v1/settings/governance`` change level / retention
|
||||
* ``GET /api/v1/settings/operation-logs`` page through records
|
||||
* ``GET /api/v1/settings/operation-logs/filters`` available filter values
|
||||
* ``GET /api/v1/settings/operation-logs/export`` download the filtered CSV
|
||||
|
||||
Operation records are append-only and there is no route to delete them on
|
||||
demand: retention is the only deletion path, and it runs automatically when
|
||||
the governance settings change and from the maintenance loop.
|
||||
|
||||
Every route requires ``audit.view``, which is granted to the Workspace owner
|
||||
and admin only. Write routes additionally require an owning/admin role so a
|
||||
future permission grant cannot silently widen who may reconfigure tracing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import typing
|
||||
|
||||
import quart
|
||||
|
||||
from ..api.http.authz import Permission
|
||||
from ..api.http.context import RequestContext
|
||||
from ..api.http.controller import group
|
||||
from . import service as settings_service
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime.datetime | None:
|
||||
"""Parse an ISO-8601 query parameter, tolerating a trailing ``Z``."""
|
||||
|
||||
if not value:
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
if candidate.endswith('Z'):
|
||||
candidate = candidate[:-1] + '+00:00'
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
# Stored timestamps are naive UTC; normalise aware inputs before comparing.
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_int(value: str | None, default: int) -> int:
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _csv_cell(value: typing.Any) -> str:
|
||||
"""Render one CSV cell with the standard quoting rules."""
|
||||
|
||||
if value is None:
|
||||
return ''
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
return '"' + text.replace('"', '""') + '"'
|
||||
|
||||
|
||||
@group.group_class('settings', '/api/v1/settings')
|
||||
class SettingsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route(
|
||||
'/governance',
|
||||
methods=['GET', 'PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
service = self.ap.workspace_settings_service
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data=await service.describe_governance(request_context.workspace_uuid))
|
||||
|
||||
if not settings_service.role_may_configure(request_context.workspace.role):
|
||||
return self.http_status(
|
||||
403,
|
||||
'permission_denied',
|
||||
'Only Workspace owners and admins can change tracing settings',
|
||||
)
|
||||
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
# The previous level is captured before the write so the trace can
|
||||
# answer "what was changed into what" for the governance change
|
||||
# itself, not only for the resources it protects.
|
||||
before = await service.describe_governance(request_context.workspace_uuid)
|
||||
updated = await service.set_operation_level(
|
||||
request_context.workspace_uuid,
|
||||
data.get('level', before['configured_level']),
|
||||
retention_days=data.get('retention_days'),
|
||||
max_rows=data.get('max_rows'),
|
||||
dedupe_window_seconds=data.get('dedupe_window_seconds'),
|
||||
)
|
||||
|
||||
account_name = None
|
||||
if request_context.account_uuid and self.ap.user_service is not None:
|
||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||
account_name = account.user if account is not None else None
|
||||
|
||||
changes = settings_service.changed_fields(
|
||||
{
|
||||
'configured_level': before['configured_level'],
|
||||
'retention_days': before['retention_days'],
|
||||
'max_rows': before['max_rows'],
|
||||
'dedupe_window_seconds': before['dedupe_window_seconds'],
|
||||
},
|
||||
{
|
||||
'configured_level': updated['configured_level'],
|
||||
'retention_days': updated['retention_days'],
|
||||
'max_rows': updated['max_rows'],
|
||||
'dedupe_window_seconds': updated['dedupe_window_seconds'],
|
||||
},
|
||||
)
|
||||
if changes:
|
||||
await service.record(
|
||||
request_context.workspace_uuid,
|
||||
rule=settings_service.ACTION_RULES_BY_ACTION['settings_update'],
|
||||
level=max(
|
||||
await service.effective_level(
|
||||
request_context.workspace_uuid,
|
||||
request_context.workspace.role,
|
||||
),
|
||||
settings_service.OPERATION_LEVEL_MUTATION,
|
||||
),
|
||||
actor_account_uuid=request_context.account_uuid,
|
||||
actor_name=account_name,
|
||||
actor_role=request_context.workspace.role,
|
||||
principal_type=request_context.principal.principal_type.value,
|
||||
request_id=request_context.request_id,
|
||||
http_method=quart.request.method,
|
||||
route='/api/v1/settings/governance',
|
||||
outcome='ok',
|
||||
summary=settings_service.build_summary(
|
||||
settings_service.ACTION_RULES_BY_ACTION['settings_update'], changes
|
||||
),
|
||||
changes=changes,
|
||||
)
|
||||
return self.success(data=updated)
|
||||
|
||||
@self.route(
|
||||
'/operation-logs',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
# Read-only by design: operation records are immutable and cannot
|
||||
# be deleted on demand. Only the retention policy may prune them.
|
||||
service = self.ap.workspace_settings_service
|
||||
|
||||
args = quart.request.args
|
||||
level_raw = args.get('level')
|
||||
result = await service.query_logs(
|
||||
request_context.workspace_uuid,
|
||||
limit=_parse_int(args.get('limit'), settings_service.DEFAULT_PAGE_SIZE),
|
||||
offset=_parse_int(args.get('offset'), 0),
|
||||
action=args.get('action') or None,
|
||||
resource_type=args.get('resource_type') or None,
|
||||
actor_account_uuid=args.get('actor') or None,
|
||||
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)
|
||||
|
||||
@self.route(
|
||||
'/operation-logs/filters',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
service = self.ap.workspace_settings_service
|
||||
options = await service.list_filter_options(request_context.workspace_uuid)
|
||||
options['governance'] = await service.describe_governance(request_context.workspace_uuid)
|
||||
return self.success(data=options)
|
||||
|
||||
@self.route(
|
||||
'/operation-logs/export',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
"""Download the filtered records as a CSV attachment.
|
||||
|
||||
The export is read-only and shares the exact filters of the panel,
|
||||
so what an operator sees is what the artifact contains.
|
||||
"""
|
||||
|
||||
args = quart.request.args
|
||||
level_raw = args.get('level')
|
||||
service = self.ap.workspace_settings_service
|
||||
document = await service.export_logs(
|
||||
request_context.workspace_uuid,
|
||||
action=args.get('action') or None,
|
||||
resource_type=args.get('resource_type') or None,
|
||||
actor_account_uuid=args.get('actor') or None,
|
||||
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')),
|
||||
)
|
||||
|
||||
columns = (
|
||||
'id',
|
||||
'created_at',
|
||||
'action',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'actor_account_uuid',
|
||||
'actor_name',
|
||||
'actor_role',
|
||||
'principal_type',
|
||||
'http_method',
|
||||
'route',
|
||||
'outcome',
|
||||
'status_code',
|
||||
'level_name',
|
||||
'integrity_ok',
|
||||
'chain_ok',
|
||||
'tampered',
|
||||
'record_hash',
|
||||
'prev_hash',
|
||||
'duration_ms',
|
||||
'summary',
|
||||
'changes',
|
||||
'detail',
|
||||
)
|
||||
lines = [','.join(columns)]
|
||||
for record in document['records']:
|
||||
lines.append(','.join(_csv_cell(record.get(column)) for column in columns))
|
||||
|
||||
# Prepend a UTF-8 BOM so spreadsheet tools detect the encoding.
|
||||
body = '\ufeff' + '\r\n'.join(lines) + '\r\n'
|
||||
filename = f'operation-logs-{document["exported_at"].replace(":", "")}.csv'
|
||||
response = quart.Response(body, mimetype='text/csv', content_type='text/csv; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ from langbot.pkg.entity.persistence import (
|
||||
metadata, # noqa: F401
|
||||
model, # noqa: F401
|
||||
monitoring, # noqa: F401
|
||||
operation_log, # noqa: F401
|
||||
pipeline, # noqa: F401
|
||||
plugin, # noqa: F401
|
||||
rag, # noqa: F401
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""add workspace operation traceability table
|
||||
|
||||
Revision ID: 0033_operation_traceability
|
||||
Revises: 0032_cert_artifact_digest
|
||||
Create Date: 2026-09-25
|
||||
|
||||
The table is append-only and tenant-owned. Fresh installs already receive it
|
||||
from SQLAlchemy ``create_all``; this revision makes the change safe for
|
||||
existing databases and mirrors the shared Row Level Security contract so the
|
||||
Cloud release validation can enforce it.
|
||||
|
||||
It carries the full shape in one step: the base traceability columns, the
|
||||
tamper-evidence hash chain (``record_hash`` / ``prev_hash``) and the
|
||||
``dedupe_key`` used to collapse repeated read observations. The single
|
||||
composite index ``ix_workspace_operation_logs_workspace_dedupe`` covers the
|
||||
dedupe lookup, so no standalone ``dedupe_key`` index exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '0033_operation_traceability'
|
||||
down_revision = '0032_cert_artifact_digest'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_TABLE_NAME = 'workspace_operation_logs'
|
||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
_TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
|
||||
#: Every index the ORM declares for this table. Kept in one place so the
|
||||
#: migration and ``Base.metadata.create_all`` stay in lockstep.
|
||||
_INDEXES: tuple[tuple[str, list[str]], ...] = (
|
||||
('ix_workspace_operation_logs_workspace_created', ['workspace_uuid', 'created_at']),
|
||||
('ix_workspace_operation_logs_workspace_resource', ['workspace_uuid', 'resource_type', 'created_at']),
|
||||
('ix_workspace_operation_logs_workspace_dedupe', ['workspace_uuid', 'dedupe_key', 'created_at']),
|
||||
('ix_workspace_operation_logs_workspace_uuid', ['workspace_uuid']),
|
||||
('ix_workspace_operation_logs_created_at', ['created_at']),
|
||||
('ix_workspace_operation_logs_action', ['action']),
|
||||
('ix_workspace_operation_logs_resource_type', ['resource_type']),
|
||||
('ix_workspace_operation_logs_level', ['level']),
|
||||
('ix_workspace_operation_logs_actor_account_uuid', ['actor_account_uuid']),
|
||||
('ix_workspace_operation_logs_request_id', ['request_id']),
|
||||
('ix_workspace_operation_logs_route', ['route']),
|
||||
)
|
||||
|
||||
|
||||
def _setting(name: str) -> str:
|
||||
return f"NULLIF(current_setting('{name}', true), '')"
|
||||
|
||||
|
||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
||||
return conn.dialect.identifier_preparer.quote(identifier)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, 'sqlite'), nullable=False),
|
||||
sa.Column(
|
||||
'workspace_uuid',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('actor_account_uuid', sa.String(36), nullable=True),
|
||||
sa.Column('actor_name', sa.String(255), nullable=True),
|
||||
sa.Column('actor_role', sa.String(32), nullable=True),
|
||||
sa.Column('principal_type', sa.String(32), nullable=True),
|
||||
sa.Column('api_key_uuid', sa.String(255), nullable=True),
|
||||
sa.Column('auth_type', sa.String(32), nullable=True),
|
||||
sa.Column('request_id', sa.String(128), nullable=True),
|
||||
sa.Column('http_method', sa.String(12), nullable=True),
|
||||
sa.Column('route', sa.String(512), nullable=True),
|
||||
sa.Column('action', sa.String(64), nullable=True),
|
||||
sa.Column('resource_type', sa.String(64), nullable=True),
|
||||
sa.Column('resource_id', sa.String(255), nullable=True),
|
||||
sa.Column('level', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('outcome', sa.String(16), nullable=False, server_default='ok'),
|
||||
sa.Column('status_code', sa.Integer(), nullable=True),
|
||||
sa.Column('summary', sa.Text(), nullable=True),
|
||||
sa.Column('changes', sa.Text(), nullable=True),
|
||||
sa.Column('detail', sa.Text(), nullable=True),
|
||||
sa.Column('record_hash', sa.String(64), nullable=True),
|
||||
sa.Column('prev_hash', sa.String(64), nullable=True),
|
||||
sa.Column('dedupe_key', sa.String(64), nullable=True),
|
||||
sa.Column('client_ip', sa.String(64), nullable=True),
|
||||
sa.Column('user_agent', sa.String(512), nullable=True),
|
||||
sa.Column('duration_ms', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.CheckConstraint('level IN (0, 1, 2)', name='ck_workspace_operation_logs_level'),
|
||||
sa.CheckConstraint(
|
||||
"outcome IN ('ok', 'denied', 'error')",
|
||||
name='ck_workspace_operation_logs_outcome',
|
||||
),
|
||||
)
|
||||
for index_name, columns in _INDEXES:
|
||||
op.create_index(index_name, _TABLE_NAME, columns, unique=False)
|
||||
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
|
||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
||||
f'USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()):
|
||||
return
|
||||
if conn.dialect.name == 'postgresql':
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
for index_name, _ in reversed(_INDEXES):
|
||||
op.drop_index(index_name, table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -87,6 +87,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'monitoring_errors',
|
||||
'monitoring_embedding_calls',
|
||||
'monitoring_feedback',
|
||||
'workspace_operation_logs',
|
||||
'langbot_vectors',
|
||||
'directory_projection_states',
|
||||
'directory_projection_inbox',
|
||||
|
||||
@@ -72,6 +72,9 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'monitoring_errors': 'workspace_uuid',
|
||||
'monitoring_embedding_calls': 'workspace_uuid',
|
||||
'monitoring_feedback': 'workspace_uuid',
|
||||
# Administrative traceability for member and resource operations. Owned by
|
||||
# the Workspace and therefore protected by the same RLS contract.
|
||||
'workspace_operation_logs': 'workspace_uuid',
|
||||
# Created by 0013 rather than ORM metadata; it is still part of the same
|
||||
# business-database RLS contract and permits no discovery policies.
|
||||
'langbot_vectors': 'workspace_uuid',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Integration tests for the operation-trace integrity cache.
|
||||
|
||||
These drive the real service against an in-memory SQLite engine so the whole
|
||||
read path -- scan projection, chain linking, cache reuse and invalidation -- is
|
||||
exercised end to end rather than in isolation. The integrity summary is called
|
||||
directly so the hashing counts are not diluted by the page serializer, which
|
||||
re-verifies only the handful of rows it returns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence import operation_log as operation_log_module
|
||||
from langbot.pkg.operation_trace import service as service_module
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
WORKSPACE = '00000000-0000-0000-0000-00000000000a'
|
||||
MODEL = operation_log_module.WorkspaceOperationLog
|
||||
FILTERS = [MODEL.workspace_uuid == WORKSPACE]
|
||||
|
||||
|
||||
class _PersistenceManager:
|
||||
"""Minimal execute_async shim backed by one SQLite engine."""
|
||||
|
||||
def __init__(self, engine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.execute(*args, **kwargs)
|
||||
await connection.commit()
|
||||
return result
|
||||
|
||||
|
||||
def _build_row(index: int, prev_hash: str | None, *, created_at: datetime.datetime | None = None) -> dict:
|
||||
"""Build one append-only chain row, hash linked to ``prev_hash``."""
|
||||
|
||||
row: dict = {
|
||||
'workspace_uuid': WORKSPACE,
|
||||
'actor_account_uuid': None,
|
||||
'actor_name': None,
|
||||
'actor_role': None,
|
||||
'principal_type': 'account',
|
||||
'api_key_uuid': None,
|
||||
'auth_type': 'user_token',
|
||||
'http_method': 'GET',
|
||||
'route': '/api/v1/pipelines',
|
||||
'action': 'view',
|
||||
'resource_type': 'pipeline',
|
||||
'resource_id': None,
|
||||
'level': 2,
|
||||
'outcome': 'ok',
|
||||
'status_code': 200,
|
||||
'summary': f'row {index}',
|
||||
'changes': None,
|
||||
'client_ip': None,
|
||||
'prev_hash': prev_hash,
|
||||
}
|
||||
if created_at is not None:
|
||||
row['created_at'] = created_at
|
||||
row['record_hash'] = service_module.compute_record_hash(row)
|
||||
return row
|
||||
|
||||
|
||||
async def _append(service, engine, count: int, *, prev_hash: str | None = None) -> list[int]:
|
||||
"""Append ``count`` valid rows and return every row id in insertion order."""
|
||||
|
||||
for _ in range(count):
|
||||
row = _build_row(0, prev_hash)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sqlalchemy.insert(MODEL).values(**row))
|
||||
prev_hash = row['record_hash']
|
||||
async with engine.begin() as connection:
|
||||
result = await connection.execute(sqlalchemy.select(MODEL.id).order_by(MODEL.id.asc()))
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def _tail_hash(engine) -> str | None:
|
||||
async with engine.begin() as connection:
|
||||
result = await connection.execute(sqlalchemy.select(MODEL.record_hash).order_by(MODEL.id.desc()).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _cached_views(service) -> list:
|
||||
"""Return the cache keys currently held for the test Workspace."""
|
||||
|
||||
return [key for key in service._integrity_cache if key[0] == WORKSPACE]
|
||||
|
||||
|
||||
def _counting_verifier(service, monkeypatch):
|
||||
"""Replace the per-row verifier with one that records the ids it hashes."""
|
||||
|
||||
calls: list[int] = []
|
||||
original = service_module.WorkspaceSettingsService._verify_hash_and_chain
|
||||
|
||||
def counting(row, previous_row):
|
||||
calls.append(row.id)
|
||||
return original(row, previous_row)
|
||||
|
||||
monkeypatch.setattr(service, '_verify_hash_and_chain', counting)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def trace_env(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "trace.db"}')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=_PersistenceManager(engine),
|
||||
logger=logging.getLogger('operation-trace-test'),
|
||||
)
|
||||
try:
|
||||
yield service_module.WorkspaceSettingsService(application), engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_cached_read_within_ttl_reuses_without_rehashing(trace_env, monkeypatch):
|
||||
service, engine = trace_env
|
||||
ids = await _append(service, engine, 5)
|
||||
calls = _counting_verifier(service, monkeypatch)
|
||||
|
||||
first = await service._integrity_summary(MODEL, FILTERS)
|
||||
assert first['summary']['scanned'] == 5
|
||||
assert first['summary']['tampered'] == 0
|
||||
assert len(calls) == 5
|
||||
views = _cached_views(service)
|
||||
assert len(views) == 1
|
||||
assert set(service._integrity_cache[views[0]]['verified']) == set(ids)
|
||||
|
||||
# A warm read inside the TTL must not re-hash anything at all: this is the
|
||||
# shield that makes a burst of panel opens, refreshes and page turns cheap.
|
||||
calls.clear()
|
||||
second = await service._integrity_summary(MODEL, FILTERS)
|
||||
assert second['summary']['scanned'] == 5
|
||||
assert calls == []
|
||||
|
||||
|
||||
async def test_cache_miss_reverifies_every_row(trace_env, monkeypatch):
|
||||
service, engine = trace_env
|
||||
await _append(service, engine, 6)
|
||||
|
||||
# A zero TTL forces every read past the cache short circuit.
|
||||
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
|
||||
await service._integrity_summary(MODEL, FILTERS)
|
||||
|
||||
calls = _counting_verifier(service, monkeypatch)
|
||||
await service._integrity_summary(MODEL, FILTERS)
|
||||
|
||||
# Every row is re-verified on each miss. Skipping previously verified ids
|
||||
# would make an edit to an older row permanently invisible.
|
||||
assert len(calls) == 6
|
||||
|
||||
|
||||
async def test_edit_to_already_verified_row_is_re_detected(trace_env, monkeypatch):
|
||||
service, engine = trace_env
|
||||
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
|
||||
ids = await _append(service, engine, 5)
|
||||
|
||||
clean = await service._integrity_summary(MODEL, FILTERS)
|
||||
assert clean['summary']['tampered'] == 0
|
||||
|
||||
# Edit a row that was already verified on the previous pass, without
|
||||
# re-signing it: the whole point of tamper evidence is to catch this.
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sqlalchemy.update(MODEL).where(MODEL.id == ids[1]).values(summary='EDITED-OLD'))
|
||||
|
||||
after = await service._integrity_summary(MODEL, FILTERS)
|
||||
assert after['summary']['integrity_failed'] == 1
|
||||
assert after['integrity_failed_ids'] == [ids[1]]
|
||||
|
||||
|
||||
async def test_content_edit_is_flagged_as_hash_mismatch(trace_env, monkeypatch):
|
||||
service, engine = trace_env
|
||||
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
|
||||
ids = await _append(service, engine, 4)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sqlalchemy.update(MODEL).where(MODEL.id == ids[1]).values(summary='silently edited'))
|
||||
|
||||
result = await service.query_logs(WORKSPACE)
|
||||
assert result['integrity_failed_count'] == 1
|
||||
assert result['chain_failed_count'] == 0
|
||||
assert result['tampered_count'] == 1
|
||||
|
||||
|
||||
async def test_resigned_link_is_flagged_as_chain_break(trace_env, monkeypatch):
|
||||
service, engine = trace_env
|
||||
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
|
||||
ids = await _append(service, engine, 4)
|
||||
|
||||
# Re-sign the row from its own content with a forged prev_hash, so its own
|
||||
# hash still matches and only the link is wrong. The successor's prev_hash
|
||||
# no longer points at this row's record_hash either, so both are flagged.
|
||||
async with engine.begin() as connection:
|
||||
result = await connection.execute(
|
||||
sqlalchemy.select(*[getattr(MODEL, field) for field in service_module._HASH_FIELDS]).where(
|
||||
MODEL.id == ids[2]
|
||||
)
|
||||
)
|
||||
content = dict(zip(service_module._HASH_FIELDS, result.first()))
|
||||
content['prev_hash'] = 'not-the-predecessor-hash'
|
||||
forged_hash = service_module.compute_record_hash(content)
|
||||
assert service_module.verify_record_hash(content, forged_hash)
|
||||
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sqlalchemy.update(MODEL)
|
||||
.where(MODEL.id == ids[2])
|
||||
.values(prev_hash=content['prev_hash'], record_hash=forged_hash)
|
||||
)
|
||||
|
||||
result = await service._integrity_summary(MODEL, FILTERS)
|
||||
assert result['summary']['integrity_failed'] == 0
|
||||
assert result['summary']['chain_failed'] == 2 # the row and its successor
|
||||
# Ids come back newest-first; compare as a set to stay order-agnostic.
|
||||
assert set(result['chain_failed_ids']) == {ids[2], ids[3]}
|
||||
|
||||
|
||||
async def test_age_prune_drops_the_stale_integrity_cache(trace_env):
|
||||
service, engine = trace_env
|
||||
await _append(service, engine, 2)
|
||||
old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1))
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sqlalchemy.insert(MODEL).values(**old_row))
|
||||
|
||||
# Warm the cache: its verified map now records the row retention will drop.
|
||||
await service.query_logs(WORKSPACE)
|
||||
assert _cached_views(service)
|
||||
|
||||
await service.prune(WORKSPACE, retention_days=1)
|
||||
|
||||
# Deleting the oldest rows invalidated the cached prefix.
|
||||
assert _cached_views(service) == []
|
||||
assert 'expired' in (await service.prune(WORKSPACE, retention_days=1))
|
||||
|
||||
|
||||
async def test_each_listing_view_is_cached_independently(trace_env, monkeypatch):
|
||||
service, engine = trace_env
|
||||
await _append(service, engine, 4)
|
||||
|
||||
# Two different listing filters must not collide: each gets its own entry,
|
||||
# and a view that was never scanned must still be verified from scratch.
|
||||
all_view = [MODEL.workspace_uuid == WORKSPACE]
|
||||
mutation_view = [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1]
|
||||
await service._integrity_summary(MODEL, all_view)
|
||||
await service._integrity_summary(MODEL, mutation_view)
|
||||
|
||||
views = _cached_views(service)
|
||||
assert len(views) == 2
|
||||
assert len({key[1] for key in views}) == 2
|
||||
|
||||
|
||||
async def test_invalidation_clears_every_cached_view(trace_env):
|
||||
service, engine = trace_env
|
||||
await _append(service, engine, 2)
|
||||
old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1))
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sqlalchemy.insert(MODEL).values(**old_row))
|
||||
|
||||
await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE])
|
||||
await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1])
|
||||
assert len(_cached_views(service)) == 2
|
||||
|
||||
await service.prune(WORKSPACE, retention_days=1)
|
||||
|
||||
# A deletion invalidates *all* views for the Workspace, not just one.
|
||||
assert _cached_views(service) == []
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Unit tests for the operation-trace integrity scan optimizations.
|
||||
|
||||
The read path must stay fast while proving a record is untampered. These tests
|
||||
pin the two invariants that make that safe:
|
||||
|
||||
* the integrity scan projects only the hash columns (never the whole row), and
|
||||
* the lightweight verifier returns exactly what the full serializer would, so
|
||||
the two verification paths can never drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
from langbot.pkg.entity.persistence import operation_log as persistence_operation_log
|
||||
from langbot.pkg.operation_trace import service as operation_trace_service
|
||||
|
||||
|
||||
def _make_row(**overrides: object) -> types.SimpleNamespace:
|
||||
"""Build a row exposing every column the verifier and serializer touch."""
|
||||
|
||||
fields: dict[str, object] = {
|
||||
'id': 1,
|
||||
'workspace_uuid': 'ws-1',
|
||||
'actor_account_uuid': 'acc-1',
|
||||
'actor_name': 'Ada',
|
||||
'actor_role': 'owner',
|
||||
'principal_type': 'account',
|
||||
'api_key_uuid': None,
|
||||
'auth_type': 'user_token',
|
||||
'request_id': 'req-1',
|
||||
'http_method': 'PUT',
|
||||
'route': '/api/v1/settings/governance',
|
||||
'action': 'settings_update',
|
||||
'resource_type': 'workspace_settings',
|
||||
'resource_id': None,
|
||||
'level': 1,
|
||||
'outcome': 'ok',
|
||||
'status_code': 200,
|
||||
'summary': 'level: 0 → 1',
|
||||
'changes': '[{"field": "level", "before": 0, "after": 1}]',
|
||||
'detail': '{}',
|
||||
'client_ip': '127.0.0.1',
|
||||
'user_agent': 'pytest',
|
||||
'duration_ms': 3,
|
||||
'prev_hash': None,
|
||||
'record_hash': None,
|
||||
'created_at': None,
|
||||
}
|
||||
fields.update(overrides)
|
||||
row = types.SimpleNamespace(**fields)
|
||||
if row.record_hash is None:
|
||||
payload = {field: getattr(row, field) for field in operation_trace_service._HASH_FIELDS}
|
||||
row.record_hash = operation_trace_service.compute_record_hash(payload)
|
||||
return row
|
||||
|
||||
|
||||
class TestIntegrityScanProjection:
|
||||
"""The scan must not load payload columns it never inspects."""
|
||||
|
||||
def test_projection_selects_exactly_the_hash_columns(self):
|
||||
model = persistence_operation_log.WorkspaceOperationLog
|
||||
statement = operation_trace_service._integrity_scan_select(model)
|
||||
keys = {column.key for column in statement.selected_columns}
|
||||
|
||||
expected = {'id', 'record_hash', *operation_trace_service._HASH_FIELDS}
|
||||
assert keys == expected
|
||||
|
||||
def test_projection_skips_expensive_payload_columns(self):
|
||||
model = persistence_operation_log.WorkspaceOperationLog
|
||||
statement = operation_trace_service._integrity_scan_select(model)
|
||||
keys = {column.key for column in statement.selected_columns}
|
||||
|
||||
# ``detail`` and the client fingerprint are never hashed, so the cold
|
||||
# scan must not page them in for up to MAX_INTEGRITY_SCAN_ROWS rows.
|
||||
assert 'detail' not in keys
|
||||
assert 'user_agent' not in keys
|
||||
assert 'duration_ms' not in keys
|
||||
# ``changes`` IS part of the hash, so it must remain selected.
|
||||
assert 'changes' in keys
|
||||
|
||||
|
||||
class TestLightweightVerifier:
|
||||
"""The fast verifier and the full serializer must agree byte for byte."""
|
||||
|
||||
def test_verifier_matches_serializer_for_a_valid_row(self):
|
||||
service = operation_trace_service.WorkspaceSettingsService.__new__(
|
||||
operation_trace_service.WorkspaceSettingsService
|
||||
)
|
||||
previous = _make_row(id=1)
|
||||
row = _make_row(id=2, prev_hash=previous.record_hash)
|
||||
|
||||
fast = service._verify_hash_and_chain(row, previous)
|
||||
serialized = service._serialize_log(row, previous_row=previous)
|
||||
|
||||
assert fast == (serialized['integrity_ok'], serialized['chain_ok'])
|
||||
assert fast == (True, True)
|
||||
|
||||
def test_verifier_detects_a_content_edit(self):
|
||||
service = operation_trace_service.WorkspaceSettingsService.__new__(
|
||||
operation_trace_service.WorkspaceSettingsService
|
||||
)
|
||||
row = _make_row(id=2)
|
||||
# Simulate a silent edit after the hash was computed.
|
||||
row.summary = 'tampered summary'
|
||||
|
||||
integrity_ok, chain_ok = service._verify_hash_and_chain(row, None)
|
||||
assert integrity_ok is False
|
||||
assert chain_ok is True
|
||||
|
||||
def test_verifier_detects_a_broken_chain_link(self):
|
||||
service = operation_trace_service.WorkspaceSettingsService.__new__(
|
||||
operation_trace_service.WorkspaceSettingsService
|
||||
)
|
||||
previous = _make_row(id=1)
|
||||
row = _make_row(id=2, prev_hash='not-the-previous-hash')
|
||||
|
||||
integrity_ok, chain_ok = service._verify_hash_and_chain(row, previous)
|
||||
# The row content itself is intact; only the link is wrong.
|
||||
assert integrity_ok is True
|
||||
assert chain_ok is False
|
||||
|
||||
def test_missing_baseline_leaves_chain_ok(self):
|
||||
service = operation_trace_service.WorkspaceSettingsService.__new__(
|
||||
operation_trace_service.WorkspaceSettingsService
|
||||
)
|
||||
row = _make_row(id=1, prev_hash=None)
|
||||
|
||||
integrity_ok, chain_ok = service._verify_hash_and_chain(row, None)
|
||||
assert integrity_ok is True
|
||||
assert chain_ok is True
|
||||
@@ -45,7 +45,9 @@ export default function AssistantToolResult({
|
||||
? result
|
||||
: Array.isArray(data.items)
|
||||
? data.items
|
||||
: null;
|
||||
: Array.isArray(data.records)
|
||||
? data.records
|
||||
: null;
|
||||
const total = typeof data.total === 'number' ? data.total : items?.length;
|
||||
const kind = tool?.arguments.kind;
|
||||
const label =
|
||||
@@ -142,7 +144,13 @@ export default function AssistantToolResult({
|
||||
: {};
|
||||
return (
|
||||
<li key={index} className="truncate">
|
||||
{String(entry.name || entry.uuid || '—')}
|
||||
{String(
|
||||
entry.name ||
|
||||
entry.summary ||
|
||||
entry.action ||
|
||||
entry.uuid ||
|
||||
'—',
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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;
|
||||
/** Registered Core route identity, e.g. ``/api/v1/settings/governance``. */
|
||||
route: 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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/**
|
||||
* 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 {
|
||||
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;
|
||||
/** Narrow the listing to records that failed verification. */
|
||||
integrity?: OperationIntegrityFilter;
|
||||
}
|
||||
@@ -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,55 @@ 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();
|
||||
// A base of "/" means "same origin". Appending the path directly produced
|
||||
// a protocol-relative "//api/..." URL, which a browser reads as the host
|
||||
// "api" — the export request then never reached the backend. Resolve the
|
||||
// base to the current origin first, mirroring the other URL builders here.
|
||||
const apiBase =
|
||||
this.instance.defaults.baseURL === '/' || !this.instance.defaults.baseURL
|
||||
? window.location.origin
|
||||
: this.instance.defaults.baseURL.replace(/\/$/, '');
|
||||
return `${apiBase}/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',
|
||||
@@ -22,6 +21,7 @@ const enUS = {
|
||||
create_knowledge_base: 'Create knowledge base',
|
||||
get_pipeline: 'Read Pipeline',
|
||||
get_knowledge_schema: 'Read knowledge engine schema',
|
||||
list_operation_logs: 'Read operation trace',
|
||||
},
|
||||
resources: {
|
||||
models: 'Find chat models',
|
||||
@@ -99,7 +99,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 +726,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 +1125,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 +1166,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 +1628,6 @@ const enUS = {
|
||||
runnerUsageAll: 'All',
|
||||
runnerUsageAgent: 'Agent / Pipeline',
|
||||
runnerUsageEvent: 'Plugin processor',
|
||||
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
searchPlaceholderCount:
|
||||
'Search {{count}} extensions, capabilities, or use cases...',
|
||||
@@ -2719,12 +2713,150 @@ 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 edits and views, and what changed.',
|
||||
captureLevel: 'Capture level',
|
||||
retention: 'Retention',
|
||||
retentionSummary: 'Keep {{days}} days · up to {{rows}} records',
|
||||
retentionDays: 'Retention days',
|
||||
maxRows: 'Max records',
|
||||
dedupeWindow: 'Dedupe window (s)',
|
||||
records: 'Operation records',
|
||||
changesCount: '{{count}} change(s)',
|
||||
hideDetails: 'Hide details',
|
||||
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',
|
||||
integrityFailedCount: '{{count}} hash mismatches',
|
||||
chainFailedCount: '{{count}} broken links',
|
||||
scanTruncated: 'Only the latest {{count}} verified',
|
||||
emptyFiltered: 'No records need verification under the current filters',
|
||||
mutationsOnly: 'Changes only',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
unknownDay: 'Unknown time',
|
||||
showDetails: 'Details',
|
||||
moreChanges: '+{{count}} more',
|
||||
elidedValue: '{{count}} chars',
|
||||
levels: {
|
||||
off: {
|
||||
label: 'Tracing off',
|
||||
},
|
||||
mutation: {
|
||||
label: 'Mutations only',
|
||||
},
|
||||
read: {
|
||||
label: 'Everything (incl. views)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: 'Off',
|
||||
mutation: 'Mutations',
|
||||
read: 'Everything',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: 'Bot',
|
||||
adapter: 'Adapter',
|
||||
model_provider: 'Model provider',
|
||||
llm_model: 'Model',
|
||||
pipeline: 'Pipeline',
|
||||
user: 'User',
|
||||
workspace: 'Workspace',
|
||||
monitoring: 'Monitoring',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'API key',
|
||||
workspace_settings: 'Workspace settings',
|
||||
member: 'Member',
|
||||
member_invitation: 'Member invitation',
|
||||
operation_log: 'Operation log',
|
||||
assistant_conversation: 'Assistant conversation',
|
||||
agent: 'Agent',
|
||||
file: 'File',
|
||||
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',
|
||||
error: 'Failed',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: 'View operation logs',
|
||||
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',
|
||||
plugin_view: 'View extension pages',
|
||||
plugin_config: 'Change extension config',
|
||||
plugin_install: 'Install extension',
|
||||
plugin_uninstall: 'Uninstall extension',
|
||||
plugin_upgrade: 'Upgrade extension',
|
||||
page_view: 'View extension page',
|
||||
skill_view: 'View skill',
|
||||
skill_install: 'Install skill',
|
||||
skill_uninstall: 'Uninstall skill',
|
||||
knowledge_base_view: 'View knowledge base',
|
||||
knowledge_base_update: 'Change knowledge base',
|
||||
knowledge_base_delete: 'Delete knowledge base',
|
||||
mcp_view: 'View MCP server',
|
||||
mcp_config: 'Change MCP server',
|
||||
mcp_delete: 'Delete MCP server',
|
||||
skill_update: 'Modify skill',
|
||||
file_view: 'View file',
|
||||
ingest: 'Ingest data',
|
||||
file_delete: 'Delete knowledge base file',
|
||||
ingress: 'External message ingress',
|
||||
codex_view: 'View Codex authorization',
|
||||
codex_authorize: 'Authorize Codex',
|
||||
embed: 'Embedded session',
|
||||
pipeline_extensions_update: 'Update pipeline extension bindings',
|
||||
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 +3314,4 @@ const enUS = {
|
||||
invalidPage: 'Invalid plugin page',
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
@@ -21,6 +21,7 @@ const esES = {
|
||||
create_knowledge_base: 'Crear base de conocimiento',
|
||||
get_pipeline: 'Consultar Pipeline',
|
||||
get_knowledge_schema: 'Consultar el esquema del motor de conocimiento',
|
||||
list_operation_logs: 'Consultar la trazabilidad de operaciones',
|
||||
},
|
||||
resources: {
|
||||
models: 'Buscar modelos de chat',
|
||||
@@ -2767,12 +2768,151 @@ 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 cambios y consultas de los administradores',
|
||||
captureLevel: 'Nivel de captura',
|
||||
retention: 'Retención',
|
||||
retentionSummary: 'Conservar {{days}} días · hasta {{rows}} registros',
|
||||
retentionDays: 'Días de retención',
|
||||
maxRows: 'Registros máximos',
|
||||
dedupeWindow: 'Ventana de deduplicación (s)',
|
||||
records: 'Registros de operación',
|
||||
changesCount: '{{count}} cambio(s)',
|
||||
hideDetails: 'Ocultar detalles',
|
||||
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',
|
||||
integrityFailedCount: '{{count}} hashes no coinciden',
|
||||
chainFailedCount: '{{count}} enlaces rotos',
|
||||
scanTruncated: 'Solo se verifican los últimos {{count}}',
|
||||
emptyFiltered: 'Ningún registro requiere verificación',
|
||||
mutationsOnly: 'Solo cambios',
|
||||
today: 'Hoy',
|
||||
yesterday: 'Ayer',
|
||||
unknownDay: 'Fecha desconocida',
|
||||
showDetails: 'Detalles',
|
||||
moreChanges: '+{{count}} más',
|
||||
elidedValue: '{{count}} caracteres',
|
||||
levels: {
|
||||
off: {
|
||||
label: 'Sin trazabilidad',
|
||||
},
|
||||
mutation: {
|
||||
label: 'Solo cambios',
|
||||
},
|
||||
read: {
|
||||
label: 'Todo (incl. consultas)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: 'Desactivado',
|
||||
mutation: 'Solo cambios',
|
||||
read: 'Todo',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: 'Bot',
|
||||
adapter: 'Adaptador',
|
||||
model_provider: 'Proveedor de modelos',
|
||||
llm_model: 'Modelo',
|
||||
pipeline: 'Pipeline',
|
||||
user: 'Usuario',
|
||||
workspace: 'Espacio de trabajo',
|
||||
monitoring: 'Monitorización',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'Clave de API',
|
||||
workspace_settings: 'Ajustes del espacio de trabajo',
|
||||
member: 'Miembro',
|
||||
member_invitation: 'Invitación de miembro',
|
||||
operation_log: 'Registro de operaciones',
|
||||
assistant_conversation: 'Conversación del asistente',
|
||||
agent: 'Agente',
|
||||
file: 'Archivo',
|
||||
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',
|
||||
error: 'Fallido',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: 'Ver registros de operación',
|
||||
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',
|
||||
plugin_view: 'Ver páginas de extensión',
|
||||
plugin_config: 'Cambiar configuración de extensión',
|
||||
plugin_install: 'Instalar extensión',
|
||||
plugin_uninstall: 'Desinstalar extensión',
|
||||
plugin_upgrade: 'Actualizar extensión',
|
||||
page_view: 'Ver página de extensión',
|
||||
skill_view: 'Ver habilidad',
|
||||
skill_install: 'Instalar habilidad',
|
||||
skill_uninstall: 'Desinstalar habilidad',
|
||||
knowledge_base_view: 'Ver base de conocimiento',
|
||||
knowledge_base_update: 'Cambiar base de conocimiento',
|
||||
knowledge_base_delete: 'Eliminar base de conocimiento',
|
||||
mcp_view: 'Ver servidor MCP',
|
||||
mcp_config: 'Cambiar servidor MCP',
|
||||
mcp_delete: 'Eliminar servidor MCP',
|
||||
skill_update: 'Modificar habilidad',
|
||||
file_view: 'Ver archivo',
|
||||
ingest: 'Importar datos',
|
||||
file_delete: 'Eliminar archivo de la base de conocimiento',
|
||||
ingress: 'Entrada de mensajes externos',
|
||||
codex_view: 'Ver autorización de Codex',
|
||||
codex_authorize: 'Autorizar Codex',
|
||||
embed: 'Sesión incrustada',
|
||||
pipeline_extensions_update:
|
||||
'Actualizar vinculaciones de extensiones del pipeline',
|
||||
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:
|
||||
|
||||
@@ -21,6 +21,7 @@ const jaJP = {
|
||||
create_knowledge_base: 'ナレッジベース作成',
|
||||
get_pipeline: 'Pipeline 参照',
|
||||
get_knowledge_schema: 'エンジン設定の参照',
|
||||
list_operation_logs: '操作トレーサビリティの参照',
|
||||
},
|
||||
resources: {
|
||||
models: 'チャットモデル検索',
|
||||
@@ -2730,12 +2731,150 @@ const jaJP = {
|
||||
title: '設定',
|
||||
nav: {
|
||||
workspace: 'ワークスペース',
|
||||
operationTrace: '操作トレーサビリティ',
|
||||
models: 'モデル',
|
||||
api: 'API',
|
||||
storage: 'ストレージ',
|
||||
account: 'アカウント',
|
||||
},
|
||||
},
|
||||
operationTrace: {
|
||||
title: '操作トレーサビリティ',
|
||||
description: '管理者とオーナーの操作を追跡します。',
|
||||
captureLevel: 'キャプチャレベル',
|
||||
retention: '保持ポリシー',
|
||||
retentionSummary: '{{days}} 日間保持 · 最大 {{rows}} 件',
|
||||
retentionDays: '保持日数',
|
||||
maxRows: '最大レコード数',
|
||||
dedupeWindow: '重複排除ウィンドウ(秒)',
|
||||
records: '操作レコード',
|
||||
changesCount: '変更 {{count}} 件',
|
||||
hideDetails: '詳細を隠す',
|
||||
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: '検証済み',
|
||||
integrityFailedCount: '{{count}} 件のハッシュ不一致',
|
||||
chainFailedCount: '{{count}} 件のリンク断絶',
|
||||
scanTruncated: '最新 {{count}} 件のみ検証',
|
||||
emptyFiltered: '現在のフィルターでは検証が必要な記録はありません',
|
||||
mutationsOnly: '変更のみ',
|
||||
today: '今日',
|
||||
yesterday: '昨日',
|
||||
unknownDay: '不明な日時',
|
||||
showDetails: '詳細',
|
||||
moreChanges: '他 {{count}} 件',
|
||||
elidedValue: '{{count}} 文字',
|
||||
levels: {
|
||||
off: {
|
||||
label: 'トレース無効',
|
||||
},
|
||||
mutation: {
|
||||
label: '変更のみ',
|
||||
},
|
||||
read: {
|
||||
label: '最上位(閲覧含む)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: '無効',
|
||||
mutation: '変更のみ',
|
||||
read: '最上位',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: 'ボット',
|
||||
adapter: 'アダプター',
|
||||
model_provider: 'モデルプロバイダー',
|
||||
llm_model: 'モデル',
|
||||
pipeline: 'パイプライン',
|
||||
user: 'ユーザー',
|
||||
workspace: 'ワークスペース',
|
||||
monitoring: 'モニタリング',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'API キー',
|
||||
workspace_settings: 'ワークスペース設定',
|
||||
member: 'メンバー',
|
||||
member_invitation: 'メンバー招待',
|
||||
operation_log: '操作ログ',
|
||||
assistant_conversation: 'アシスタント会話',
|
||||
agent: 'エージェント',
|
||||
file: 'ファイル',
|
||||
plugin: '拡張機能',
|
||||
plugin_page: '拡張ページ',
|
||||
skill: 'スキル',
|
||||
knowledge_base: 'ナレッジベース',
|
||||
mcp_server: 'MCP サーバー',
|
||||
runtime: 'ランタイム',
|
||||
system: 'システム',
|
||||
resource: 'リソース',
|
||||
},
|
||||
outcomes: {
|
||||
ok: '成功',
|
||||
denied: '拒否',
|
||||
error: '失敗',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: '操作ログを閲覧',
|
||||
settings_update: 'トレーサビリティ設定を変更',
|
||||
settings_view: 'トレーサビリティ設定を閲覧',
|
||||
member_invite: 'メンバーを招待',
|
||||
member_role_update: 'メンバーのロールを変更',
|
||||
member_remove: 'メンバーを削除',
|
||||
member_view: 'メンバーを閲覧',
|
||||
plugin_view: '拡張ページを閲覧',
|
||||
plugin_config: '拡張設定を変更',
|
||||
plugin_install: '拡張をインストール',
|
||||
plugin_uninstall: '拡張をアンインストール',
|
||||
plugin_upgrade: '拡張をアップグレード',
|
||||
page_view: '拡張ページを閲覧',
|
||||
skill_view: 'スキルを閲覧',
|
||||
skill_install: 'スキルをインストール',
|
||||
skill_uninstall: 'スキルをアンインストール',
|
||||
knowledge_base_view: 'ナレッジベースを閲覧',
|
||||
knowledge_base_update: 'ナレッジベースを変更',
|
||||
knowledge_base_delete: 'ナレッジベースを削除',
|
||||
mcp_view: 'MCP サーバーを閲覧',
|
||||
mcp_config: 'MCP サーバーを変更',
|
||||
mcp_delete: 'MCP サーバーを削除',
|
||||
skill_update: 'スキルを変更',
|
||||
file_view: 'ファイルを表示',
|
||||
ingest: 'データを取り込み',
|
||||
file_delete: 'ナレッジベースのファイルを削除',
|
||||
ingress: '外部メッセージ受信',
|
||||
codex_view: 'Codex 認証を表示',
|
||||
codex_authorize: 'Codex を認証',
|
||||
embed: '埋め込みセッション',
|
||||
pipeline_extensions_update: 'パイプライン拡張の紐付けを変更',
|
||||
export: 'データをエクスポート',
|
||||
execute: 'タスクを実行',
|
||||
debug: 'タスクをデバッグ',
|
||||
publish: 'リソースを公開',
|
||||
create: 'リソースを作成',
|
||||
update: 'リソースを変更',
|
||||
delete: 'リソースを削除',
|
||||
view: 'リソースを閲覧',
|
||||
probe: 'エンドポイントを確認',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'ストレージ分析',
|
||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
|
||||
@@ -21,6 +21,7 @@ const ruRU = {
|
||||
create_knowledge_base: 'Создать базу знаний',
|
||||
get_pipeline: 'Посмотреть Pipeline',
|
||||
get_knowledge_schema: 'Посмотреть схему движка знаний',
|
||||
list_operation_logs: 'Просмотр трассировки операций',
|
||||
},
|
||||
resources: {
|
||||
models: 'Найти чат-модели',
|
||||
@@ -2737,12 +2738,150 @@ const ruRU = {
|
||||
title: 'Настройки',
|
||||
nav: {
|
||||
workspace: 'Workspace',
|
||||
operationTrace: 'Трассировка',
|
||||
models: 'Модели',
|
||||
api: 'API',
|
||||
storage: 'Хранилище',
|
||||
account: 'Аккаунт',
|
||||
},
|
||||
},
|
||||
operationTrace: {
|
||||
title: 'Трассировка операций',
|
||||
description: 'Отслеживание изменений и просмотров администраторов',
|
||||
captureLevel: 'Уровень записи',
|
||||
retention: 'Хранение',
|
||||
retentionSummary: 'Хранить {{days}} дн. · до {{rows}} записей',
|
||||
retentionDays: 'Дней хранения',
|
||||
maxRows: 'Максимум записей',
|
||||
dedupeWindow: 'Окно дедупликации (с)',
|
||||
records: 'Записи операций',
|
||||
changesCount: 'Изменений: {{count}}',
|
||||
hideDetails: 'Скрыть детали',
|
||||
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: 'Проверено',
|
||||
integrityFailedCount: 'Несовпадение хэша: {{count}}',
|
||||
chainFailedCount: 'Разрыв связи: {{count}}',
|
||||
scanTruncated: 'Проверены только последние {{count}}',
|
||||
emptyFiltered: 'По текущим фильтрам нет записей, требующих проверки',
|
||||
mutationsOnly: 'Только изменения',
|
||||
today: 'Сегодня',
|
||||
yesterday: 'Вчера',
|
||||
unknownDay: 'Неизвестное время',
|
||||
showDetails: 'Подробнее',
|
||||
moreChanges: 'ещё {{count}}',
|
||||
elidedValue: '{{count}} символов',
|
||||
levels: {
|
||||
off: {
|
||||
label: 'Трассировка выключена',
|
||||
},
|
||||
mutation: {
|
||||
label: 'Только изменения',
|
||||
},
|
||||
read: {
|
||||
label: 'Всё (включая просмотры)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: 'Выкл',
|
||||
mutation: 'Изменения',
|
||||
read: 'Всё',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: 'Бот',
|
||||
adapter: 'Адаптер',
|
||||
model_provider: 'Провайдер моделей',
|
||||
llm_model: 'Модель',
|
||||
pipeline: 'Конвейер',
|
||||
user: 'Пользователь',
|
||||
workspace: 'Рабочее пространство',
|
||||
monitoring: 'Мониторинг',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'API-ключ',
|
||||
workspace_settings: 'Настройки пространства',
|
||||
member: 'Участник',
|
||||
member_invitation: 'Приглашение участника',
|
||||
operation_log: 'Журнал операций',
|
||||
assistant_conversation: 'Сессия ассистента',
|
||||
agent: 'Агент',
|
||||
file: 'Файл',
|
||||
plugin: 'Расширение',
|
||||
plugin_page: 'Страница расширения',
|
||||
skill: 'Навык',
|
||||
knowledge_base: 'База знаний',
|
||||
mcp_server: 'MCP-сервер',
|
||||
runtime: 'Среда выполнения',
|
||||
system: 'Система',
|
||||
resource: 'Ресурс',
|
||||
},
|
||||
outcomes: {
|
||||
ok: 'Успех',
|
||||
denied: 'Отказано',
|
||||
error: 'Ошибка',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: 'Просмотр журнала операций',
|
||||
settings_update: 'Изменение настроек трассировки',
|
||||
settings_view: 'Просмотр настроек трассировки',
|
||||
member_invite: 'Приглашение участника',
|
||||
member_role_update: 'Изменение роли участника',
|
||||
member_remove: 'Удаление участника',
|
||||
member_view: 'Просмотр участников',
|
||||
plugin_view: 'Просмотр страниц расширения',
|
||||
plugin_config: 'Изменение настроек расширения',
|
||||
plugin_install: 'Установка расширения',
|
||||
plugin_uninstall: 'Удаление расширения',
|
||||
plugin_upgrade: 'Обновление расширения',
|
||||
page_view: 'Просмотр страницы расширения',
|
||||
skill_view: 'Просмотр навыка',
|
||||
skill_install: 'Установка навыка',
|
||||
skill_uninstall: 'Удаление навыка',
|
||||
knowledge_base_view: 'Просмотр базы знаний',
|
||||
knowledge_base_update: 'Изменение базы знаний',
|
||||
knowledge_base_delete: 'Удаление базы знаний',
|
||||
mcp_view: 'Просмотр сервера MCP',
|
||||
mcp_config: 'Изменение сервера MCP',
|
||||
mcp_delete: 'Удаление сервера MCP',
|
||||
skill_update: 'Изменение навыка',
|
||||
file_view: 'Просмотр файла',
|
||||
ingest: 'Импорт данных',
|
||||
file_delete: 'Удаление файла базы знаний',
|
||||
ingress: 'Внешний приём сообщений',
|
||||
codex_view: 'Просмотр авторизации Codex',
|
||||
codex_authorize: 'Авторизация Codex',
|
||||
embed: 'Встроенная сессия',
|
||||
pipeline_extensions_update: 'Изменение привязок расширений конвейера',
|
||||
export: 'Экспорт данных',
|
||||
execute: 'Выполнение задачи',
|
||||
debug: 'Отладка задачи',
|
||||
publish: 'Публикация ресурса',
|
||||
create: 'Создание ресурса',
|
||||
update: 'Изменение ресурса',
|
||||
delete: 'Удаление ресурса',
|
||||
view: 'Просмотр ресурса',
|
||||
probe: 'Проверка эндпоинта',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Анализ хранилища',
|
||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
|
||||
@@ -21,6 +21,7 @@ const thTH = {
|
||||
create_knowledge_base: 'สร้างฐานความรู้',
|
||||
get_pipeline: 'ดู Pipeline',
|
||||
get_knowledge_schema: 'ดูสคีมาของ knowledge engine',
|
||||
list_operation_logs: 'ดูการติดตามการดำเนินการ',
|
||||
},
|
||||
resources: {
|
||||
models: 'ค้นหาโมเดลแชต',
|
||||
@@ -2664,12 +2665,150 @@ const thTH = {
|
||||
title: 'การตั้งค่า',
|
||||
nav: {
|
||||
workspace: 'Workspace',
|
||||
operationTrace: 'การติดตาม',
|
||||
models: 'โมเดล',
|
||||
api: 'API',
|
||||
storage: 'พื้นที่จัดเก็บ',
|
||||
account: 'บัญชี',
|
||||
},
|
||||
},
|
||||
operationTrace: {
|
||||
title: 'การติดตามการดำเนินการ',
|
||||
description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ',
|
||||
captureLevel: 'ระดับการบันทึก',
|
||||
retention: 'นโยบายการเก็บรักษา',
|
||||
retentionSummary: 'เก็บ {{days}} วัน · สูงสุด {{rows}} รายการ',
|
||||
retentionDays: 'จำนวนวันเก็บรักษา',
|
||||
maxRows: 'จำนวนบันทึกสูงสุด',
|
||||
dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)',
|
||||
records: 'บันทึกการดำเนินการ',
|
||||
changesCount: '{{count}} การเปลี่ยนแปลง',
|
||||
hideDetails: 'ซ่อนรายละเอียด',
|
||||
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: 'ตรวจสอบผ่าน',
|
||||
integrityFailedCount: 'แฮชไม่ตรงกัน {{count}} รายการ',
|
||||
chainFailedCount: 'ลิงก์ขาด {{count}} รายการ',
|
||||
scanTruncated: 'ตรวจสอบเฉพาะ {{count}} รายการล่าสุด',
|
||||
emptyFiltered: 'ไม่มีรายการที่ต้องตรวจสอบภายใต้ตัวกรองปัจจุบัน',
|
||||
mutationsOnly: 'เฉพาะการแก้ไข',
|
||||
today: 'วันนี้',
|
||||
yesterday: 'เมื่อวาน',
|
||||
unknownDay: 'ไม่ทราบเวลา',
|
||||
showDetails: 'รายละเอียด',
|
||||
moreChanges: 'อีก {{count}} รายการ',
|
||||
elidedValue: '{{count}} ตัวอักษร',
|
||||
levels: {
|
||||
off: {
|
||||
label: 'ปิดการติดตาม',
|
||||
},
|
||||
mutation: {
|
||||
label: 'เฉพาะการแก้ไข',
|
||||
},
|
||||
read: {
|
||||
label: 'สูงสุด (รวมการดู)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: 'ปิด',
|
||||
mutation: 'เฉพาะแก้ไข',
|
||||
read: 'สูงสุด',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: 'บอท',
|
||||
adapter: 'อะแดปเตอร์',
|
||||
model_provider: 'ผู้ให้บริการโมเดล',
|
||||
llm_model: 'โมเดล',
|
||||
pipeline: 'ไปป์ไลน์',
|
||||
user: 'ผู้ใช้',
|
||||
workspace: 'เวิร์กสเปซ',
|
||||
monitoring: 'การมอนิเตอร์',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'คีย์ API',
|
||||
workspace_settings: 'การตั้งค่าพื้นที่ทำงาน',
|
||||
member: 'สมาชิก',
|
||||
member_invitation: 'คำเชิญสมาชิก',
|
||||
operation_log: 'บันทึกการดำเนินการ',
|
||||
assistant_conversation: 'บทสนทนาผู้ช่วย',
|
||||
agent: 'เอเจนต์',
|
||||
file: 'ไฟล์',
|
||||
plugin: 'ส่วนขยาย',
|
||||
plugin_page: 'หน้าส่วนขยาย',
|
||||
skill: 'ทักษะ',
|
||||
knowledge_base: 'ฐานความรู้',
|
||||
mcp_server: 'เซิร์ฟเวอร์ MCP',
|
||||
runtime: 'รันไทม์',
|
||||
system: 'ระบบ',
|
||||
resource: 'ทรัพยากร',
|
||||
},
|
||||
outcomes: {
|
||||
ok: 'สำเร็จ',
|
||||
denied: 'ถูกปฏิเสธ',
|
||||
error: 'ล้มเหลว',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: 'ดูบันทึกการดำเนินการ',
|
||||
settings_update: 'แก้ไขการตั้งค่าการติดตาม',
|
||||
settings_view: 'ดูการตั้งค่าการติดตาม',
|
||||
member_invite: 'เชิญสมาชิก',
|
||||
member_role_update: 'แก้ไขบทบาทสมาชิก',
|
||||
member_remove: 'ลบสมาชิก',
|
||||
member_view: 'ดูสมาชิก',
|
||||
plugin_view: 'ดูหน้าส่วนขยาย',
|
||||
plugin_config: 'แก้ไขการตั้งค่าส่วนขยาย',
|
||||
plugin_install: 'ติดตั้งส่วนขยาย',
|
||||
plugin_uninstall: 'ถอนการติดตั้งส่วนขยาย',
|
||||
plugin_upgrade: 'อัปเกรดส่วนขยาย',
|
||||
page_view: 'ดูหน้าส่วนขยาย',
|
||||
skill_view: 'ดูทักษะ',
|
||||
skill_install: 'ติดตั้งทักษะ',
|
||||
skill_uninstall: 'ถอนการติดตั้งทักษะ',
|
||||
knowledge_base_view: 'ดูฐานความรู้',
|
||||
knowledge_base_update: 'แก้ไขฐานความรู้',
|
||||
knowledge_base_delete: 'ลบฐานความรู้',
|
||||
mcp_view: 'ดูเซิร์ฟเวอร์ MCP',
|
||||
mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP',
|
||||
mcp_delete: 'ลบเซิร์ฟเวอร์ MCP',
|
||||
skill_update: 'แก้ไขทักษะ',
|
||||
file_view: 'ดูไฟล์',
|
||||
ingest: 'นำเข้าข้อมูล',
|
||||
file_delete: 'ลบไฟล์ฐานความรู้',
|
||||
ingress: 'การรับข้อความภายนอก',
|
||||
codex_view: 'ดูการอนุญาต Codex',
|
||||
codex_authorize: 'อนุญาต Codex',
|
||||
embed: 'เซสชันฝังตัว',
|
||||
pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์',
|
||||
export: 'ส่งออกข้อมูล',
|
||||
execute: 'รันงาน',
|
||||
debug: 'ดีบักงาน',
|
||||
publish: 'เผยแพร่ทรัพยากร',
|
||||
create: 'สร้างทรัพยากร',
|
||||
update: 'แก้ไขทรัพยากร',
|
||||
delete: 'ลบทรัพยากร',
|
||||
view: 'ดูทรัพยากร',
|
||||
probe: 'ตรวจสอบปลายทาง',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
|
||||
@@ -22,6 +22,7 @@ const viVN = {
|
||||
create_knowledge_base: 'Tạo cơ sở tri thức',
|
||||
get_pipeline: 'Xem Pipeline',
|
||||
get_knowledge_schema: 'Xem lược đồ knowledge engine',
|
||||
list_operation_logs: 'Truy vấn truy vết thao tác',
|
||||
},
|
||||
resources: {
|
||||
models: 'Tìm mô hình trò chuyện',
|
||||
@@ -2699,12 +2700,150 @@ 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',
|
||||
captureLevel: 'Mức ghi nhận',
|
||||
retention: 'Chính sách lưu trữ',
|
||||
retentionSummary: 'Lưu {{days}} ngày · tối đa {{rows}} bản ghi',
|
||||
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',
|
||||
changesCount: '{{count}} thay đổi',
|
||||
hideDetails: 'Ẩn chi tiết',
|
||||
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',
|
||||
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',
|
||||
mutationsOnly: 'Chỉ thay đổi',
|
||||
today: 'Hôm nay',
|
||||
yesterday: 'Hôm qua',
|
||||
unknownDay: 'Không rõ thời gian',
|
||||
showDetails: 'Chi tiết',
|
||||
moreChanges: '+{{count}} mục',
|
||||
elidedValue: '{{count}} ký tự',
|
||||
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',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: 'Bot',
|
||||
adapter: 'Bộ chuyển đổi',
|
||||
model_provider: 'Nhà cung cấp mô hình',
|
||||
llm_model: 'Mô hình',
|
||||
pipeline: 'Đường ống',
|
||||
user: 'Người dùng',
|
||||
workspace: 'Workspace',
|
||||
monitoring: 'Giám sát',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'Khóa API',
|
||||
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',
|
||||
assistant_conversation: 'Phiên trợ lý',
|
||||
agent: 'Tác nhân',
|
||||
file: 'Tệp',
|
||||
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',
|
||||
error: 'Thất bại',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: 'Xem nhật ký thao tác',
|
||||
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',
|
||||
plugin_view: 'Xem trang tiện ích',
|
||||
plugin_config: 'Thay đổi cấu hình tiện ích',
|
||||
plugin_install: 'Cài đặt tiện ích',
|
||||
plugin_uninstall: 'Gỡ cài đặt tiện ích',
|
||||
plugin_upgrade: 'Nâng cấp tiện ích',
|
||||
page_view: 'Xem trang tiện ích',
|
||||
skill_view: 'Xem kỹ năng',
|
||||
skill_install: 'Cài đặt kỹ năng',
|
||||
skill_uninstall: 'Gỡ cài đặt kỹ năng',
|
||||
knowledge_base_view: 'Xem cơ sở tri thức',
|
||||
knowledge_base_update: 'Thay đổi cơ sở tri thức',
|
||||
knowledge_base_delete: 'Xóa cơ sở tri thức',
|
||||
mcp_view: 'Xem máy chủ MCP',
|
||||
mcp_config: 'Thay đổi máy chủ MCP',
|
||||
mcp_delete: 'Xóa máy chủ MCP',
|
||||
skill_update: 'Sửa kỹ năng',
|
||||
file_view: 'Xem tệp',
|
||||
ingest: 'Nhập dữ liệu',
|
||||
file_delete: 'Xóa tệp cơ sở tri thức',
|
||||
ingress: 'Nhận tin nhắn bên ngoài',
|
||||
codex_view: 'Xem ủy quyền Codex',
|
||||
codex_authorize: 'Ủy quyền Codex',
|
||||
embed: 'Phiên nhúng',
|
||||
pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline',
|
||||
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',
|
||||
|
||||
@@ -19,6 +19,7 @@ const zhHans = {
|
||||
create_knowledge_base: '创建知识库',
|
||||
get_pipeline: '查看 Pipeline',
|
||||
get_knowledge_schema: '查看知识引擎配置',
|
||||
list_operation_logs: '查询操作溯源',
|
||||
},
|
||||
resources: {
|
||||
models: '查询聊天模型',
|
||||
@@ -2562,12 +2563,150 @@ const zhHans = {
|
||||
title: '设置',
|
||||
nav: {
|
||||
workspace: '工作区',
|
||||
operationTrace: '操作溯源',
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '存储',
|
||||
account: '账户',
|
||||
},
|
||||
},
|
||||
operationTrace: {
|
||||
title: '操作溯源',
|
||||
description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。',
|
||||
captureLevel: '操作等级',
|
||||
retention: '保留策略',
|
||||
retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 条',
|
||||
retentionDays: '保留天数',
|
||||
maxRows: '最大记录数',
|
||||
dedupeWindow: '去重窗口(秒)',
|
||||
records: '操作记录',
|
||||
changesCount: '{{count}} 项变更',
|
||||
hideDetails: '收起详情',
|
||||
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: '校验通过',
|
||||
integrityFailedCount: '{{count}} 条哈希不匹配',
|
||||
chainFailedCount: '{{count}} 条链路断裂',
|
||||
scanTruncated: '仅校验最近 {{count}} 条',
|
||||
emptyFiltered: '当前筛选下没有需要校验的记录',
|
||||
mutationsOnly: '只看修改',
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
unknownDay: '未知时间',
|
||||
showDetails: '详情',
|
||||
moreChanges: '还有 {{count}} 项',
|
||||
elidedValue: '{{count}} 字符',
|
||||
levels: {
|
||||
off: {
|
||||
label: '关闭溯源',
|
||||
},
|
||||
mutation: {
|
||||
label: '仅修改操作',
|
||||
},
|
||||
read: {
|
||||
label: '最高级(含查看)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: '关闭',
|
||||
mutation: '仅修改',
|
||||
read: '最高级',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: '机器人',
|
||||
adapter: '适配器',
|
||||
model_provider: '模型提供商',
|
||||
llm_model: '模型',
|
||||
pipeline: '管道',
|
||||
user: '用户',
|
||||
workspace: '工作区',
|
||||
monitoring: '监控',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'API 密钥',
|
||||
workspace_settings: '工作区设置',
|
||||
member: '成员',
|
||||
member_invitation: '成员邀请',
|
||||
operation_log: '操作日志',
|
||||
assistant_conversation: '助手会话',
|
||||
agent: '智能体',
|
||||
file: '文件',
|
||||
plugin: '扩展',
|
||||
plugin_page: '扩展页面',
|
||||
skill: '技能',
|
||||
knowledge_base: '知识库',
|
||||
mcp_server: 'MCP 服务器',
|
||||
runtime: '运行时',
|
||||
system: '系统',
|
||||
resource: '资源',
|
||||
},
|
||||
outcomes: {
|
||||
ok: '成功',
|
||||
denied: '被拒绝',
|
||||
error: '失败',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: '查看操作日志',
|
||||
settings_update: '修改溯源设置',
|
||||
settings_view: '查看溯源设置',
|
||||
member_invite: '邀请成员',
|
||||
member_role_update: '修改成员角色',
|
||||
member_remove: '移除成员',
|
||||
member_view: '查看成员',
|
||||
plugin_view: '查看扩展页面',
|
||||
plugin_config: '修改扩展配置',
|
||||
plugin_install: '安装扩展',
|
||||
plugin_uninstall: '卸载扩展',
|
||||
plugin_upgrade: '升级扩展',
|
||||
page_view: '查看扩展页面',
|
||||
skill_view: '查看技能',
|
||||
skill_install: '安装技能',
|
||||
skill_uninstall: '卸载技能',
|
||||
knowledge_base_view: '查看知识库',
|
||||
knowledge_base_update: '修改知识库',
|
||||
knowledge_base_delete: '删除知识库',
|
||||
mcp_view: '查看 MCP 服务器',
|
||||
mcp_config: '修改 MCP 服务器',
|
||||
mcp_delete: '删除 MCP 服务器',
|
||||
skill_update: '修改技能',
|
||||
file_view: '查看文件',
|
||||
ingest: '导入数据',
|
||||
file_delete: '删除知识库文件',
|
||||
ingress: '外部消息接入',
|
||||
codex_view: '查看 Codex 授权',
|
||||
codex_authorize: '授权 Codex',
|
||||
embed: '嵌入式会话',
|
||||
pipeline_extensions_update: '修改管道扩展绑定',
|
||||
export: '导出数据',
|
||||
execute: '执行任务',
|
||||
debug: '调试任务',
|
||||
publish: '发布资源',
|
||||
create: '创建资源',
|
||||
update: '修改资源',
|
||||
delete: '删除资源',
|
||||
view: '查看资源',
|
||||
probe: '探测接口',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '存储分析',
|
||||
description: '查看存储占用和可清理文件',
|
||||
|
||||
@@ -19,6 +19,7 @@ const zhHant = {
|
||||
create_knowledge_base: '建立知識庫',
|
||||
get_pipeline: '查看 Pipeline',
|
||||
get_knowledge_schema: '查看知識引擎設定',
|
||||
list_operation_logs: '查詢操作溯源',
|
||||
},
|
||||
resources: {
|
||||
models: '查詢聊天模型',
|
||||
@@ -2563,12 +2564,150 @@ const zhHant = {
|
||||
title: '設定',
|
||||
nav: {
|
||||
workspace: 'Workspace',
|
||||
operationTrace: '操作溯源',
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '儲存',
|
||||
account: '帳戶',
|
||||
},
|
||||
},
|
||||
operationTrace: {
|
||||
title: '操作溯源',
|
||||
description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。',
|
||||
captureLevel: '操作等級',
|
||||
retention: '保留策略',
|
||||
retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 條',
|
||||
retentionDays: '保留天數',
|
||||
maxRows: '最大記錄數',
|
||||
dedupeWindow: '去重視窗(秒)',
|
||||
records: '操作記錄',
|
||||
changesCount: '{{count}} 項變更',
|
||||
hideDetails: '收合詳情',
|
||||
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: '驗證通過',
|
||||
integrityFailedCount: '{{count}} 筆雜湊不符',
|
||||
chainFailedCount: '{{count}} 筆鏈結中斷',
|
||||
scanTruncated: '僅校驗最近 {{count}} 筆',
|
||||
emptyFiltered: '目前篩選下沒有需要校驗的記錄',
|
||||
mutationsOnly: '只看修改',
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
unknownDay: '未知時間',
|
||||
showDetails: '詳情',
|
||||
moreChanges: '還有 {{count}} 項',
|
||||
elidedValue: '{{count}} 字元',
|
||||
levels: {
|
||||
off: {
|
||||
label: '關閉溯源',
|
||||
},
|
||||
mutation: {
|
||||
label: '僅修改操作',
|
||||
},
|
||||
read: {
|
||||
label: '最高級(含查看)',
|
||||
},
|
||||
},
|
||||
levelNames: {
|
||||
off: '關閉',
|
||||
mutation: '僅修改',
|
||||
read: '最高級',
|
||||
},
|
||||
resourceTypes: {
|
||||
bot: '機器人',
|
||||
adapter: '適配器',
|
||||
model_provider: '模型提供商',
|
||||
llm_model: '模型',
|
||||
pipeline: '管道',
|
||||
user: '使用者',
|
||||
workspace: '工作區',
|
||||
monitoring: '監控',
|
||||
webhook: 'Webhook',
|
||||
api_key: 'API 金鑰',
|
||||
workspace_settings: '工作區設定',
|
||||
member: '成員',
|
||||
member_invitation: '成員邀請',
|
||||
operation_log: '操作日誌',
|
||||
assistant_conversation: '助手會話',
|
||||
agent: '智慧代理',
|
||||
file: '檔案',
|
||||
plugin: '擴充',
|
||||
plugin_page: '擴充頁面',
|
||||
skill: '技能',
|
||||
knowledge_base: '知識庫',
|
||||
mcp_server: 'MCP 伺服器',
|
||||
runtime: '執行階段',
|
||||
system: '系統',
|
||||
resource: '資源',
|
||||
},
|
||||
outcomes: {
|
||||
ok: '成功',
|
||||
denied: '被拒絕',
|
||||
error: '失敗',
|
||||
},
|
||||
actions: {
|
||||
audit_log_view: '查看操作日誌',
|
||||
settings_update: '修改溯源設定',
|
||||
settings_view: '查看溯源設定',
|
||||
member_invite: '邀請成員',
|
||||
member_role_update: '修改成員角色',
|
||||
member_remove: '移除成員',
|
||||
member_view: '查看成員',
|
||||
plugin_view: '查看擴充頁面',
|
||||
plugin_config: '修改擴充設定',
|
||||
plugin_install: '安裝擴充',
|
||||
plugin_uninstall: '解除安裝擴充',
|
||||
plugin_upgrade: '升級擴充',
|
||||
page_view: '查看擴充頁面',
|
||||
skill_view: '查看技能',
|
||||
skill_install: '安裝技能',
|
||||
skill_uninstall: '解除安裝技能',
|
||||
knowledge_base_view: '查看知識庫',
|
||||
knowledge_base_update: '修改知識庫',
|
||||
knowledge_base_delete: '刪除知識庫',
|
||||
mcp_view: '查看 MCP 伺服器',
|
||||
mcp_config: '修改 MCP 伺服器',
|
||||
mcp_delete: '刪除 MCP 伺服器',
|
||||
skill_update: '修改技能',
|
||||
file_view: '查看檔案',
|
||||
ingest: '匯入資料',
|
||||
file_delete: '刪除知識庫檔案',
|
||||
ingress: '外部訊息接入',
|
||||
codex_view: '查看 Codex 授權',
|
||||
codex_authorize: '授權 Codex',
|
||||
embed: '嵌入式工作階段',
|
||||
pipeline_extensions_update: '修改管道擴展綁定',
|
||||
export: '匯出資料',
|
||||
execute: '執行任務',
|
||||
debug: '偵錯任務',
|
||||
publish: '發佈資源',
|
||||
create: '建立資源',
|
||||
update: '修改資源',
|
||||
delete: '刪除資源',
|
||||
view: '查看資源',
|
||||
probe: '探測介面',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '儲存分析',
|
||||
description: '查看儲存占用和可清理檔案',
|
||||
|
||||
Reference in New Issue
Block a user