mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-16 18:56:02 +00:00
Compare commits
11 Commits
test/forma
...
feat/host-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8789c42eeb | ||
|
|
b3c6de2072 | ||
|
|
4e45886647 | ||
|
|
f592656680 | ||
|
|
e9db858dcc | ||
|
|
2d6faf9d5e | ||
|
|
d4699547e9 | ||
|
|
716d7aca94 | ||
|
|
b3c00fe6da | ||
|
|
f4a6edf7ec | ||
|
|
f390980d0a |
@@ -313,18 +313,30 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Get traces
|
||||
traces, traces_total = await self.ap.monitoring_service.get_traces(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'overview': overview,
|
||||
'messages': messages,
|
||||
'llmCalls': llm_calls,
|
||||
'embeddingCalls': embedding_calls,
|
||||
'traces': traces,
|
||||
'sessions': sessions,
|
||||
'errors': errors,
|
||||
'totalCount': {
|
||||
'messages': messages_total,
|
||||
'llmCalls': llm_calls_total,
|
||||
'embeddingCalls': embedding_calls_total,
|
||||
'traces': traces_total,
|
||||
'sessions': sessions_total,
|
||||
'errors': errors_total,
|
||||
},
|
||||
@@ -350,6 +362,49 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=details)
|
||||
|
||||
@self.route('/traces', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_traces() -> str:
|
||||
"""Get end-to-end trace records."""
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
session_ids = quart.request.args.getlist('sessionId')
|
||||
statuses = quart.request.args.getlist('status')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
traces, total = await self.ap.monitoring_service.get_traces(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
session_ids=session_ids if session_ids else None,
|
||||
statuses=statuses if statuses else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'traces': traces,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/traces/<trace_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_trace_details(trace_id: str) -> str:
|
||||
"""Get one trace with all spans."""
|
||||
details = await self.ap.monitoring_service.get_trace_details(trace_id)
|
||||
if not details.get('found'):
|
||||
return self.http_status(404, -1, f'Trace {trace_id} not found')
|
||||
return self.success(data=details)
|
||||
|
||||
@self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def export_data() -> tuple[str, int]:
|
||||
"""Export monitoring data as CSV"""
|
||||
|
||||
@@ -350,8 +350,24 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
if not endpoint.startswith('/') or '..' in endpoint:
|
||||
return self.http_status(400, -1, 'invalid endpoint')
|
||||
|
||||
caller = {
|
||||
'plugin_author': author,
|
||||
'plugin_name': plugin_name,
|
||||
'page_id': page_id,
|
||||
'origin': _get_request_origin(),
|
||||
}
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in {
|
||||
'user-agent': quart.request.headers.get('User-Agent'),
|
||||
'x-request-id': quart.request.headers.get('X-Request-ID'),
|
||||
'x-forwarded-for': quart.request.headers.get('X-Forwarded-For'),
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
|
||||
result = await self.ap.plugin_connector.handle_page_api(
|
||||
author, plugin_name, page_id, endpoint, method.upper(), body
|
||||
author, plugin_name, page_id, endpoint, method.upper(), body, caller, headers
|
||||
)
|
||||
if result.get('error'):
|
||||
return self.http_status(400, -1, result['error'])
|
||||
|
||||
@@ -3,11 +3,53 @@ from __future__ import annotations
|
||||
import uuid
|
||||
import datetime
|
||||
import sqlalchemy
|
||||
import json
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import monitoring as persistence_monitoring
|
||||
|
||||
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _json_dumps(value: dict | list | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return json.dumps({'serialization_error': str(value)}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _json_loads(value: str | None) -> dict | list | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def new_trace_id() -> str:
|
||||
return f'trace-{uuid.uuid4().hex[:16]}'
|
||||
|
||||
|
||||
def new_span_id() -> str:
|
||||
return f'span-{uuid.uuid4().hex[:16]}'
|
||||
|
||||
|
||||
def normalize_trace_status(status: str | None) -> str:
|
||||
"""Normalize operation status to the monitoring UI vocabulary."""
|
||||
if status in ('completed', 'ok'):
|
||||
return 'success'
|
||||
if status in ('failed', 'failure', 'exception'):
|
||||
return 'error'
|
||||
if status in ('running', 'success', 'error'):
|
||||
return status
|
||||
return 'success'
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
"""Monitoring service"""
|
||||
|
||||
@@ -74,6 +116,18 @@ class MonitoringService:
|
||||
persistence_monitoring.MonitoringFeedback.timestamp,
|
||||
persistence_monitoring.MonitoringFeedback.id,
|
||||
),
|
||||
(
|
||||
'monitoring_traces',
|
||||
persistence_monitoring.MonitoringTrace,
|
||||
persistence_monitoring.MonitoringTrace.started_at,
|
||||
persistence_monitoring.MonitoringTrace.trace_id,
|
||||
),
|
||||
(
|
||||
'monitoring_spans',
|
||||
persistence_monitoring.MonitoringSpan,
|
||||
persistence_monitoring.MonitoringSpan.started_at,
|
||||
persistence_monitoring.MonitoringSpan.span_id,
|
||||
),
|
||||
]
|
||||
|
||||
deleted_counts: dict[str, int] = {}
|
||||
@@ -133,6 +187,116 @@ class MonitoringService:
|
||||
|
||||
# ========== Recording Methods ==========
|
||||
|
||||
async def start_trace(
|
||||
self,
|
||||
trace_id: str | None = None,
|
||||
name: str = 'LangBot query',
|
||||
bot_id: str | None = None,
|
||||
bot_name: str | None = None,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
query_id: str | int | None = None,
|
||||
attributes: dict | None = None,
|
||||
) -> str:
|
||||
"""Create or update a trace header row."""
|
||||
trace_id = trace_id or new_trace_id()
|
||||
trace_data = {
|
||||
'trace_id': trace_id,
|
||||
'started_at': _utc_now(),
|
||||
'ended_at': None,
|
||||
'duration': None,
|
||||
'status': 'running',
|
||||
'name': name,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'session_id': session_id,
|
||||
'message_id': message_id,
|
||||
'query_id': str(query_id) if query_id is not None else None,
|
||||
'attributes': _json_dumps(attributes),
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringTrace).values(trace_data)
|
||||
)
|
||||
return trace_id
|
||||
|
||||
async def finish_trace(
|
||||
self,
|
||||
trace_id: str,
|
||||
status: str = 'success',
|
||||
duration: int | None = None,
|
||||
message_id: str | None = None,
|
||||
attributes: dict | None = None,
|
||||
) -> None:
|
||||
"""Mark a trace complete."""
|
||||
update_values: dict = {
|
||||
'ended_at': _utc_now(),
|
||||
'status': normalize_trace_status(status),
|
||||
}
|
||||
if duration is not None:
|
||||
update_values['duration'] = duration
|
||||
if message_id is not None:
|
||||
update_values['message_id'] = message_id
|
||||
if attributes is not None:
|
||||
update_values['attributes'] = _json_dumps(attributes)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringTrace)
|
||||
.where(persistence_monitoring.MonitoringTrace.trace_id == trace_id)
|
||||
.values(update_values)
|
||||
)
|
||||
|
||||
async def record_span(
|
||||
self,
|
||||
trace_id: str,
|
||||
name: str,
|
||||
kind: str,
|
||||
status: str = 'success',
|
||||
span_id: str | None = None,
|
||||
parent_span_id: str | None = None,
|
||||
started_at: datetime.datetime | None = None,
|
||||
ended_at: datetime.datetime | None = None,
|
||||
duration: int | None = None,
|
||||
message_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
pipeline_id: str | None = None,
|
||||
attributes: dict | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> str:
|
||||
"""Record a single completed span."""
|
||||
started_at = started_at or _utc_now()
|
||||
if duration is None and ended_at is not None:
|
||||
duration = int((ended_at - started_at).total_seconds() * 1000)
|
||||
elif duration is not None:
|
||||
duration = int(round(float(duration)))
|
||||
span_data = {
|
||||
'span_id': span_id or new_span_id(),
|
||||
'trace_id': trace_id,
|
||||
'parent_span_id': parent_span_id,
|
||||
'name': name,
|
||||
'kind': kind,
|
||||
'status': normalize_trace_status(status),
|
||||
'started_at': started_at,
|
||||
'ended_at': ended_at or _utc_now(),
|
||||
'duration': duration,
|
||||
'message_id': message_id,
|
||||
'session_id': session_id,
|
||||
'bot_id': bot_id,
|
||||
'pipeline_id': pipeline_id,
|
||||
'attributes': _json_dumps(attributes),
|
||||
'error_message': error_message,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringSpan).values(span_data)
|
||||
)
|
||||
return span_data['span_id']
|
||||
|
||||
async def record_message(
|
||||
self,
|
||||
bot_id: str,
|
||||
@@ -1076,6 +1240,19 @@ class MonitoringService:
|
||||
for row in error_rows
|
||||
]
|
||||
|
||||
trace_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringTrace)
|
||||
.where(persistence_monitoring.MonitoringTrace.message_id == message_id)
|
||||
.order_by(persistence_monitoring.MonitoringTrace.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
trace_result = await self.ap.persistence_mgr.execute_async(trace_query)
|
||||
trace_row = trace_result.first()
|
||||
trace = None
|
||||
if trace_row:
|
||||
trace_model = trace_row[0] if isinstance(trace_row, tuple) else trace_row
|
||||
trace = self._serialize_trace(trace_model)
|
||||
|
||||
return {
|
||||
'message_id': message_id,
|
||||
'found': True,
|
||||
@@ -1090,6 +1267,90 @@ class MonitoringService:
|
||||
'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0,
|
||||
},
|
||||
'errors': errors,
|
||||
'trace': trace,
|
||||
}
|
||||
|
||||
def _serialize_trace(self, trace: persistence_monitoring.MonitoringTrace) -> dict:
|
||||
data = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringTrace, trace)
|
||||
data['attributes'] = _json_loads(data.get('attributes')) or {}
|
||||
return data
|
||||
|
||||
def _serialize_span(self, span: persistence_monitoring.MonitoringSpan) -> dict:
|
||||
data = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSpan, span)
|
||||
data['attributes'] = _json_loads(data.get('attributes')) or {}
|
||||
return data
|
||||
|
||||
async def get_traces(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
session_ids: list[str] | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get trace headers with filters."""
|
||||
conditions = []
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringTrace.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringTrace.pipeline_id.in_(pipeline_ids))
|
||||
if session_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringTrace.session_id.in_(session_ids))
|
||||
if statuses:
|
||||
conditions.append(persistence_monitoring.MonitoringTrace.status.in_(statuses))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringTrace.started_at >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringTrace.started_at <= end_time)
|
||||
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringTrace.trace_id))
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringTrace)
|
||||
if conditions:
|
||||
clause = sqlalchemy.and_(*conditions)
|
||||
count_query = count_query.where(clause)
|
||||
query = query.where(clause)
|
||||
|
||||
total_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(persistence_monitoring.MonitoringTrace.started_at.desc()).limit(limit).offset(offset)
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
traces = [
|
||||
self._serialize_trace(row[0] if isinstance(row, tuple) else row)
|
||||
for row in result.all()
|
||||
]
|
||||
return traces, total
|
||||
|
||||
async def get_trace_details(self, trace_id: str) -> dict:
|
||||
"""Get a single trace and all spans in chronological order."""
|
||||
trace_query = sqlalchemy.select(persistence_monitoring.MonitoringTrace).where(
|
||||
persistence_monitoring.MonitoringTrace.trace_id == trace_id
|
||||
)
|
||||
trace_result = await self.ap.persistence_mgr.execute_async(trace_query)
|
||||
trace_row = trace_result.first()
|
||||
if not trace_row:
|
||||
return {'trace_id': trace_id, 'found': False}
|
||||
|
||||
trace = trace_row[0] if isinstance(trace_row, tuple) else trace_row
|
||||
span_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringSpan)
|
||||
.where(persistence_monitoring.MonitoringSpan.trace_id == trace_id)
|
||||
.order_by(persistence_monitoring.MonitoringSpan.started_at.asc())
|
||||
)
|
||||
span_result = await self.ap.persistence_mgr.execute_async(span_query)
|
||||
spans = [
|
||||
self._serialize_span(row[0] if isinstance(row, tuple) else row)
|
||||
for row in span_result.all()
|
||||
]
|
||||
|
||||
return {
|
||||
'trace_id': trace_id,
|
||||
'found': True,
|
||||
'trace': self._serialize_trace(trace),
|
||||
'spans': spans,
|
||||
}
|
||||
|
||||
# ========== Export Methods ==========
|
||||
|
||||
@@ -3,6 +3,49 @@ import sqlalchemy
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MonitoringTrace(Base):
|
||||
"""End-to-end monitoring trace records"""
|
||||
|
||||
__tablename__ = 'monitoring_traces'
|
||||
|
||||
trace_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
ended_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
|
||||
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) # milliseconds
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) # running, success, error
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
query_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
attributes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
|
||||
|
||||
class MonitoringSpan(Base):
|
||||
"""Trace span records for pipeline, RAG, model, plugin and tool operations"""
|
||||
|
||||
__tablename__ = 'monitoring_spans'
|
||||
|
||||
span_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
trace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
parent_span_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
kind = sqlalchemy.Column(sqlalchemy.String(80), nullable=False, index=True)
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
|
||||
started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
ended_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) # milliseconds
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
attributes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
|
||||
|
||||
class MonitoringMessage(Base):
|
||||
"""Monitoring message records"""
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add monitoring traces and spans
|
||||
|
||||
Revision ID: 0006_monitoring_traces
|
||||
Revises: 0005_add_llm_context_length
|
||||
Create Date: 2026-06-16
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0006_monitoring_traces'
|
||||
down_revision = '0005_add_llm_context_length'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if 'monitoring_traces' not in tables:
|
||||
op.create_table(
|
||||
'monitoring_traces',
|
||||
sa.Column('trace_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('duration', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('bot_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('bot_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('pipeline_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('pipeline_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('session_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('message_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('query_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('attributes', sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('trace_id'),
|
||||
)
|
||||
op.create_index('ix_monitoring_traces_started_at', 'monitoring_traces', ['started_at'])
|
||||
op.create_index('ix_monitoring_traces_ended_at', 'monitoring_traces', ['ended_at'])
|
||||
op.create_index('ix_monitoring_traces_status', 'monitoring_traces', ['status'])
|
||||
op.create_index('ix_monitoring_traces_bot_id', 'monitoring_traces', ['bot_id'])
|
||||
op.create_index('ix_monitoring_traces_pipeline_id', 'monitoring_traces', ['pipeline_id'])
|
||||
op.create_index('ix_monitoring_traces_session_id', 'monitoring_traces', ['session_id'])
|
||||
op.create_index('ix_monitoring_traces_message_id', 'monitoring_traces', ['message_id'])
|
||||
op.create_index('ix_monitoring_traces_query_id', 'monitoring_traces', ['query_id'])
|
||||
|
||||
if 'monitoring_spans' not in tables:
|
||||
op.create_table(
|
||||
'monitoring_spans',
|
||||
sa.Column('span_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('trace_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('parent_span_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('kind', sa.String(length=80), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('duration', sa.Integer(), nullable=True),
|
||||
sa.Column('message_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('session_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('bot_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('pipeline_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('attributes', sa.Text(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('span_id'),
|
||||
)
|
||||
op.create_index('ix_monitoring_spans_trace_id', 'monitoring_spans', ['trace_id'])
|
||||
op.create_index('ix_monitoring_spans_parent_span_id', 'monitoring_spans', ['parent_span_id'])
|
||||
op.create_index('ix_monitoring_spans_kind', 'monitoring_spans', ['kind'])
|
||||
op.create_index('ix_monitoring_spans_status', 'monitoring_spans', ['status'])
|
||||
op.create_index('ix_monitoring_spans_started_at', 'monitoring_spans', ['started_at'])
|
||||
op.create_index('ix_monitoring_spans_message_id', 'monitoring_spans', ['message_id'])
|
||||
op.create_index('ix_monitoring_spans_session_id', 'monitoring_spans', ['session_id'])
|
||||
op.create_index('ix_monitoring_spans_bot_id', 'monitoring_spans', ['bot_id'])
|
||||
op.create_index('ix_monitoring_spans_pipeline_id', 'monitoring_spans', ['pipeline_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
if 'monitoring_spans' in tables:
|
||||
op.drop_table('monitoring_spans')
|
||||
if 'monitoring_traces' in tables:
|
||||
op.drop_table('monitoring_traces')
|
||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import traceback
|
||||
import time
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
@@ -79,6 +82,19 @@ class RuntimePipeline:
|
||||
enable_all_plugins: bool
|
||||
"""是否启用所有插件"""
|
||||
|
||||
@staticmethod
|
||||
def _new_span_id() -> str:
|
||||
return f'span-{uuid.uuid4().hex[:16]}'
|
||||
|
||||
@staticmethod
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def _query_session_id(query: pipeline_query.Query) -> str:
|
||||
launcher_type = query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type)
|
||||
return f'{launcher_type}_{query.launcher_id}'
|
||||
|
||||
enable_all_mcp_servers: bool
|
||||
"""是否启用所有MCP服务器"""
|
||||
|
||||
@@ -234,44 +250,92 @@ class RuntimePipeline:
|
||||
stage_container = self.stage_containers[i]
|
||||
|
||||
query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里
|
||||
span_started_at = self._utc_now()
|
||||
span_started = time.perf_counter()
|
||||
span_status = 'success'
|
||||
span_error = None
|
||||
span_result_type = None
|
||||
|
||||
result = stage_container.inst.process(query, stage_container.inst_name)
|
||||
try:
|
||||
result = stage_container.inst.process(query, stage_container.inst_name)
|
||||
|
||||
if isinstance(result, typing.Coroutine):
|
||||
result = await result
|
||||
if isinstance(result, typing.Coroutine):
|
||||
result = await result
|
||||
|
||||
if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果
|
||||
self.ap.logger.debug(
|
||||
f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}'
|
||||
)
|
||||
await self._check_output(query, result)
|
||||
|
||||
if result.result_type == pipeline_entities.ResultType.INTERRUPT:
|
||||
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
|
||||
break
|
||||
elif result.result_type == pipeline_entities.ResultType.CONTINUE:
|
||||
query = result.new_query
|
||||
elif isinstance(result, typing.AsyncGenerator): # 生成器
|
||||
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
|
||||
|
||||
async for sub_result in result:
|
||||
if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果
|
||||
span_result_type = str(result.result_type.value if hasattr(result.result_type, 'value') else result.result_type)
|
||||
self.ap.logger.debug(
|
||||
f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
|
||||
f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}'
|
||||
)
|
||||
await self._check_output(query, sub_result)
|
||||
await self._check_output(query, result)
|
||||
|
||||
if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
|
||||
if result.result_type == pipeline_entities.ResultType.INTERRUPT:
|
||||
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
|
||||
break
|
||||
elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
|
||||
query = sub_result.new_query
|
||||
await self._execute_from_stage(i + 1, query)
|
||||
break
|
||||
elif result.result_type == pipeline_entities.ResultType.CONTINUE:
|
||||
query = result.new_query
|
||||
elif isinstance(result, typing.AsyncGenerator): # 生成器
|
||||
span_result_type = 'generator'
|
||||
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
|
||||
|
||||
async for sub_result in result:
|
||||
span_result_type = str(
|
||||
sub_result.result_type.value
|
||||
if hasattr(sub_result.result_type, 'value')
|
||||
else sub_result.result_type
|
||||
)
|
||||
self.ap.logger.debug(
|
||||
f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
|
||||
)
|
||||
await self._check_output(query, sub_result)
|
||||
|
||||
if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
|
||||
self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
|
||||
break
|
||||
elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
|
||||
query = sub_result.new_query
|
||||
await self._execute_from_stage(i + 1, query)
|
||||
break
|
||||
except Exception as e:
|
||||
span_status = 'error'
|
||||
span_error = str(e)
|
||||
raise
|
||||
finally:
|
||||
trace_id = (query.variables or {}).get('_monitoring_trace_id')
|
||||
root_span_id = (query.variables or {}).get('_monitoring_root_span_id')
|
||||
if trace_id:
|
||||
try:
|
||||
await self.ap.monitoring_service.record_span(
|
||||
trace_id=trace_id,
|
||||
parent_span_id=root_span_id,
|
||||
name=stage_container.inst_name,
|
||||
kind='pipeline.stage',
|
||||
status=span_status,
|
||||
started_at=span_started_at,
|
||||
duration=int((time.perf_counter() - span_started) * 1000),
|
||||
message_id=(query.variables or {}).get('_monitoring_message_id'),
|
||||
session_id=self._query_session_id(query),
|
||||
bot_id=query.bot_uuid,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
attributes={
|
||||
'stage_class': stage_container.inst.__class__.__name__,
|
||||
'result_type': span_result_type,
|
||||
'query_id': query.query_id,
|
||||
},
|
||||
error_message=span_error,
|
||||
)
|
||||
except Exception as monitor_err:
|
||||
self.ap.logger.error(f'Failed to record stage span: {monitor_err}')
|
||||
|
||||
i += 1
|
||||
|
||||
async def process_query(self, query: pipeline_query.Query):
|
||||
"""处理请求"""
|
||||
trace_started_at = self._utc_now()
|
||||
trace_started = time.perf_counter()
|
||||
root_span_id = self._new_span_id()
|
||||
trace_id = None
|
||||
trace_status = 'success'
|
||||
# Get monitoring metadata
|
||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||
@@ -303,6 +367,28 @@ class RuntimePipeline:
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||
|
||||
try:
|
||||
trace_id = await self.ap.monitoring_service.start_trace(
|
||||
name='LangBot query',
|
||||
bot_id=query.bot_uuid or 'unknown',
|
||||
bot_name=bot_name,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
pipeline_name=pipeline_name,
|
||||
session_id=self._query_session_id(query),
|
||||
message_id=message_id or None,
|
||||
query_id=query.query_id,
|
||||
attributes={
|
||||
'launcher_type': query.launcher_type.value
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
'runner_name': runner_name,
|
||||
},
|
||||
)
|
||||
query.variables['_monitoring_trace_id'] = trace_id
|
||||
query.variables['_monitoring_root_span_id'] = root_span_id
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to start query trace: {e}')
|
||||
|
||||
try:
|
||||
# Get bound plugins for this pipeline
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
@@ -361,6 +447,7 @@ class RuntimePipeline:
|
||||
self.ap.logger.error(f'Failed to record query response: {e}')
|
||||
|
||||
except Exception as e:
|
||||
trace_status = 'error'
|
||||
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
|
||||
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
|
||||
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
|
||||
@@ -383,6 +470,35 @@ class RuntimePipeline:
|
||||
self.ap.logger.error(f'Failed to record query error: {me}')
|
||||
|
||||
finally:
|
||||
if trace_id:
|
||||
try:
|
||||
duration_ms = int((time.perf_counter() - trace_started) * 1000)
|
||||
await self.ap.monitoring_service.record_span(
|
||||
trace_id=trace_id,
|
||||
span_id=root_span_id,
|
||||
name='LangBot query',
|
||||
kind='pipeline.query',
|
||||
status=trace_status,
|
||||
started_at=trace_started_at,
|
||||
duration=duration_ms,
|
||||
message_id=message_id or None,
|
||||
session_id=self._query_session_id(query),
|
||||
bot_id=query.bot_uuid,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
attributes={
|
||||
'query_id': query.query_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'runner_name': runner_name,
|
||||
},
|
||||
)
|
||||
await self.ap.monitoring_service.finish_trace(
|
||||
trace_id=trace_id,
|
||||
status=trace_status,
|
||||
duration=duration_ms,
|
||||
message_id=message_id or None,
|
||||
)
|
||||
except Exception as monitor_err:
|
||||
self.ap.logger.error(f'Failed to finish query trace: {monitor_err}')
|
||||
self.ap.logger.debug(f'Query {query.query_id} processed')
|
||||
del self.ap.query_pool.cached_queries[query.query_id]
|
||||
|
||||
|
||||
@@ -711,8 +711,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
endpoint: str,
|
||||
method: str,
|
||||
body: Any = None,
|
||||
caller: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||
return await self.handler.handle_page_api(
|
||||
plugin_author,
|
||||
plugin_name,
|
||||
page_id,
|
||||
endpoint,
|
||||
method,
|
||||
body,
|
||||
caller,
|
||||
headers or {},
|
||||
)
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
|
||||
@@ -755,6 +755,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'session_name': session_name,
|
||||
'bot_uuid': query.bot_uuid or '',
|
||||
'sender_id': str(query.sender_id),
|
||||
'_trace_context': {
|
||||
'trace_id': query.variables.get('_monitoring_trace_id') if query.variables else None,
|
||||
'parent_span_id': query.variables.get('_monitoring_root_span_id') if query.variables else None,
|
||||
'message_id': query.variables.get('_monitoring_message_id') if query.variables else None,
|
||||
'query_id': query.query_id,
|
||||
'session_id': session_name,
|
||||
'bot_id': query.bot_uuid or '',
|
||||
'pipeline_id': query.pipeline_uuid or '',
|
||||
'knowledge_base_id': kb_id,
|
||||
'attributes': {
|
||||
'source': 'plugin-api',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
results = [entry.model_dump(mode='json') for entry in entries]
|
||||
@@ -1011,6 +1024,8 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
endpoint: str,
|
||||
method: str,
|
||||
body: Any = None,
|
||||
caller: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Forward a page API call to the plugin via runtime."""
|
||||
result = await self.call_action(
|
||||
@@ -1022,6 +1037,8 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'endpoint': endpoint,
|
||||
'method': method,
|
||||
'body': body,
|
||||
'caller': caller,
|
||||
'headers': headers or {},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import abc
|
||||
import typing
|
||||
import time
|
||||
import datetime
|
||||
|
||||
from ...core import app
|
||||
from ...entity.persistence import model as persistence_model
|
||||
@@ -16,6 +17,15 @@ LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
|
||||
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
|
||||
|
||||
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _query_session_id(query: pipeline_query.Query) -> str:
|
||||
launcher_type = query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type)
|
||||
return f'{launcher_type}_{query.launcher_id}'
|
||||
|
||||
|
||||
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
|
||||
"""Store the latest provider usage on the query for upstream action handlers."""
|
||||
if query is None or not usage_info:
|
||||
@@ -59,6 +69,7 @@ class RuntimeProvider:
|
||||
"""Bridge method for invoking LLM with monitoring"""
|
||||
# Start timing for monitoring
|
||||
start_time = time.time()
|
||||
span_started_at = _utc_now()
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
status = 'success'
|
||||
@@ -125,6 +136,30 @@ class RuntimeProvider:
|
||||
error_message=error_message,
|
||||
message_id=message_id,
|
||||
)
|
||||
trace_id = query.variables.get('_monitoring_trace_id') if query.variables else None
|
||||
parent_span_id = query.variables.get('_monitoring_root_span_id') if query.variables else None
|
||||
if trace_id:
|
||||
await self.requester.ap.monitoring_service.record_span(
|
||||
trace_id=trace_id,
|
||||
parent_span_id=parent_span_id,
|
||||
name=f'LLM {model.model_entity.name}',
|
||||
kind='model.llm',
|
||||
status=status,
|
||||
started_at=span_started_at,
|
||||
duration=duration_ms,
|
||||
message_id=message_id,
|
||||
session_id=_query_session_id(query),
|
||||
bot_id=query.bot_uuid,
|
||||
pipeline_id=query.pipeline_uuid,
|
||||
attributes={
|
||||
'model_name': model.model_entity.name,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': input_tokens + output_tokens,
|
||||
'stream': False,
|
||||
},
|
||||
error_message=error_message,
|
||||
)
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
|
||||
|
||||
@@ -140,6 +175,7 @@ class RuntimeProvider:
|
||||
"""Bridge method for invoking LLM stream with monitoring"""
|
||||
# Start timing for monitoring
|
||||
start_time = time.time()
|
||||
span_started_at = _utc_now()
|
||||
status = 'success'
|
||||
error_message = None
|
||||
input_tokens = 0
|
||||
@@ -204,6 +240,30 @@ class RuntimeProvider:
|
||||
error_message=error_message,
|
||||
message_id=message_id,
|
||||
)
|
||||
trace_id = query.variables.get('_monitoring_trace_id') if query.variables else None
|
||||
parent_span_id = query.variables.get('_monitoring_root_span_id') if query.variables else None
|
||||
if trace_id:
|
||||
await self.requester.ap.monitoring_service.record_span(
|
||||
trace_id=trace_id,
|
||||
parent_span_id=parent_span_id,
|
||||
name=f'LLM stream {model.model_entity.name}',
|
||||
kind='model.llm',
|
||||
status=status,
|
||||
started_at=span_started_at,
|
||||
duration=duration_ms,
|
||||
message_id=message_id,
|
||||
session_id=_query_session_id(query),
|
||||
bot_id=query.bot_uuid,
|
||||
pipeline_id=query.pipeline_uuid,
|
||||
attributes={
|
||||
'model_name': model.model_entity.name,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': input_tokens + output_tokens,
|
||||
'stream': True,
|
||||
},
|
||||
error_message=error_message,
|
||||
)
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
|
||||
|
||||
|
||||
@@ -268,6 +268,19 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
'bot_uuid': query.bot_uuid or '',
|
||||
'sender_id': str(query.sender_id),
|
||||
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
'_trace_context': {
|
||||
'trace_id': query.variables.get('_monitoring_trace_id') if query.variables else None,
|
||||
'parent_span_id': query.variables.get('_monitoring_root_span_id') if query.variables else None,
|
||||
'message_id': query.variables.get('_monitoring_message_id') if query.variables else None,
|
||||
'query_id': query.query_id,
|
||||
'session_id': f'{query.launcher_type.value}_{query.launcher_id}',
|
||||
'bot_id': query.bot_uuid or '',
|
||||
'pipeline_id': query.pipeline_uuid or '',
|
||||
'knowledge_base_id': kb_uuid,
|
||||
'attributes': {
|
||||
'source': 'local-agent',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import traceback
|
||||
import uuid
|
||||
import zipfile
|
||||
import io
|
||||
import datetime
|
||||
from typing import Any
|
||||
from langbot.pkg.core import app
|
||||
import sqlalchemy
|
||||
@@ -25,6 +26,10 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
super().__init__(ap)
|
||||
self.knowledge_base_entity = knowledge_base_entity
|
||||
|
||||
@staticmethod
|
||||
def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
@@ -334,6 +339,24 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
# are passed directly to vector_search by some plugins (e.g. LangRAG)
|
||||
# and would cause empty results when the metadata field doesn't exist.
|
||||
filters = settings.pop('filters', {})
|
||||
trace_context = settings.pop('_trace_context', None)
|
||||
host_span_started_at = self._utc_now()
|
||||
host_span_id = None
|
||||
if trace_context and trace_context.get('trace_id'):
|
||||
host_parent_span_id = trace_context.get('parent_span_id')
|
||||
host_span_id = trace_context.get('rag_span_id') or f'span-{uuid.uuid4().hex[:16]}'
|
||||
trace_context = {
|
||||
'trace_id': trace_context.get('trace_id'),
|
||||
'parent_span_id': host_span_id,
|
||||
'host_parent_span_id': host_parent_span_id,
|
||||
'message_id': trace_context.get('message_id'),
|
||||
'query_id': trace_context.get('query_id'),
|
||||
'session_id': trace_context.get('session_id'),
|
||||
'bot_id': trace_context.get('bot_id'),
|
||||
'pipeline_id': trace_context.get('pipeline_id'),
|
||||
'knowledge_base_id': kb.uuid,
|
||||
'attributes': trace_context.get('attributes') or {},
|
||||
}
|
||||
|
||||
retrieval_context = {
|
||||
'query': query,
|
||||
@@ -343,13 +366,104 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
'creation_settings': kb.creation_settings or {},
|
||||
'filters': filters,
|
||||
}
|
||||
if trace_context:
|
||||
retrieval_context['trace_context'] = trace_context
|
||||
|
||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||
plugin_id,
|
||||
retrieval_context,
|
||||
)
|
||||
try:
|
||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||
plugin_id,
|
||||
retrieval_context,
|
||||
)
|
||||
except Exception as e:
|
||||
if trace_context:
|
||||
await self._record_rag_trace_result(
|
||||
trace_context=trace_context,
|
||||
host_span_id=host_span_id,
|
||||
started_at=host_span_started_at,
|
||||
plugin_id=plugin_id,
|
||||
result={
|
||||
'results': [],
|
||||
'metadata': {
|
||||
'status': 'error',
|
||||
'error_message': str(e),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise
|
||||
if trace_context:
|
||||
await self._record_rag_trace_result(
|
||||
trace_context=trace_context,
|
||||
host_span_id=host_span_id,
|
||||
started_at=host_span_started_at,
|
||||
plugin_id=plugin_id,
|
||||
result=result,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _record_rag_trace_result(
|
||||
self,
|
||||
trace_context: dict[str, Any],
|
||||
host_span_id: str | None,
|
||||
started_at: datetime.datetime,
|
||||
plugin_id: str,
|
||||
result: dict[str, Any],
|
||||
) -> None:
|
||||
"""Persist host RAG span and plugin-provided child spans."""
|
||||
trace_id = trace_context.get('trace_id')
|
||||
if not trace_id:
|
||||
return
|
||||
|
||||
metadata = result.get('metadata') if isinstance(result, dict) else {}
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
plugin_spans = metadata.get('trace_spans') if isinstance(metadata.get('trace_spans'), list) else []
|
||||
parent_span_id = trace_context.get('parent_span_id')
|
||||
host_parent_span_id = trace_context.get('host_parent_span_id')
|
||||
|
||||
try:
|
||||
await self.ap.monitoring_service.record_span(
|
||||
trace_id=trace_id,
|
||||
span_id=host_span_id,
|
||||
parent_span_id=host_parent_span_id,
|
||||
name=f'Knowledge retrieval {self.knowledge_base_entity.name}',
|
||||
kind='rag.retrieval',
|
||||
status=metadata.get('status', 'success'),
|
||||
started_at=started_at,
|
||||
duration=metadata.get('duration_ms'),
|
||||
message_id=trace_context.get('message_id'),
|
||||
session_id=trace_context.get('session_id'),
|
||||
bot_id=trace_context.get('bot_id'),
|
||||
pipeline_id=trace_context.get('pipeline_id'),
|
||||
attributes={
|
||||
'knowledge_base_id': self.knowledge_base_entity.uuid,
|
||||
'knowledge_base_name': self.knowledge_base_entity.name,
|
||||
'plugin_id': plugin_id,
|
||||
'returned_count': len(result.get('results', []) if isinstance(result, dict) else []),
|
||||
'total_found': result.get('total_found') if isinstance(result, dict) else None,
|
||||
},
|
||||
error_message=metadata.get('error_message'),
|
||||
)
|
||||
for span in plugin_spans:
|
||||
if not isinstance(span, dict):
|
||||
continue
|
||||
await self.ap.monitoring_service.record_span(
|
||||
trace_id=trace_id,
|
||||
span_id=span.get('span_id'),
|
||||
parent_span_id=span.get('parent_span_id') or host_span_id or parent_span_id,
|
||||
name=span.get('name') or 'RAG plugin stage',
|
||||
kind=span.get('kind') or 'rag.stage',
|
||||
status=span.get('status') or 'success',
|
||||
started_at=started_at,
|
||||
duration=span.get('duration_ms'),
|
||||
message_id=trace_context.get('message_id'),
|
||||
session_id=trace_context.get('session_id'),
|
||||
bot_id=trace_context.get('bot_id'),
|
||||
pipeline_id=trace_context.get('pipeline_id'),
|
||||
attributes=span.get('attributes') if isinstance(span.get('attributes'), dict) else {},
|
||||
error_message=span.get('error_message'),
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record RAG trace spans: {e}')
|
||||
|
||||
async def _delete_document(self, document_id: str) -> bool:
|
||||
"""Call plugin to delete document."""
|
||||
kb = self.knowledge_base_entity
|
||||
|
||||
@@ -82,6 +82,15 @@ def fake_monitoring_app():
|
||||
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
|
||||
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
|
||||
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
|
||||
app.monitoring_service.get_traces = AsyncMock(return_value=([{'trace_id': 'trace-1'}], 1))
|
||||
app.monitoring_service.get_trace_details = AsyncMock(
|
||||
return_value={
|
||||
'found': True,
|
||||
'trace_id': 'trace-1',
|
||||
'trace': {'trace_id': 'trace-1'},
|
||||
'spans': [],
|
||||
}
|
||||
)
|
||||
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
|
||||
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
|
||||
app.monitoring_service.get_session_analysis = AsyncMock(
|
||||
@@ -222,6 +231,7 @@ class TestMonitoringAllDataEndpoint:
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert 'overview' in data['data']
|
||||
assert 'traces' in data['data']
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
@@ -246,6 +256,15 @@ class TestMonitoringDetailsEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trace_details(self, quart_test_client):
|
||||
"""GET /api/v1/monitoring/traces/{id}."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/traces/trace-1', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringFeedbackEndpoints:
|
||||
|
||||
@@ -104,7 +104,7 @@ class TestSQLiteMigrationUpgrade:
|
||||
rev = await get_alembic_current(sqlite_engine)
|
||||
assert rev is not None, 'Expected a revision after upgrade'
|
||||
# Head should be the latest migration
|
||||
assert rev.startswith('0005'), f'Expected head to be 0005_*, got {rev}'
|
||||
assert rev.startswith('0006'), f'Expected head to be 0006_*, got {rev}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_idempotent(self, sqlite_engine):
|
||||
|
||||
@@ -144,8 +144,8 @@ class TestPostgreSQLMigrationUpgrade:
|
||||
# Verify revision
|
||||
rev = await get_alembic_current(postgres_engine)
|
||||
assert rev is not None, 'Expected a revision after upgrade'
|
||||
# Head should be the latest migration (0005 for current state)
|
||||
assert rev.startswith('0005'), f'Expected head to be 0005_*, got {rev}'
|
||||
# Head should be the latest migration.
|
||||
assert rev.startswith('0006'), f'Expected head to be 0006_*, got {rev}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version):
|
||||
|
||||
@@ -48,7 +48,6 @@ interface PipelineOption {
|
||||
}
|
||||
|
||||
interface RoutingRulesEditorProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: UseFormReturn<any>;
|
||||
pipelineNameList: PipelineOption[];
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemActions,
|
||||
} from '@/components/ui/item';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
|
||||
interface AccountSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AccountSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AccountSettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const info = await httpClient.getUserInfo();
|
||||
setAccountType(info.account_type);
|
||||
setHasPassword(info.has_password);
|
||||
setUserEmail(info.user);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordDialogClose = (dialogOpen: boolean) => {
|
||||
setPasswordDialogOpen(dialogOpen);
|
||||
if (!dialogOpen) {
|
||||
// Reload user info to update password status
|
||||
loadUserInfo();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('account.settings')}</DialogTitle>
|
||||
<DialogDescription>{userEmail}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Password Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{hasPassword
|
||||
? t('account.passwordSetDescription')
|
||||
: t('account.setPasswordHint')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
{hasPassword
|
||||
? t('common.changePassword')
|
||||
: t('account.setPassword')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
{/* Space Account Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<Layers className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{accountType === 'space'
|
||||
? t('account.spaceBoundDescription')
|
||||
: t('account.bindSpaceDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
{accountType === 'local' && (
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBindSpace}
|
||||
disabled={
|
||||
spaceBindLoading || !systemInfo.allow_modify_login_info
|
||||
}
|
||||
>
|
||||
{spaceBindLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('account.bindSpaceButton')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PasswordChangeDialog
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={handlePasswordDialogClose}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemActions,
|
||||
} from '@/components/ui/item';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface AccountSettingsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
onEmailResolved?: (email: string) => void;
|
||||
}
|
||||
|
||||
export default function AccountSettingsPanel({
|
||||
active,
|
||||
onEmailResolved,
|
||||
}: AccountSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const info = await httpClient.getUserInfo();
|
||||
setAccountType(info.account_type);
|
||||
setHasPassword(info.has_password);
|
||||
setUserEmail(info.user);
|
||||
onEmailResolved?.(info.user);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordDialogClose = (dialogOpen: boolean) => {
|
||||
setPasswordDialogOpen(dialogOpen);
|
||||
if (!dialogOpen) {
|
||||
// Reload user info to update password status
|
||||
loadUserInfo();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelBody>
|
||||
{userEmail && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Password Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{hasPassword
|
||||
? t('account.passwordSetDescription')
|
||||
: t('account.setPasswordHint')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
{hasPassword
|
||||
? t('common.changePassword')
|
||||
: t('account.setPassword')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
{/* Space Account Item */}
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<Layers className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{accountType === 'space'
|
||||
? t('account.spaceBoundDescription')
|
||||
: t('account.bindSpaceDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
{accountType === 'local' && (
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBindSpace}
|
||||
disabled={
|
||||
spaceBindLoading || !systemInfo.allow_modify_login_info
|
||||
}
|
||||
>
|
||||
{spaceBindLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('account.bindSpaceButton')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PasswordChangeDialog
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={handlePasswordDialogClose}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
</PanelBody>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { PanelToolbar } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
@@ -55,20 +55,15 @@ interface Webhook {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiIntegrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interface ApiIntegrationPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function ApiIntegrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ApiIntegrationDialogProps) {
|
||||
export default function ApiIntegrationPanel({
|
||||
active,
|
||||
}: ApiIntegrationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('apikeys');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
@@ -91,33 +86,7 @@ export default function ApiIntegrationDialog({
|
||||
);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
// Sync URL with dialog state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showApiIntegrationSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
|
||||
return;
|
||||
}
|
||||
if (!newOpen) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// 清理 body 样式,防止对话框关闭后页面无法交互
|
||||
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
|
||||
useEffect(() => {
|
||||
if (!deleteKeyId && !deleteWebhookId) {
|
||||
const cleanup = () => {
|
||||
@@ -131,11 +100,11 @@ export default function ApiIntegrationDialog({
|
||||
}, [deleteKeyId, deleteWebhookId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (active) {
|
||||
loadApiKeys();
|
||||
loadWebhooks();
|
||||
}
|
||||
}, [open]);
|
||||
}, [active]);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setLoading(true);
|
||||
@@ -284,233 +253,209 @@ export default function ApiIntegrationDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
|
||||
{t('common.apiKeys')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="px-5 py-4 cursor-pointer"
|
||||
value="webhooks"
|
||||
>
|
||||
{t('common.webhooks')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* API Keys Tab */}
|
||||
<TabsContent
|
||||
value="apikeys"
|
||||
className="space-y-4 flex-1 flex flex-col overflow-hidden"
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
|
||||
>
|
||||
<PanelToolbar>
|
||||
<TabsList>
|
||||
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{activeTab === 'apikeys' ? (
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.apiKeyHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-auto flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t('common.apiKeyValue')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
<TabsContent
|
||||
value="webhooks"
|
||||
className="space-y-4 flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
{t('common.webhookHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateWebhookDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createWebhook')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noWebhooks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-auto flex-1 max-w-full">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[380px]">
|
||||
{t('common.webhookUrl')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.webhookEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="truncate">
|
||||
<div className="truncate">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={webhook.name}
|
||||
>
|
||||
{webhook.name}
|
||||
</div>
|
||||
{webhook.description && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
title={webhook.description}
|
||||
>
|
||||
{webhook.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-x-auto max-w-[380px]">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||
{webhook.url}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={webhook.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleWebhook(webhook)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteWebhookId(webhook.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close')}
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setShowCreateWebhookDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createWebhook')}
|
||||
</Button>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
|
||||
{/* API Keys Tab */}
|
||||
<TabsContent
|
||||
value="apikeys"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('common.apiKeyHint')}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t('common.apiKeyValue')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
<TabsContent
|
||||
value="webhooks"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('common.webhookHint')}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noWebhooks')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-full flex-1 overflow-auto rounded-md border">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
{t('common.name')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[380px]">
|
||||
{t('common.webhookUrl')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.webhookEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="truncate">
|
||||
<div className="truncate">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={webhook.name}
|
||||
>
|
||||
{webhook.name}
|
||||
</div>
|
||||
{webhook.description && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
title={webhook.description}
|
||||
>
|
||||
{webhook.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="overflow-x-auto max-w-[380px]">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||
{webhook.url}
|
||||
</code>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={webhook.enabled}
|
||||
onCheckedChange={() => handleToggleWebhook(webhook)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteWebhookId(webhook.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
@@ -87,6 +89,8 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
|
||||
const fetchLlmModels = () => {
|
||||
httpClient
|
||||
@@ -561,9 +565,11 @@ export default function DynamicFormItemComponent({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('models.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
<SettingsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={setSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -913,9 +919,11 @@ export default function DynamicFormItemComponent({
|
||||
{t('models.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
<SettingsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={setSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
|
||||
|
||||
export function getDefaultValues(
|
||||
itemConfigList: IDynamicFormItemSchema[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): Record<string, any> {
|
||||
return itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
@@ -59,7 +58,7 @@ export function getDefaultValues(
|
||||
acc[item.name] = item.default;
|
||||
return acc;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,11 +57,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
SETTINGS_ACTION_BY_SECTION,
|
||||
SETTINGS_SECTION_BY_ACTION,
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
@@ -1548,17 +1549,10 @@ export default function HomeSidebar({
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('action') === 'showModelSettings') {
|
||||
setModelsDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showAccountSettings') {
|
||||
setAccountSettingsOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
||||
setApiKeyDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
||||
setStorageAnalysisOpen(true);
|
||||
const action = searchParams.get('action');
|
||||
if (action && SETTINGS_SECTION_BY_ACTION[action]) {
|
||||
setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -1567,15 +1561,14 @@ export default function HomeSidebar({
|
||||
useState<Record<string, boolean>>(loadSectionState);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
|
||||
null,
|
||||
);
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -1600,51 +1593,28 @@ export default function HomeSidebar({
|
||||
setShowScrollHint(false);
|
||||
}, 250);
|
||||
}
|
||||
function handleModelsDialogChange(open: boolean) {
|
||||
setModelsDialogOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showModelSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
function openSettings(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
setSettingsOpen(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccountSettingsChange(open: boolean) {
|
||||
setAccountSettingsOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showAccountSettings');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
function handleSettingsSectionChange(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleStorageAnalysisChange(open: boolean) {
|
||||
setStorageAnalysisOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showStorageAnalysis');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
function handleSettingsOpenChange(open: boolean) {
|
||||
setSettingsOpen(open);
|
||||
if (!open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
@@ -1913,24 +1883,11 @@ export default function HomeSidebar({
|
||||
|
||||
{/* Footer */}
|
||||
<SidebarFooter>
|
||||
{/* API Integration entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setApiKeyDialogOpen(true)}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* Models entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleModelsDialogChange(true)}
|
||||
onClick={() => openSettings('models')}
|
||||
tooltip={t('models.title')}
|
||||
>
|
||||
<Sparkles className="text-blue-500" />
|
||||
@@ -1939,6 +1896,19 @@ export default function HomeSidebar({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* API Integration entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -2018,7 +1988,10 @@ export default function HomeSidebar({
|
||||
{/* Account actions */}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAccountSettingsChange(true)}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('account');
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
@@ -2026,7 +1999,7 @@ export default function HomeSidebar({
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
handleStorageAnalysisChange(true);
|
||||
openSettings('storageAnalysis');
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
@@ -2123,27 +2096,17 @@ export default function HomeSidebar({
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AccountSettingsDialog
|
||||
open={accountSettingsOpen}
|
||||
onOpenChange={handleAccountSettingsChange}
|
||||
/>
|
||||
<ApiIntegrationDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={handleSettingsOpenChange}
|
||||
section={settingsSection}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
/>
|
||||
<NewVersionDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
release={latestRelease}
|
||||
/>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
<StorageAnalysisDialog
|
||||
open={storageAnalysisOpen}
|
||||
onOpenChange={handleStorageAnalysisChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ import {
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface ModelsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
// Notify parent when a nested modal (provider form) should block outer-close.
|
||||
onBlockingChange?: (blocking: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
@@ -75,10 +78,10 @@ function parseContextLength(
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function ModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ModelsDialogProps) {
|
||||
export default function ModelsPanel({
|
||||
active,
|
||||
onBlockingChange,
|
||||
}: ModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
@@ -136,12 +139,17 @@ export default function ModelsDialog({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
}, [open]);
|
||||
}, [active]);
|
||||
|
||||
// Notify parent of blocking state so it can guard outer-close.
|
||||
useEffect(() => {
|
||||
onBlockingChange?.(providerFormOpen);
|
||||
}, [providerFormOpen, onBlockingChange]);
|
||||
|
||||
// Auto-expand LangBot Models when no external providers exist
|
||||
useEffect(() => {
|
||||
@@ -604,57 +612,38 @@ export default function ModelsDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && providerFormOpen) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
|
||||
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
|
||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PanelBody>
|
||||
{/* LangBot Models (Space) provider card is intentionally pinned to the
|
||||
top, above the "add custom provider" action row. */}
|
||||
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
|
||||
{/* LangBot Models Card */}
|
||||
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
||||
{/* Add-provider row: stays below the pinned card by design. */}
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{otherProviders.length === 0
|
||||
? t(
|
||||
systemInfo.disable_models_service
|
||||
? 'models.addProviderHintSimple'
|
||||
: 'models.addProviderHint',
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Provider Button */}
|
||||
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{otherProviders.length === 0
|
||||
? t(
|
||||
systemInfo.disable_models_service
|
||||
? 'models.addProviderHintSimple'
|
||||
: 'models.addProviderHint',
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
{otherProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('models.noProviders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
otherProviders.map((p) => renderProviderCard(p))
|
||||
)}
|
||||
{/* Provider List */}
|
||||
{otherProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('models.noProviders')}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
otherProviders.map((p) => renderProviderCard(p))
|
||||
)}
|
||||
</PanelBody>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-[600px] p-6">
|
||||
229
web/src/app/home/components/settings-dialog/SettingsDialog.tsx
Normal file
229
web/src/app/home/components/settings-dialog/SettingsDialog.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
|
||||
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
|
||||
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
|
||||
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
|
||||
|
||||
// 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 =
|
||||
| 'account'
|
||||
| 'apiIntegration'
|
||||
| 'models'
|
||||
| 'storageAnalysis';
|
||||
|
||||
// Map between a section id and its ?action= query value, so existing deep links
|
||||
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
|
||||
// showStorageAnalysis) continue to resolve to the right section.
|
||||
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
|
||||
account: 'showAccountSettings',
|
||||
apiIntegration: 'showApiIntegrationSettings',
|
||||
models: 'showModelSettings',
|
||||
storageAnalysis: 'showStorageAnalysis',
|
||||
};
|
||||
|
||||
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
|
||||
Object.fromEntries(
|
||||
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
|
||||
action,
|
||||
section as SettingsSection,
|
||||
]),
|
||||
);
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
section: SettingsSection;
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
}
|
||||
|
||||
export default function SettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
section,
|
||||
onSectionChange,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// A nested modal (e.g. the provider form) can request that we ignore
|
||||
// outer-close until it is dismissed.
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
|
||||
// Only the Models panel can raise a blocking nested modal. When we navigate
|
||||
// away from it (or close the dialog) the panel unmounts without resetting,
|
||||
// so clear the flag here to avoid getting stuck unable to close.
|
||||
useEffect(() => {
|
||||
if (section !== 'models' || !open) {
|
||||
setBlocking(false);
|
||||
}
|
||||
}, [section, open]);
|
||||
|
||||
const navItems: {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: 'models',
|
||||
label: t('settingsDialog.nav.models'),
|
||||
title: t('models.title'),
|
||||
description: t('models.description'),
|
||||
icon: <Sparkles className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'apiIntegration',
|
||||
label: t('settingsDialog.nav.api'),
|
||||
title: t('common.apiIntegration'),
|
||||
description: t('common.apiIntegrationDescription'),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'storageAnalysis',
|
||||
label: t('settingsDialog.nav.storage'),
|
||||
title: t('storageAnalysis.title'),
|
||||
description: t('storageAnalysis.description'),
|
||||
icon: <HardDrive className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: t('settingsDialog.nav.account'),
|
||||
title: t('account.settings'),
|
||||
description: t('account.settingsDescription'),
|
||||
icon: <Settings className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = navItems.find((item) => item.id === section);
|
||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && blocking) return;
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
|
||||
// Fixed height so switching sections never resizes the dialog; each
|
||||
// panel scrolls its own content internally.
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('settingsDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
|
||||
|
||||
{/* Override the SidebarProvider wrapper's default h-svh so the two
|
||||
columns fill the dialog's fixed height instead of the viewport. */}
|
||||
<SidebarProvider className="!min-h-0 h-full">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden h-full w-44 shrink-0 border-r md:flex"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="px-2 py-3 text-sm font-semibold">
|
||||
{t('settingsDialog.title')}
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={section === item.id}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile section switcher (sidebar is hidden on small screens) */}
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
|
||||
section === item.id
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Unified section header (shared across all tabs). The extra
|
||||
right padding keeps the title clear of the dialog's close X. */}
|
||||
<div className="flex shrink-0 flex-col gap-0.5 border-b px-6 py-4 pr-12">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold">
|
||||
{activeItem?.icon}
|
||||
{activeItem?.title}
|
||||
</h2>
|
||||
{activeItem?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeItem.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{section === 'models' && (
|
||||
<ModelsPanel
|
||||
active={open && section === 'models'}
|
||||
onBlockingChange={setBlocking}
|
||||
/>
|
||||
)}
|
||||
{section === 'apiIntegration' && (
|
||||
<ApiIntegrationPanel
|
||||
active={open && section === 'apiIntegration'}
|
||||
/>
|
||||
)}
|
||||
{section === 'storageAnalysis' && (
|
||||
<StorageAnalysisPanel
|
||||
active={open && section === 'storageAnalysis'}
|
||||
/>
|
||||
)}
|
||||
{section === 'account' && (
|
||||
<AccountSettingsPanel active={open && section === 'account'} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
45
web/src/app/home/components/settings-dialog/panel-layout.tsx
Normal file
45
web/src/app/home/components/settings-dialog/panel-layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Shared layout primitives for the settings-dialog panels.
|
||||
*
|
||||
* Every section renders under the dialog's unified header, so the panels
|
||||
* themselves should share the same vertical rhythm: an optional top toolbar
|
||||
* (meta on the left, primary action on the right) followed by a scrollable
|
||||
* body with consistent padding. Keeping these in one place is what makes the
|
||||
* tabs feel like one cohesive surface instead of four separately-styled views.
|
||||
*/
|
||||
|
||||
export function PanelToolbar({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBody({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-h-0 flex-1 overflow-auto px-6 py-5', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: StorageAnalysisDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, open]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
|
||||
<DialogHeader className="shrink-0 px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="size-5 text-blue-500" />
|
||||
{t('storageAnalysis.dialogTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('storageAnalysis.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{section.exists ? (
|
||||
<span />
|
||||
) : (
|
||||
<Badge variant="outline" className="self-center">
|
||||
{t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { PanelToolbar } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisPanel({
|
||||
active,
|
||||
}: StorageAnalysisPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, active]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<PanelToolbar>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</PanelToolbar>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{section.exists ? (
|
||||
<span />
|
||||
) : (
|
||||
<Badge variant="outline" className="self-center">
|
||||
{t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,6 @@ export default function SystemStatusCard({
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchStatus, refreshKey]);
|
||||
|
||||
const pluginOk = pluginStatus
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ModelCall,
|
||||
LLMCall,
|
||||
EmbeddingCall,
|
||||
MonitoringTrace,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
@@ -263,12 +264,48 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
messageId: error.message_id,
|
||||
}),
|
||||
),
|
||||
traces: (response.traces || []).map(
|
||||
(trace: {
|
||||
trace_id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
status: string;
|
||||
name: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
query_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}): MonitoringTrace => ({
|
||||
traceId: trace.trace_id,
|
||||
name: trace.name,
|
||||
startedAt: parseUTCTimestamp(trace.started_at),
|
||||
endedAt: trace.ended_at
|
||||
? parseUTCTimestamp(trace.ended_at)
|
||||
: undefined,
|
||||
duration: trace.duration,
|
||||
status: trace.status as 'running' | 'success' | 'error',
|
||||
botId: trace.bot_id,
|
||||
botName: trace.bot_name,
|
||||
pipelineId: trace.pipeline_id,
|
||||
pipelineName: trace.pipeline_name,
|
||||
sessionId: trace.session_id,
|
||||
messageId: trace.message_id,
|
||||
queryId: trace.query_id,
|
||||
attributes: trace.attributes || {},
|
||||
}),
|
||||
),
|
||||
totalCount: {
|
||||
messages: response.totalCount.messages,
|
||||
llmCalls: response.totalCount.llmCalls,
|
||||
embeddingCalls: response.totalCount.embeddingCalls || 0,
|
||||
sessions: response.totalCount.sessions,
|
||||
errors: response.totalCount.errors,
|
||||
traces: response.totalCount.traces || 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import OverviewCards from './components/overview-cards/OverviewCards';
|
||||
import MonitoringFilters from './components/filters/MonitoringFilters';
|
||||
@@ -22,9 +23,15 @@ import { MessageDetailsCard } from './components/MessageDetailsCard';
|
||||
import { MessageContentRenderer } from './components/MessageContentRenderer';
|
||||
import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { MessageDetails } from './types/monitoring';
|
||||
import {
|
||||
MessageDetails,
|
||||
TraceDetails,
|
||||
MonitoringSpan,
|
||||
} from './types/monitoring';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
import { parseUTCTimestamp } from './utils/dateUtils';
|
||||
|
||||
interface RawMessageData {
|
||||
id: string;
|
||||
@@ -72,6 +79,97 @@ interface RawErrorData {
|
||||
stack_trace: string | null;
|
||||
}
|
||||
|
||||
interface RawTraceData {
|
||||
trace_id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
status: string;
|
||||
name: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
query_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RawSpanData {
|
||||
span_id: string;
|
||||
trace_id: string;
|
||||
parent_span_id?: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
message_id?: string;
|
||||
session_id?: string;
|
||||
bot_id?: string;
|
||||
pipeline_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
function mapTrace(raw: RawTraceData) {
|
||||
return {
|
||||
traceId: raw.trace_id,
|
||||
name: raw.name,
|
||||
startedAt: parseUTCTimestamp(raw.started_at),
|
||||
endedAt: raw.ended_at ? parseUTCTimestamp(raw.ended_at) : undefined,
|
||||
duration: raw.duration,
|
||||
status: raw.status as 'running' | 'success' | 'error',
|
||||
botId: raw.bot_id,
|
||||
botName: raw.bot_name,
|
||||
pipelineId: raw.pipeline_id,
|
||||
pipelineName: raw.pipeline_name,
|
||||
sessionId: raw.session_id,
|
||||
messageId: raw.message_id,
|
||||
queryId: raw.query_id,
|
||||
attributes: raw.attributes || {},
|
||||
};
|
||||
}
|
||||
|
||||
function mapSpan(raw: RawSpanData): MonitoringSpan {
|
||||
return {
|
||||
spanId: raw.span_id,
|
||||
traceId: raw.trace_id,
|
||||
parentSpanId: raw.parent_span_id,
|
||||
name: raw.name,
|
||||
kind: raw.kind,
|
||||
status: raw.status as 'running' | 'success' | 'error',
|
||||
startedAt: parseUTCTimestamp(raw.started_at),
|
||||
endedAt: raw.ended_at ? parseUTCTimestamp(raw.ended_at) : undefined,
|
||||
duration: raw.duration,
|
||||
messageId: raw.message_id,
|
||||
sessionId: raw.session_id,
|
||||
botId: raw.bot_id,
|
||||
pipelineId: raw.pipeline_id,
|
||||
attributes: raw.attributes || {},
|
||||
errorMessage: raw.error_message,
|
||||
};
|
||||
}
|
||||
|
||||
function spanDepth(
|
||||
span: MonitoringSpan,
|
||||
spansById: Map<string, MonitoringSpan>,
|
||||
) {
|
||||
let depth = 0;
|
||||
let current = span.parentSpanId
|
||||
? spansById.get(span.parentSpanId)
|
||||
: undefined;
|
||||
while (current && depth < 8) {
|
||||
depth += 1;
|
||||
current = current.parentSpanId
|
||||
? spansById.get(current.parentSpanId)
|
||||
: undefined;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function MonitoringPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
@@ -158,6 +256,13 @@ function MonitoringPageContent() {
|
||||
|
||||
// State for expanded errors
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||
const [expandedTraceId, setExpandedTraceId] = useState<string | null>(null);
|
||||
const [traceDetails, setTraceDetails] = useState<
|
||||
Record<string, TraceDetails>
|
||||
>({});
|
||||
const [loadingTraceDetails, setLoadingTraceDetails] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// State for controlled tabs
|
||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||
@@ -265,6 +370,34 @@ function MonitoringPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTraceExpand = async (traceId: string) => {
|
||||
if (expandedTraceId === traceId) {
|
||||
setExpandedTraceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedTraceId(traceId);
|
||||
if (traceDetails[traceId]) return;
|
||||
|
||||
setLoadingTraceDetails((prev) => ({ ...prev, [traceId]: true }));
|
||||
try {
|
||||
const result = await backendClient.getMonitoringTraceDetails(traceId);
|
||||
setTraceDetails((prev) => ({
|
||||
...prev,
|
||||
[traceId]: {
|
||||
traceId: result.trace_id,
|
||||
found: result.found,
|
||||
trace: result.trace ? mapTrace(result.trace) : undefined,
|
||||
spans: (result.spans || []).map(mapSpan),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch trace details:', error);
|
||||
} finally {
|
||||
setLoadingTraceDetails((prev) => ({ ...prev, [traceId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
{/* Filters and Refresh Button - Sticky */}
|
||||
@@ -323,6 +456,9 @@ function MonitoringPageContent() {
|
||||
<TabsTrigger value="tokens" className="px-6 py-2">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="traces" className="px-6 py-2">
|
||||
{t('monitoring.tabs.traces')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-6 py-2">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
@@ -690,6 +826,166 @@ function MonitoringPageContent() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="traces" className="p-6 m-0">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.traces && data.traces.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.traces.map((trace) => {
|
||||
const details = traceDetails[trace.traceId];
|
||||
const spans = details?.spans || [];
|
||||
const spansById = new Map(
|
||||
spans.map((span) => [span.spanId, span]),
|
||||
);
|
||||
const maxDuration = Math.max(
|
||||
1,
|
||||
...spans.map((span) => span.duration || 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trace.traceId}
|
||||
className="border rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
<div
|
||||
className="p-5 cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => toggleTraceExpand(trace.traceId)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start flex-1 min-w-0">
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedTraceId === trace.traceId ? (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{trace.traceId}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
trace.status === 'error'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
: trace.status === 'running'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
}`}
|
||||
>
|
||||
{trace.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-sm text-foreground mb-1">
|
||||
{trace.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{trace.botName || '-'} →{' '}
|
||||
{trace.pipelineName || '-'}
|
||||
{trace.sessionId
|
||||
? ` · ${trace.sessionId}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span>{trace.startedAt.toLocaleString()}</span>
|
||||
<span>{trace.duration ?? 0}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedTraceId === trace.traceId && (
|
||||
<div className="border-t p-5 bg-muted">
|
||||
{loadingTraceDetails[trace.traceId] && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<LoadingSpinner size="sm" text="" />
|
||||
</div>
|
||||
)}
|
||||
{!loadingTraceDetails[trace.traceId] && (
|
||||
<div className="space-y-3">
|
||||
{spans.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('monitoring.traces.noSpans')}
|
||||
</div>
|
||||
)}
|
||||
{spans.map((span) => {
|
||||
const depth = spanDepth(span, spansById);
|
||||
const width = Math.max(
|
||||
6,
|
||||
Math.min(
|
||||
100,
|
||||
((span.duration || 0) / maxDuration) *
|
||||
100,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={span.spanId}
|
||||
className="grid grid-cols-[minmax(180px,1fr)_minmax(140px,2fr)_80px] gap-3 items-center text-xs"
|
||||
>
|
||||
<div
|
||||
className="min-w-0"
|
||||
style={{
|
||||
paddingLeft: `${depth * 16}px`,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{span.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate">
|
||||
{span.kind}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-7 bg-background rounded border overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${
|
||||
span.status === 'error'
|
||||
? 'bg-red-500/70'
|
||||
: 'bg-blue-500/70'
|
||||
}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{span.duration ?? 0}ms
|
||||
</div>
|
||||
{span.errorMessage && (
|
||||
<div className="col-span-3 text-red-600 dark:text-red-400 bg-background rounded p-2">
|
||||
{span.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.traces || data.traces.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<GitBranch className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.traces.noTraces')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-6 m-0">
|
||||
<div>
|
||||
{loading && (
|
||||
|
||||
@@ -111,6 +111,48 @@ export interface ErrorLog {
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface MonitoringTrace {
|
||||
traceId: string;
|
||||
name: string;
|
||||
startedAt: Date;
|
||||
endedAt?: Date;
|
||||
duration?: number;
|
||||
status: 'running' | 'success' | 'error';
|
||||
botId?: string;
|
||||
botName?: string;
|
||||
pipelineId?: string;
|
||||
pipelineName?: string;
|
||||
sessionId?: string;
|
||||
messageId?: string;
|
||||
queryId?: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MonitoringSpan {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
parentSpanId?: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
status: 'success' | 'error' | 'running';
|
||||
startedAt: Date;
|
||||
endedAt?: Date;
|
||||
duration?: number;
|
||||
messageId?: string;
|
||||
sessionId?: string;
|
||||
botId?: string;
|
||||
pipelineId?: string;
|
||||
attributes: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface TraceDetails {
|
||||
traceId: string;
|
||||
found: boolean;
|
||||
trace?: MonitoringTrace;
|
||||
spans: MonitoringSpan[];
|
||||
}
|
||||
|
||||
export interface MessageDetails {
|
||||
messageId: string;
|
||||
found: boolean;
|
||||
@@ -125,6 +167,7 @@ export interface MessageDetails {
|
||||
averageDurationMs: number;
|
||||
};
|
||||
errors: ErrorLog[];
|
||||
trace?: MonitoringTrace;
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
@@ -203,6 +246,7 @@ export interface MonitoringData {
|
||||
modelCalls: ModelCall[];
|
||||
sessions: SessionInfo[];
|
||||
errors: ErrorLog[];
|
||||
traces: MonitoringTrace[];
|
||||
feedback?: FeedbackRecord[];
|
||||
feedbackStats?: FeedbackStats;
|
||||
totalCount: {
|
||||
@@ -211,6 +255,7 @@ export interface MonitoringData {
|
||||
embeddingCalls: number;
|
||||
sessions: number;
|
||||
errors: number;
|
||||
traces: number;
|
||||
feedback?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,7 +323,6 @@ export default function PipelineFormComponent({
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
@@ -368,7 +367,6 @@ export default function PipelineFormComponent({
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
@@ -402,7 +400,6 @@ export default function PipelineFormComponent({
|
||||
<N8nAuthFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
@@ -445,7 +442,7 @@ export default function PipelineFormComponent({
|
||||
// make the locked selector display a scope that is NOT the one actually in
|
||||
// effect. Coerce the displayed/saved value to the forced template so the UI
|
||||
// truthfully reflects runtime behavior.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
const effectiveInitialValues =
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface IPluginCardVO {
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
install_info: Record<string, any>;
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
debug: boolean;
|
||||
@@ -27,7 +27,7 @@ export class PluginCardVO implements IPluginCardVO {
|
||||
priority: number;
|
||||
debug: boolean;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
install_info: Record<string, any>;
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
hasUpdate?: boolean;
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ComponentManifest {
|
||||
version?: string;
|
||||
author?: string;
|
||||
};
|
||||
spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
spec: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CustomApiError {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SYSTEM_FIELD_PREFIX = '__system.';
|
||||
export interface IShowIfCondition {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'in';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
value: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Plugin {
|
||||
debug: boolean;
|
||||
enabled: boolean;
|
||||
install_source: string;
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
install_info: Record<string, any>;
|
||||
components: PluginComponent[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1185,12 +1185,29 @@ export class BackendClient extends BaseHttpClient {
|
||||
stack_trace?: string;
|
||||
message_id?: string;
|
||||
}>;
|
||||
traces?: Array<{
|
||||
trace_id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
status: string;
|
||||
name: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
query_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}>;
|
||||
totalCount: {
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
embeddingCalls: number;
|
||||
sessions: number;
|
||||
errors: number;
|
||||
traces?: number;
|
||||
};
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
@@ -1213,6 +1230,90 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getMonitoringTraces(params: {
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
traces: Array<{
|
||||
trace_id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
status: string;
|
||||
name: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
query_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botId) {
|
||||
params.botId.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineId) {
|
||||
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.append('limit', params.limit.toString());
|
||||
}
|
||||
return this.get(`/api/v1/monitoring/traces?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getMonitoringTraceDetails(traceId: string): Promise<{
|
||||
trace_id: string;
|
||||
found: boolean;
|
||||
trace: {
|
||||
trace_id: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
status: string;
|
||||
name: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
query_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
};
|
||||
spans: Array<{
|
||||
span_id: string;
|
||||
trace_id: string;
|
||||
parent_span_id?: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
duration?: number;
|
||||
message_id?: string;
|
||||
session_id?: string;
|
||||
bot_id?: string;
|
||||
pipeline_id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
error_message?: string;
|
||||
}>;
|
||||
}> {
|
||||
return this.get(`/api/v1/monitoring/traces/${traceId}`);
|
||||
}
|
||||
|
||||
public getMonitoringOverview(params: {
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function WizardPage() {
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
|
||||
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
|
||||
const [botName, setBotName] = useState('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const [botDescription, _setBotDescription] = useState('');
|
||||
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
|
||||
{},
|
||||
|
||||
@@ -122,6 +122,8 @@ const enUS = {
|
||||
changePasswordFailed:
|
||||
'Failed to change password, please check your current password',
|
||||
apiIntegration: 'API Integration',
|
||||
apiIntegrationDescription:
|
||||
'Manage API keys and webhooks for external access',
|
||||
apiKeys: 'API Keys',
|
||||
manageApiIntegration: 'Manage API Integration',
|
||||
manageApiKeys: 'Manage API Keys',
|
||||
@@ -1149,6 +1151,7 @@ const enUS = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Account Settings',
|
||||
settingsDescription: 'Manage your password and linked accounts',
|
||||
setPassword: 'Set Password',
|
||||
passwordSetSuccess: 'Password set successfully',
|
||||
passwordStatus: 'Local Password',
|
||||
@@ -1214,6 +1217,7 @@ const enUS = {
|
||||
embeddingCalls: 'Embedding Calls',
|
||||
modelCalls: 'Model Calls',
|
||||
tokens: 'Token Monitoring',
|
||||
traces: 'Traces',
|
||||
feedback: 'User Feedback',
|
||||
sessions: 'Session Analysis',
|
||||
errors: 'Error Logs',
|
||||
@@ -1318,6 +1322,11 @@ const enUS = {
|
||||
noErrors: 'No errors found',
|
||||
stackTrace: 'Stack Trace',
|
||||
},
|
||||
traces: {
|
||||
title: 'Traces',
|
||||
noTraces: 'No traces found',
|
||||
noSpans: 'No spans recorded for this trace',
|
||||
},
|
||||
feedback: {
|
||||
title: 'User Feedback',
|
||||
totalFeedback: 'Total Feedback',
|
||||
@@ -1386,6 +1395,15 @@ const enUS = {
|
||||
boxSessionCreated: 'Created',
|
||||
boxSessionLastUsed: 'Last used',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Settings',
|
||||
nav: {
|
||||
models: 'Models',
|
||||
api: 'API',
|
||||
storage: 'Storage',
|
||||
account: 'Account',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Storage Analysis',
|
||||
description: 'Inspect storage usage and cleanup candidates',
|
||||
|
||||
@@ -126,6 +126,8 @@ const esES = {
|
||||
changePasswordFailed:
|
||||
'Error al cambiar la contraseña, por favor verifica tu contraseña actual',
|
||||
apiIntegration: 'Integración API',
|
||||
apiIntegrationDescription:
|
||||
'Gestiona las claves API y los webhooks para el acceso externo',
|
||||
apiKeys: 'Claves API',
|
||||
manageApiIntegration: 'Gestionar integración API',
|
||||
manageApiKeys: 'Gestionar claves API',
|
||||
@@ -844,7 +846,7 @@ const esES = {
|
||||
'Una vez eliminada, la configuración de este servidor MCP no se podrá recuperar.',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipelines',
|
||||
title: 'Flujos',
|
||||
description:
|
||||
'Los Pipelines definen el flujo de procesamiento de eventos de mensajes, se usan para vincular a los Bots',
|
||||
createPipeline: 'Crear Pipeline',
|
||||
@@ -1178,6 +1180,7 @@ const esES = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Configuración de la cuenta',
|
||||
settingsDescription: 'Gestiona tu contraseña y las cuentas vinculadas',
|
||||
setPassword: 'Establecer contraseña',
|
||||
passwordSetSuccess: 'Contraseña establecida correctamente',
|
||||
passwordStatus: 'Contraseña local',
|
||||
@@ -1419,6 +1422,15 @@ const esES = {
|
||||
boxSessionCreated: 'Creado',
|
||||
boxSessionLastUsed: 'Último uso',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Configuración',
|
||||
nav: {
|
||||
models: 'Modelos',
|
||||
api: 'API',
|
||||
storage: 'Almacenamiento',
|
||||
account: 'Cuenta',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Análisis de almacenamiento',
|
||||
description:
|
||||
|
||||
@@ -124,6 +124,8 @@ const jaJP = {
|
||||
changePasswordFailed:
|
||||
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
|
||||
apiIntegration: 'API統合',
|
||||
apiIntegrationDescription:
|
||||
'外部アクセス用の API キーと Webhook を管理します',
|
||||
apiKeys: 'API キー',
|
||||
manageApiIntegration: 'API統合の管理',
|
||||
manageApiKeys: 'API キーの管理',
|
||||
@@ -1153,6 +1155,7 @@ const jaJP = {
|
||||
},
|
||||
account: {
|
||||
settings: 'アカウント設定',
|
||||
settingsDescription: 'パスワードと連携アカウントを管理します',
|
||||
setPassword: 'パスワードを設定',
|
||||
passwordSetSuccess: 'パスワードの設定に成功しました',
|
||||
passwordStatus: 'ローカルパスワード',
|
||||
@@ -1392,6 +1395,15 @@ const jaJP = {
|
||||
boxSessionCreated: '作成日時',
|
||||
boxSessionLastUsed: '最終使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
nav: {
|
||||
models: 'モデル',
|
||||
api: 'API',
|
||||
storage: 'ストレージ',
|
||||
account: 'アカウント',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'ストレージ分析',
|
||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
|
||||
@@ -122,6 +122,8 @@ const ruRU = {
|
||||
changePasswordFailed:
|
||||
'Не удалось изменить пароль, проверьте текущий пароль',
|
||||
apiIntegration: 'API-интеграция',
|
||||
apiIntegrationDescription:
|
||||
'Управление API-ключами и вебхуками для внешнего доступа',
|
||||
apiKeys: 'API-ключи',
|
||||
manageApiIntegration: 'Управление API-интеграцией',
|
||||
manageApiKeys: 'Управление API-ключами',
|
||||
@@ -1156,6 +1158,7 @@ const ruRU = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Настройки аккаунта',
|
||||
settingsDescription: 'Управление паролем и связанными аккаунтами',
|
||||
setPassword: 'Установить пароль',
|
||||
passwordSetSuccess: 'Пароль успешно установлен',
|
||||
passwordStatus: 'Локальный пароль',
|
||||
@@ -1395,6 +1398,15 @@ const ruRU = {
|
||||
boxSessionCreated: 'Создано',
|
||||
boxSessionLastUsed: 'Последнее использование',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Настройки',
|
||||
nav: {
|
||||
models: 'Модели',
|
||||
api: 'API',
|
||||
storage: 'Хранилище',
|
||||
account: 'Аккаунт',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Анализ хранилища',
|
||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
|
||||
@@ -120,6 +120,8 @@ const thTH = {
|
||||
changePasswordSuccess: 'เปลี่ยนรหัสผ่านสำเร็จ',
|
||||
changePasswordFailed: 'เปลี่ยนรหัสผ่านล้มเหลว กรุณาตรวจสอบรหัสผ่านปัจจุบัน',
|
||||
apiIntegration: 'การเชื่อมต่อ API',
|
||||
apiIntegrationDescription:
|
||||
'จัดการ API key และ webhook สำหรับการเข้าถึงจากภายนอก',
|
||||
apiKeys: 'คีย์ API',
|
||||
manageApiIntegration: 'จัดการการเชื่อมต่อ API',
|
||||
manageApiKeys: 'จัดการคีย์ API',
|
||||
@@ -300,7 +302,7 @@ const thTH = {
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: 'Bot',
|
||||
title: 'บอท',
|
||||
description:
|
||||
'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ',
|
||||
createBot: 'สร้าง Bot',
|
||||
@@ -819,7 +821,7 @@ const thTH = {
|
||||
'เมื่อลบแล้ว การกำหนดค่าเซิร์ฟเวอร์ MCP นี้จะไม่สามารถกู้คืนได้',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipeline',
|
||||
title: 'ไปป์ไลน์',
|
||||
description:
|
||||
'Pipeline กำหนดกระบวนการประมวลผลเหตุการณ์ข้อความ ใช้เพื่อผูกกับ Bot',
|
||||
createPipeline: 'สร้าง Pipeline',
|
||||
@@ -1130,6 +1132,7 @@ const thTH = {
|
||||
},
|
||||
account: {
|
||||
settings: 'การตั้งค่าบัญชี',
|
||||
settingsDescription: 'จัดการรหัสผ่านและบัญชีที่เชื่อมโยงของคุณ',
|
||||
setPassword: 'ตั้งรหัสผ่าน',
|
||||
passwordSetSuccess: 'ตั้งรหัสผ่านสำเร็จ',
|
||||
passwordStatus: 'รหัสผ่านท้องถิ่น',
|
||||
@@ -1364,6 +1367,15 @@ const thTH = {
|
||||
boxSessionCreated: 'สร้างเมื่อ',
|
||||
boxSessionLastUsed: 'ใช้ล่าสุด',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'การตั้งค่า',
|
||||
nav: {
|
||||
models: 'โมเดล',
|
||||
api: 'API',
|
||||
storage: 'พื้นที่จัดเก็บ',
|
||||
account: 'บัญชี',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
|
||||
@@ -123,6 +123,8 @@ const viVN = {
|
||||
changePasswordFailed:
|
||||
'Đổi mật khẩu thất bại, vui lòng kiểm tra mật khẩu hiện tại',
|
||||
apiIntegration: 'Tích hợp API',
|
||||
apiIntegrationDescription:
|
||||
'Quản lý API key và webhook cho truy cập từ bên ngoài',
|
||||
apiKeys: 'Khóa API',
|
||||
manageApiIntegration: 'Quản lý tích hợp API',
|
||||
manageApiKeys: 'Quản lý khóa API',
|
||||
@@ -833,7 +835,7 @@ const viVN = {
|
||||
deleteMCPHint: 'Sau khi xóa, cấu hình máy chủ MCP này không thể khôi phục.',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'Pipeline',
|
||||
title: 'Quy trình',
|
||||
description:
|
||||
'Pipeline xác định luồng xử lý sự kiện tin nhắn, dùng để liên kết với Bot',
|
||||
createPipeline: 'Tạo Pipeline',
|
||||
@@ -1150,6 +1152,7 @@ const viVN = {
|
||||
},
|
||||
account: {
|
||||
settings: 'Cài đặt tài khoản',
|
||||
settingsDescription: 'Quản lý mật khẩu và các tài khoản liên kết của bạn',
|
||||
setPassword: 'Đặt mật khẩu',
|
||||
passwordSetSuccess: 'Đặt mật khẩu thành công',
|
||||
passwordStatus: 'Mật khẩu cục bộ',
|
||||
@@ -1388,6 +1391,15 @@ const viVN = {
|
||||
boxSessionCreated: 'Đã tạo',
|
||||
boxSessionLastUsed: 'Lần cuối sử dụng',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: 'Cài đặt',
|
||||
nav: {
|
||||
models: 'Mô hình',
|
||||
api: 'API',
|
||||
storage: 'Lưu trữ',
|
||||
account: 'Tài khoản',
|
||||
},
|
||||
},
|
||||
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',
|
||||
|
||||
@@ -116,6 +116,7 @@ const zhHans = {
|
||||
changePasswordSuccess: '密码修改成功',
|
||||
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
|
||||
apiIntegration: 'API 集成',
|
||||
apiIntegrationDescription: '管理用于外部访问的 API 密钥和 Webhook',
|
||||
apiKeys: 'API 密钥',
|
||||
manageApiIntegration: '管理 API 集成',
|
||||
manageApiKeys: '管理 API 密钥',
|
||||
@@ -1096,6 +1097,7 @@ const zhHans = {
|
||||
},
|
||||
account: {
|
||||
settings: '账户设置',
|
||||
settingsDescription: '管理你的密码和关联账户',
|
||||
setPassword: '设置密码',
|
||||
passwordSetSuccess: '密码设置成功',
|
||||
passwordStatus: '本地密码',
|
||||
@@ -1156,6 +1158,7 @@ const zhHans = {
|
||||
embeddingCalls: 'Embedding调用',
|
||||
modelCalls: '模型调用',
|
||||
tokens: 'Token 监控',
|
||||
traces: '链路追踪',
|
||||
feedback: '用户反馈',
|
||||
sessions: '会话分析',
|
||||
errors: '错误日志',
|
||||
@@ -1260,6 +1263,11 @@ const zhHans = {
|
||||
noErrors: '未找到错误',
|
||||
stackTrace: '堆栈追踪',
|
||||
},
|
||||
traces: {
|
||||
title: '链路追踪',
|
||||
noTraces: '未找到链路记录',
|
||||
noSpans: '此链路暂无 Span 记录',
|
||||
},
|
||||
feedback: {
|
||||
title: '用户反馈',
|
||||
totalFeedback: '总反馈数',
|
||||
@@ -1328,6 +1336,15 @@ const zhHans = {
|
||||
boxSessionCreated: '创建时间',
|
||||
boxSessionLastUsed: '最后使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '设置',
|
||||
nav: {
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '存储',
|
||||
account: '账户',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '存储分析',
|
||||
description: '查看存储占用和可清理文件',
|
||||
|
||||
@@ -116,6 +116,7 @@ const zhHant = {
|
||||
changePasswordSuccess: '密碼修改成功',
|
||||
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
|
||||
apiIntegration: 'API 整合',
|
||||
apiIntegrationDescription: '管理用於外部存取的 API 金鑰和 Webhook',
|
||||
apiKeys: 'API 金鑰',
|
||||
manageApiIntegration: '管理 API 整合',
|
||||
manageApiKeys: '管理 API 金鑰',
|
||||
@@ -1095,6 +1096,7 @@ const zhHant = {
|
||||
},
|
||||
account: {
|
||||
settings: '帳戶設定',
|
||||
settingsDescription: '管理你的密碼和關聯帳戶',
|
||||
setPassword: '設定密碼',
|
||||
passwordSetSuccess: '密碼設定成功',
|
||||
passwordStatus: '本地密碼',
|
||||
@@ -1327,6 +1329,15 @@ const zhHant = {
|
||||
boxSessionCreated: '建立時間',
|
||||
boxSessionLastUsed: '最後使用',
|
||||
},
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
nav: {
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '儲存',
|
||||
account: '帳戶',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '儲存分析',
|
||||
description: '查看儲存占用和可清理檔案',
|
||||
|
||||
455
web/tests/e2e/crud-smoke.spec.ts
Normal file
455
web/tests/e2e/crud-smoke.spec.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
async function save(page: Page) {
|
||||
const button = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
}
|
||||
|
||||
async function submit(page: Page) {
|
||||
await page.getByRole('button', { name: /^Submit$/ }).click();
|
||||
}
|
||||
|
||||
async function confirmDelete(page: Page) {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^Confirm Delete$/ })
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('creates, edits, and deletes a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Support Bot');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Answers customer support questions.');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Answers customer support questions with context.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Answers customer support questions with context.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots$/);
|
||||
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a pipeline', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.locator('input[name="basic.name"]').fill('Escalation Pipeline');
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Routes urgent customer issues.');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
|
||||
'Escalation Pipeline',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Routes urgent customer issues to operators.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
|
||||
'Routes urgent customer issues to operators.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/pipelines$/);
|
||||
await expect(
|
||||
page.getByText('Select a pipeline from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/knowledge?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('Support Knowledge');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Source material for support answers.');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'Support Knowledge',
|
||||
);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Updated source material for support answers.');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="description"]')).toHaveValue(
|
||||
'Updated source material for support answers.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/knowledge$/);
|
||||
await expect(
|
||||
page.getByText('Select a knowledge base from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes an MCP server', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/mcp?id=new');
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
await page.locator('input[name="name"]').fill('playwright-mcp');
|
||||
await page
|
||||
.locator('input[name="url"]')
|
||||
.fill('https://mcp.example.test/sse');
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/mcp\?id=playwright-mcp$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue(
|
||||
'playwright-mcp',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('input[name="url"]')
|
||||
.fill('https://mcp.example.test/updated-sse');
|
||||
await save(page);
|
||||
await expect(page.locator('input[name="url"]')).toHaveValue(
|
||||
'https://mcp.example.test/updated-sse',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/mcp$/);
|
||||
await expect(
|
||||
page.getByText('Select an MCP server from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('updates and deletes a manually-created skill', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/skills?action=create');
|
||||
|
||||
await page.locator('#display_name').fill('Release Notes');
|
||||
await page.locator('#name').fill('release_notes');
|
||||
await page.locator('#description').fill('Drafts release notes.');
|
||||
await page
|
||||
.locator('#instructions')
|
||||
.fill('Summarize merged changes for the next release.');
|
||||
await save(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/skills\?id=release_notes$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Drafts release notes.',
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('#description')
|
||||
.fill('Drafts concise release notes for maintainers.');
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Drafts concise release notes for maintainers.',
|
||||
);
|
||||
await save(page);
|
||||
await page.reload();
|
||||
await expect(page.locator('#description')).toHaveValue(
|
||||
'Drafts concise release notes for maintainers.',
|
||||
);
|
||||
await expect(page.locator('#instructions')).toHaveValue(
|
||||
'Summarize merged changes for the next release.',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /^Delete$/ }).click();
|
||||
await confirmDelete(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/add-extension$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('bot advanced flows', () => {
|
||||
test('toggles bot enable/disable state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot first
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Toggle Test Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for the enable switch to load (it's fetched via getBot)
|
||||
await expect(page.locator('#bot-enable-switch')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify initial state is enabled
|
||||
await expect(page.locator('#bot-enable-switch')).toBeChecked();
|
||||
|
||||
// Toggle to disabled
|
||||
await page.locator('#bot-enable-switch').click();
|
||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
||||
|
||||
// Reload and verify state persisted
|
||||
await page.reload();
|
||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('switches between bot detail tabs', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Tab Test Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
|
||||
// Switch to Logs tab
|
||||
await page.getByRole('tab', { name: /Logs/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Logs/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch to Sessions tab
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button is disabled when form is clean', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Clean Form Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// After creation, save button should be disabled (form is clean)
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeDisabled();
|
||||
|
||||
// Edit the form
|
||||
await page.locator('input[name="description"]').fill('New description');
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows validation error when bot name is empty', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
|
||||
// Select adapter but leave name empty
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
|
||||
// Should show validation error for name (zod validation)
|
||||
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=new$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pipeline advanced flows', () => {
|
||||
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Verify we're on the Configuration tab
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /Configuration/ }),
|
||||
).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
|
||||
// Skip Debug tab as it requires WebSocket connection
|
||||
await page.getByRole('tab', { name: /Dashboard/ }).click();
|
||||
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
|
||||
// Switch back to Configuration
|
||||
await page.getByRole('tab', { name: /Configuration/ }).click();
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('save button reflects form dirty state', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline');
|
||||
await submit(page);
|
||||
|
||||
// Wait for the page to fully load and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the form - use the name field which definitely triggers dirty state
|
||||
await page
|
||||
.locator('input[name="basic.name"]')
|
||||
.fill('Dirty Form Pipeline Updated');
|
||||
const saveButton = page.getByRole('button', { name: /^Save$/ });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
// Save
|
||||
await saveButton.click();
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('shows validation error when pipeline name is empty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
|
||||
// Submit without filling name
|
||||
await submit(page);
|
||||
|
||||
// Should show validation error for name (zod validation)
|
||||
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=new$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('cross-resource flows', () => {
|
||||
test('creates a pipeline then binds it to a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
// Create a pipeline first
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Production Pipeline');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
|
||||
// Create a bot
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Bound Bot');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
// Wait for form to fully load
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
|
||||
|
||||
// Find the pipeline select by its label "Bind Pipeline"
|
||||
const pipelineCard = page.getByText('Bind Pipeline').locator('..');
|
||||
await expect(pipelineCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click on the select trigger within the pipeline binding card
|
||||
// The select trigger shows "Select Pipeline" placeholder initially
|
||||
const pipelineSelectTrigger = page.getByText('Select Pipeline').first();
|
||||
await pipelineSelectTrigger.click();
|
||||
|
||||
// Select the pipeline option
|
||||
await page.getByRole('option', { name: 'Production Pipeline' }).click();
|
||||
|
||||
// Save the bot
|
||||
await save(page);
|
||||
|
||||
// Reload and verify binding persisted
|
||||
await page.reload();
|
||||
// The pipeline name should appear in the select trigger (not in sidebar or options)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="select-trigger"]')
|
||||
.filter({ hasText: 'Production Pipeline' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('empty states', () => {
|
||||
test('shows empty state when no bots exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/bots');
|
||||
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no pipelines exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines');
|
||||
await expect(
|
||||
page.getByText('Select a pipeline from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no knowledge bases exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/knowledge');
|
||||
await expect(
|
||||
page.getByText('Select a knowledge base from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no MCP servers exist', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/mcp');
|
||||
await expect(
|
||||
page.getByText('Select an MCP server from the sidebar'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,68 @@ interface SkillMock {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface PipelineMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config: JsonRecord;
|
||||
emoji: string;
|
||||
is_default: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface KnowledgeBaseMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
knowledge_engine_plugin_id: string;
|
||||
creation_settings: JsonRecord;
|
||||
retrieval_settings: JsonRecord;
|
||||
knowledge_engine: {
|
||||
plugin_id: string;
|
||||
name: {
|
||||
en_US: string;
|
||||
zh_Hans: string;
|
||||
};
|
||||
capabilities: string[];
|
||||
};
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MCPServerMock {
|
||||
name: string;
|
||||
mode: 'sse' | 'stdio' | 'http';
|
||||
enable: boolean;
|
||||
extra_args: JsonRecord;
|
||||
runtime_info: {
|
||||
status: 'connected';
|
||||
tool_count: number;
|
||||
tools: unknown[];
|
||||
};
|
||||
readme: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface BotMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enable: boolean;
|
||||
adapter: string;
|
||||
adapter_config: JsonRecord;
|
||||
use_pipeline_uuid?: string;
|
||||
pipeline_routing_rules: unknown[];
|
||||
adapter_runtime_values: JsonRecord;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface LangBotApiMockState {
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
mcpServers: MCPServerMock[];
|
||||
pipelines: PipelineMock[];
|
||||
skills: SkillMock[];
|
||||
}
|
||||
|
||||
@@ -36,6 +97,19 @@ function routePath(route: Route) {
|
||||
return new URL(route.request().url()).pathname;
|
||||
}
|
||||
|
||||
function parseJsonBody(route: Route): JsonRecord {
|
||||
return JSON.parse(route.request().postData() || '{}') as JsonRecord;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function nextId(state: LangBotApiMockState, prefix: string) {
|
||||
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
|
||||
return `${prefix}-${state.counters[prefix]}`;
|
||||
}
|
||||
|
||||
function emptyMonitoringData() {
|
||||
return {
|
||||
overview: {
|
||||
@@ -93,6 +167,131 @@ function makeSkill(data: JsonRecord): SkillMock {
|
||||
};
|
||||
}
|
||||
|
||||
function makePipeline(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'pipeline'),
|
||||
): PipelineMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
config: (data.config as JsonRecord | undefined) || {
|
||||
ai: {},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
emoji: String(data.emoji || '⚙️'),
|
||||
is_default: false,
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function knowledgeEngine() {
|
||||
return {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
name: {
|
||||
en_US: 'Minimal Knowledge Engine',
|
||||
zh_Hans: '最小知识库引擎',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimal mocked engine for frontend smoke tests.',
|
||||
zh_Hans: '用于前端冒烟测试的最小模拟引擎。',
|
||||
},
|
||||
capabilities: ['text_retrieval'],
|
||||
creation_schema: [],
|
||||
retrieval_schema: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeKnowledgeBase(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'knowledge'),
|
||||
): KnowledgeBaseMock {
|
||||
const engine = knowledgeEngine();
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
emoji: String(data.emoji || '📚'),
|
||||
knowledge_engine_plugin_id: String(
|
||||
data.knowledge_engine_plugin_id || engine.plugin_id,
|
||||
),
|
||||
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
|
||||
retrieval_settings:
|
||||
(data.retrieval_settings as JsonRecord | undefined) || {},
|
||||
knowledge_engine: {
|
||||
plugin_id: engine.plugin_id,
|
||||
name: engine.name,
|
||||
capabilities: engine.capabilities,
|
||||
},
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMCPServer(data: JsonRecord): MCPServerMock {
|
||||
return {
|
||||
name: String(data.name || ''),
|
||||
mode: (data.mode as MCPServerMock['mode']) || 'sse',
|
||||
enable: data.enable !== false,
|
||||
extra_args: (data.extra_args as JsonRecord | undefined) || {},
|
||||
runtime_info: {
|
||||
status: 'connected',
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
},
|
||||
readme: '',
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBot(
|
||||
state: LangBotApiMockState,
|
||||
data: JsonRecord,
|
||||
uuid = nextId(state, 'bot'),
|
||||
): BotMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
enable: data.enable !== false,
|
||||
adapter: String(data.adapter || 'playwright-adapter'),
|
||||
adapter_config: (data.adapter_config as JsonRecord | undefined) || {},
|
||||
use_pipeline_uuid: data.use_pipeline_uuid
|
||||
? String(data.use_pipeline_uuid)
|
||||
: undefined,
|
||||
pipeline_routing_rules:
|
||||
(data.pipeline_routing_rules as unknown[] | undefined) || [],
|
||||
adapter_runtime_values: {
|
||||
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
|
||||
extra_webhook_full_url: '',
|
||||
},
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockAdapters() {
|
||||
return [
|
||||
{
|
||||
name: 'playwright-adapter',
|
||||
label: {
|
||||
en_US: 'Playwright Adapter',
|
||||
zh_Hans: 'Playwright 适配器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimal adapter for frontend E2E tests.',
|
||||
zh_Hans: '用于前端 E2E 测试的最小适配器。',
|
||||
},
|
||||
spec: {
|
||||
categories: ['testing'],
|
||||
config: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
@@ -147,16 +346,160 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, { credits: null });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/adapters') {
|
||||
return fulfillJson(route, { adapters: mockAdapters() });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/bots') {
|
||||
return fulfillJson(route, { bots: [] });
|
||||
if (method === 'POST') {
|
||||
const bot = makeBot(state, parseJsonBody(route));
|
||||
state.bots = [
|
||||
...state.bots.filter((item) => item.uuid !== bot.uuid),
|
||||
bot,
|
||||
];
|
||||
return fulfillJson(route, { uuid: bot.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { bots: state.bots });
|
||||
}
|
||||
|
||||
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
|
||||
if (botLogsMatch) {
|
||||
return fulfillJson(route, { logs: [], total: 0 });
|
||||
}
|
||||
|
||||
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
|
||||
if (botMatch) {
|
||||
const botId = decodeURIComponent(botMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const bot = makeBot(state, parseJsonBody(route), botId);
|
||||
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.bots = state.bots.filter((item) => item.uuid !== botId);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const bot = state.bots.find((item) => item.uuid === botId);
|
||||
return fulfillJson(route, {
|
||||
bot: bot || makeBot(state, { name: botId }, botId),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines/_/metadata') {
|
||||
return fulfillJson(route, { configs: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines') {
|
||||
return fulfillJson(route, { pipelines: [] });
|
||||
if (method === 'POST') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route));
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, { uuid: pipeline.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { pipelines: state.pipelines });
|
||||
}
|
||||
|
||||
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
|
||||
if (pipelineMatch) {
|
||||
const pipelineId = decodeURIComponent(pipelineMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipelineId),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.pipelines = state.pipelines.filter(
|
||||
(item) => item.uuid !== pipelineId,
|
||||
);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const pipeline = state.pipelines.find((item) => item.uuid === pipelineId);
|
||||
return fulfillJson(route, {
|
||||
pipeline:
|
||||
pipeline || makePipeline(state, { name: pipelineId }, pipelineId),
|
||||
});
|
||||
}
|
||||
|
||||
const pipelineExtensionsMatch = path.match(
|
||||
/^\/api\/v1\/pipelines\/([^/]+)\/extensions$/,
|
||||
);
|
||||
if (pipelineExtensionsMatch) {
|
||||
return fulfillJson(route, {
|
||||
enable_all_plugins: true,
|
||||
enable_all_mcp_servers: true,
|
||||
enable_all_skills: true,
|
||||
bound_plugins: [],
|
||||
available_plugins: [],
|
||||
bound_mcp_servers: [],
|
||||
available_mcp_servers: state.mcpServers,
|
||||
bound_skills: [],
|
||||
available_skills: state.skills,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/bases') {
|
||||
return fulfillJson(route, { bases: [] });
|
||||
if (method === 'POST') {
|
||||
const base = makeKnowledgeBase(state, parseJsonBody(route));
|
||||
state.knowledgeBases = [
|
||||
...state.knowledgeBases.filter((item) => item.uuid !== base.uuid),
|
||||
base,
|
||||
];
|
||||
return fulfillJson(route, { uuid: base.uuid });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { bases: state.knowledgeBases });
|
||||
}
|
||||
|
||||
const knowledgeBaseFilesMatch = path.match(
|
||||
/^\/api\/v1\/knowledge\/bases\/([^/]+)\/files$/,
|
||||
);
|
||||
if (knowledgeBaseFilesMatch) {
|
||||
return fulfillJson(route, { files: [] });
|
||||
}
|
||||
|
||||
const knowledgeBaseMatch = path.match(
|
||||
/^\/api\/v1\/knowledge\/bases\/([^/]+)$/,
|
||||
);
|
||||
if (knowledgeBaseMatch) {
|
||||
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const base = makeKnowledgeBase(state, parseJsonBody(route), baseId);
|
||||
state.knowledgeBases = [
|
||||
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
|
||||
base,
|
||||
];
|
||||
return fulfillJson(route, { uuid: base.uuid });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.knowledgeBases = state.knowledgeBases.filter(
|
||||
(item) => item.uuid !== baseId,
|
||||
);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const base = state.knowledgeBases.find((item) => item.uuid === baseId);
|
||||
return fulfillJson(route, {
|
||||
base: base || makeKnowledgeBase(state, { name: baseId }, baseId),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/engines') {
|
||||
return fulfillJson(route, { engines: [knowledgeEngine()] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/knowledge/migration/status') {
|
||||
@@ -176,7 +519,60 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
}
|
||||
|
||||
if (path === '/api/v1/mcp/servers') {
|
||||
return fulfillJson(route, { servers: [] });
|
||||
if (method === 'POST') {
|
||||
const server = makeMCPServer(parseJsonBody(route));
|
||||
state.mcpServers = [
|
||||
...state.mcpServers.filter((item) => item.name !== server.name),
|
||||
server,
|
||||
];
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
return fulfillJson(route, { servers: state.mcpServers });
|
||||
}
|
||||
|
||||
const mcpTestMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)\/test$/);
|
||||
if (mcpTestMatch) {
|
||||
return fulfillJson(route, {
|
||||
runtime_info: {
|
||||
status: 'connected',
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mcpServerMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)$/);
|
||||
if (mcpServerMatch) {
|
||||
const serverName = decodeURIComponent(mcpServerMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const existing = state.mcpServers.find(
|
||||
(item) => item.name === serverName,
|
||||
);
|
||||
const server = makeMCPServer({
|
||||
...(existing || {}),
|
||||
...parseJsonBody(route),
|
||||
name: serverName,
|
||||
});
|
||||
state.mcpServers = [
|
||||
...state.mcpServers.filter((item) => item.name !== serverName),
|
||||
server,
|
||||
];
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.mcpServers = state.mcpServers.filter(
|
||||
(item) => item.name !== serverName,
|
||||
);
|
||||
return fulfillJson(route, { task_id: nextId(state, 'task') });
|
||||
}
|
||||
|
||||
const server = state.mcpServers.find((item) => item.name === serverName);
|
||||
return fulfillJson(route, {
|
||||
server: server || makeMCPServer({ name: serverName }),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/skills') {
|
||||
@@ -229,6 +625,23 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/);
|
||||
if (skillMatch) {
|
||||
const skillName = decodeURIComponent(skillMatch[1]);
|
||||
if (method === 'PUT') {
|
||||
const skill = makeSkill({
|
||||
...parseJsonBody(route),
|
||||
name: skillName,
|
||||
});
|
||||
state.skills = [
|
||||
...state.skills.filter((item) => item.name !== skillName),
|
||||
skill,
|
||||
];
|
||||
return fulfillJson(route, { skill });
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.skills = state.skills.filter((item) => item.name !== skillName);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
const skill = state.skills.find((item) => item.name === skillName) || {
|
||||
name: skillName,
|
||||
display_name: '',
|
||||
@@ -389,6 +802,11 @@ export async function installLangBotApiMocks(
|
||||
) {
|
||||
const { authenticated = false, storage = {} } = options;
|
||||
const state: LangBotApiMockState = {
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
mcpServers: [],
|
||||
pipelines: [],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user