diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py index fb0fe6bc7..731d4315a 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py @@ -7,7 +7,7 @@ import time from collections import Counter, OrderedDict from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from threading import RLock @@ -32,6 +32,28 @@ WORKFLOW_DB = Path.home() / ".flocks" / "data" / "workflow.db" WORKFLOW_SNAPSHOT_TABLE = "soc_dashboard_workflow_stats_samples" +TASK_DB = Path.home() / ".flocks" / "data" / "tasks.db" +USAGE_DB = Path.home() / ".flocks" / "data" / "flocks.db" +SOC_PINNED_WORKFLOW_NAMES = { + "stream_alert_denoise": "告警降噪工作流", + "stream_alert_triage": "告警研判工作流", +} +TRIAGE_WORKFLOW_IDS = { + "stream_alert_triage", + "onesec_kafka_investigation", + "tdp_alert_triage", + "sec_alert_unified_ops", +} +WORKFLOW_DISPLAY_NAMES = { + **SOC_PINNED_WORKFLOW_NAMES, + "onesec_kafka_investigation": "OneSEC Kafka 告警研判工作流", + "tdp_alert_triage": "TDP 告警研判工作流", + "sec_alert_unified_ops": "统一告警运营工作流", +} +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) _workflow_stats_cache: OrderedDict = OrderedDict() _CACHE_TTL: float = 30.0 @@ -477,6 +499,86 @@ def _safe_json_object(value): return parsed if isinstance(parsed, dict) else {} +def _usage_iso(dt): + return dt.astimezone(timezone.utc).isoformat() + + +def _parse_usage_created_at(value): + raw = str(value or "").strip() + if not raw: + return None + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(raw) + except Exception: + return None + if parsed.tzinfo is None: + return parsed.astimezone() + return parsed.astimezone() + + +def _read_token_usage(): + empty = { + "totalTokens": 0, + "todayTokens": 0, + "todayRequests": 0, + "dailySeries": [], + "dailyLabels": [], + "source": "usage_records", + } + if not USAGE_DB.is_file(): + return empty + + now_local = datetime.now().astimezone() + today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow_start = today_start + timedelta(days=1) + first_day = today_start - timedelta(days=6) + labels = [(first_day + timedelta(days=index)).strftime("%m/%d") for index in range(7)] + series_by_date = { + (first_day + timedelta(days=index)).date().isoformat(): 0 + for index in range(7) + } + + try: + with sqlite3.connect(f"file:{USAGE_DB}?mode=ro", uri=True, timeout=1.0) as conn: + conn.execute("PRAGMA query_only = ON") + total_tokens = _safe_int( + conn.execute( + "SELECT COALESCE(SUM(total_tokens), 0) FROM usage_records" + ).fetchone()[0] + ) + today_row = conn.execute( + "SELECT COALESCE(SUM(total_tokens), 0), COUNT(*) " + "FROM usage_records WHERE created_at >= ? AND created_at < ?", + (_usage_iso(today_start), _usage_iso(tomorrow_start)), + ).fetchone() + series_rows = conn.execute( + "SELECT created_at, total_tokens FROM usage_records " + "WHERE created_at >= ? AND created_at < ?", + (_usage_iso(first_day), _usage_iso(tomorrow_start)), + ).fetchall() + except Exception: + return {**empty, "dailyLabels": labels, "dailySeries": [0] * 7} + + for created_at, total in series_rows: + parsed = _parse_usage_created_at(created_at) + if parsed is None: + continue + key = parsed.date().isoformat() + if key in series_by_date: + series_by_date[key] += max(_safe_int(total), 0) + + return { + **empty, + "totalTokens": max(total_tokens, 0), + "todayTokens": max(_safe_int(today_row[0]), 0) if today_row else 0, + "todayRequests": max(_safe_int(today_row[1]), 0) if today_row else 0, + "dailySeries": list(series_by_date.values()), + "dailyLabels": labels, + } + + def _workflow_stats_sample_deltas(conn, workflow_name, start_time=0, end_time=0): stats_exists = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_stats'" @@ -890,11 +992,27 @@ def _get_workflow_recent_events( ) -> list: if not WORKFLOW_DB.is_file(): return [] - query = ( - "SELECT id, status, started_at, output_results, input_params " - "FROM workflow_executions WHERE workflow_id = ?" - ) + workflow_stage = "triage" if workflow_name in TRIAGE_WORKFLOW_IDS else "denoise" query_params = [workflow_name] + try: + with sqlite3.connect(WORKFLOW_DB) as conn: + execution_columns = { + row[1] + for row in conn.execute("PRAGMA table_info(workflow_executions)").fetchall() + } + except Exception: + return [] + latest_select = ", ".join( + [ + "id", + "status", + "started_at", + _workflow_execution_column_expr(execution_columns, "output_results", "'{}'"), + _workflow_execution_column_expr(execution_columns, "input_params", "'{}'"), + _workflow_execution_column_expr(execution_columns, "payload", "'{}'"), + ] + ) + query = f"SELECT {latest_select} FROM workflow_executions WHERE workflow_id = ?" if start_time > 0 and end_time > 0: query += " AND started_at >= ? AND started_at <= ?" query_params.extend((int(start_time * 1000), int(end_time * 1000))) @@ -902,28 +1020,59 @@ def _get_workflow_recent_events( query_params.append(max(1, min(_safe_int(limit), 10))) try: with sqlite3.connect(WORKFLOW_DB) as conn: + conn.row_factory = sqlite3.Row rows = conn.execute(query, query_params).fetchall() except Exception: return [] events = [] - for execution_id, status, started_at, output_text, input_text in rows: + for row in rows: + execution_id = row["id"] + status = row["status"] + started_at = row["started_at"] + output_text = row["output_results"] + input_text = row["input_params"] + payload_text = row["payload"] metrics = _workflow_execution_metrics(output_text, input_text) preview = metrics["preview"] raw_count = metrics["rawCount"] unique_count = metrics["uniqueCount"] - threat_name = str( - preview.get("threat_name") or f"降噪批次 · 原始 {raw_count} 条" + threat_name = "" + if workflow_stage == "triage": + threat_name = _workflow_latest_alert_name(workflow_name, output_text, input_text) + if not threat_name: + threat_name = str( + preview.get("threat_name") + or preview.get("_threat_type") + or preview.get("threat_type") + or f"降噪批次 · 原始 {raw_count} 条" + ) + normalized_status = str(status or "").lower() + event_status = ( + "completed" + if normalized_status in {"success", "completed"} + else "running" + if normalized_status in {"running", "queued", "pending"} + else "failed" + ) + session_id, message_id = _workflow_link_context( + { + "payload": payload_text, + "input_params": input_text, + } ) events.append( { "eventId": f"workflow-execution:{execution_id}", - "stage": "denoise", - "status": "completed" if str(status).lower() == "success" else "failed", + "stage": workflow_stage, + "status": event_status, "occurredAt": datetime.fromtimestamp( _safe_int(started_at) / 1000 ).astimezone().isoformat(timespec="seconds"), "triggerSource": "workflow_execution", + "workflowId": workflow_name, + "sessionId": session_id, + "messageId": message_id, "sampleCount": max(unique_count, 1), "alert": { "id": str(preview.get("id") or execution_id), @@ -989,6 +1138,518 @@ async def get_activity(ctx, request): return await asyncio.to_thread(_get_activity, params) +async def get_task_center(ctx, request): + return await asyncio.to_thread(_get_task_center) + + +def _table_exists(conn, table_name): + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ).fetchone() + ) + + +def _task_center_empty(): + return { + "generatedAt": datetime.now().astimezone().isoformat(timespec="seconds"), + "sessionCount": 0, + "scheduledTasks": [], + "workflows": [], + "sourceStatus": { + "tasksDb": str(TASK_DB), + "workflowDb": str(WORKFLOW_DB), + "tasksAvailable": TASK_DB.is_file(), + "workflowAvailable": WORKFLOW_DB.is_file(), + }, + } + + +def _task_trigger_value(trigger, key): + if not isinstance(trigger, dict): + return "" + return trigger.get(key) or trigger.get(key[0].lower() + key[1:]) or "" + + +def _today_bounds(): + now_local = datetime.now().astimezone() + today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + return today_start, today_start + timedelta(days=1) + + +def _task_center_task_rows(limit=12): + if not TASK_DB.is_file(): + return 0, [], 0, 0 + try: + with sqlite3.connect(f"file:{TASK_DB}?mode=ro", uri=True, timeout=1.0) as conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA query_only = ON") + if not ( + _table_exists(conn, "task_schedulers") + and _table_exists(conn, "task_executions") + ): + return 0, [], 0, 0 + today_start, tomorrow_start = _today_bounds() + today_start_iso = today_start.isoformat(timespec="seconds") + tomorrow_start_iso = tomorrow_start.isoformat(timespec="seconds") + session_count = _safe_int( + conn.execute( + "SELECT COUNT(DISTINCT session_id) FROM task_executions " + "WHERE session_id IS NOT NULL AND session_id <> ''" + ).fetchone()[0] + ) + scheduler_rows = conn.execute( + "SELECT id, title, mode, status, trigger, execution_mode, workflow_id, updated_at " + "FROM task_schedulers WHERE status <> 'archived' " + "ORDER BY updated_at DESC" + ).fetchall() + tasks = [] + for scheduler in scheduler_rows: + summary = conn.execute( + "SELECT COUNT(*) AS execution_count, " + "SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS success_count, " + "SUM(CASE WHEN status IN ('pending', 'queued', 'running') THEN 1 ELSE 0 END) AS active_count, " + "SUM(CASE WHEN " + "julianday(COALESCE(completed_at, updated_at, started_at, queued_at, created_at)) >= julianday(?) " + "AND julianday(COALESCE(completed_at, updated_at, started_at, queued_at, created_at)) < julianday(?) " + "THEN 1 ELSE 0 END) AS today_execution_count " + "FROM task_executions WHERE scheduler_id = ?", + (today_start_iso, tomorrow_start_iso, scheduler["id"]), + ).fetchone() + latest = conn.execute( + "SELECT status, queued_at, started_at, completed_at, updated_at " + "FROM task_executions WHERE scheduler_id = ? " + "ORDER BY julianday(COALESCE(completed_at, updated_at, started_at, queued_at, created_at)) DESC " + "LIMIT 1", + (scheduler["id"],), + ).fetchone() + trigger = _safe_json_object(scheduler["trigger"]) + execution_count = max(_safe_int(summary["execution_count"]), 0) + success_count = max(_safe_int(summary["success_count"]), 0) + active_count = max(_safe_int(summary["active_count"]), 0) + today_execution_count = max(_safe_int(summary["today_execution_count"]), 0) + last_run_at = "" + if latest: + last_run_at = latest["completed_at"] or latest["updated_at"] or latest["started_at"] or latest["queued_at"] or "" + tasks.append( + { + "id": scheduler["id"], + "name": scheduler["title"] or scheduler["id"], + "mode": scheduler["mode"] or "once", + "status": scheduler["status"] or "active", + "executionMode": scheduler["execution_mode"] or "agent", + "workflowId": scheduler["workflow_id"] or "", + "executionCount": execution_count, + "todayExecutionCount": today_execution_count, + "successCount": success_count, + "successRate": _ratio(success_count, execution_count), + "activeCount": active_count, + "lastStatus": latest["status"] if latest else "", + "lastRunAt": last_run_at, + "nextRunAt": _task_trigger_value(trigger, "nextRun"), + "cron": _task_trigger_value(trigger, "cron"), + "cronDescription": _task_trigger_value(trigger, "cronDescription"), + } + ) + tasks.sort( + key=lambda item: ( + item["activeCount"] > 0, + item["lastRunAt"] or "", + item["executionCount"], + ), + reverse=True, + ) + return ( + session_count, + tasks[:limit], + sum(task["executionCount"] for task in tasks), + sum(task["todayExecutionCount"] for task in tasks), + ) + except Exception: + return 0, [], 0, 0 + + +def _workflow_manifest_name_map(): + roots = [ + Path.home() / ".flocks" / "plugins" / "workflows", + Path.home() / ".flocks" / "workspace" / "workflows", + Path(__file__).resolve().parents[4] / "workflows", + ] + names = {} + for root in roots: + if not root.is_dir(): + continue + for manifest_path in root.glob("*/manifest.json"): + workflow_id = manifest_path.parent.name + try: + manifest = _safe_json_object(manifest_path.read_text(encoding="utf-8")) + except Exception: + manifest = {} + name = ( + manifest.get("nameCn") + or manifest.get("name") + or manifest.get("title") + or workflow_id + ) + name_i18n = manifest.get("nameI18n") + if isinstance(name_i18n, dict): + name = name_i18n.get("zh-CN") or name_i18n.get("zh") or name + names.setdefault(workflow_id, str(name)) + return names + + +def _workflow_config_name_map(conn): + if not _table_exists(conn, "workflow_configs"): + return {} + names = {} + for row in conn.execute( + "SELECT workflow_id, config FROM workflow_configs WHERE kind = ?", + ("workflow.integration-config",), + ).fetchall(): + workflow_id = row["workflow_id"] if isinstance(row, sqlite3.Row) else row[0] + config_raw = row["config"] if isinstance(row, sqlite3.Row) else row[1] + if not workflow_id: + continue + config = _safe_json_object(config_raw) + workflow = config.get("workflow") + if not isinstance(workflow, dict): + continue + name = workflow.get("name") or workflow.get("title") or workflow.get("id") + if name: + names.setdefault(str(workflow_id), str(name)) + return names + + +def _workflow_execution_column_expr(columns, column_name, fallback): + return column_name if column_name in columns else f"{fallback} AS {column_name}" + + +def _workflow_node_count(workflow_id): + roots = [ + Path.home() / ".flocks" / "plugins" / "workflows", + Path.home() / ".flocks" / "workspace" / "workflows", + Path(__file__).resolve().parents[4] / "workflows", + ] + for root in roots: + workflow_path = root / str(workflow_id) / "workflow.json" + if not workflow_path.is_file(): + continue + try: + workflow_json = _safe_json_object(workflow_path.read_text(encoding="utf-8")) + nodes = workflow_json.get("nodes") + if isinstance(nodes, list): + return max(len(nodes), 0) + except Exception: + continue + return 0 + + +def _first_text(*values): + for value in values: + text = str(value or "").strip() + if text and text.lower() not in {"unknown", "none", "null", "--"}: + return text + return "" + + +def _alert_name_from_record(record): + if not isinstance(record, dict): + return "" + return _first_text( + record.get("threat_name"), + record.get("_threat_type"), + record.get("threat_type"), + record.get("alert_name"), + record.get("name"), + record.get("report_title"), + record.get("title"), + ) + + +def _workflow_latest_alert_name(workflow_id, output_text="", input_text=""): + output = _safe_json_object(output_text) + inputs = _safe_json_object(input_text) + preview = _workflow_alert_preview(output) or _workflow_input_preview(inputs) + name = _alert_name_from_record(preview) + if name: + return name + for key in ( + "enriched_alerts_with_triage", + "triage_results", + "unique_alerts", + "enriched_alerts", + ): + value = output.get(key) + if isinstance(value, dict): + value = value.get("preview") + if isinstance(value, list): + for item in value: + name = _alert_name_from_record(item) + if name: + return name + name = _first_text( + output.get("top_report_title"), + output.get("report_title"), + output.get("title"), + ) + if name: + return name + if workflow_id in TRIAGE_WORKFLOW_IDS: + input_date = _first_text(inputs.get("input_date")) + return f"研判批次 {input_date}" if input_date else "研判批次" + if workflow_id == "stream_alert_denoise": + return "降噪批次" + return "" + + +def _workflow_progress(status, current_step_index, completed_steps, total_steps): + normalized = str(status or "").lower() + current = max(_safe_int(current_step_index), _safe_int(completed_steps), 0) + total = max(_safe_int(total_steps), current, 1) + if normalized in {"success", "completed"}: + return 1, "已完成" + if normalized in {"error", "failed", "timeout"}: + percent = _ratio(current or total, total) + return percent, f"失败于 {current or total}/{total} 步" + if normalized == "cancelled": + percent = _ratio(current or total, total) + return percent, f"已取消 {current or total}/{total} 步" + if normalized in {"running", "queued", "pending"}: + visible_current = current if current > 0 else 1 + return max(_ratio(visible_current, total), 0.03), f"第 {visible_current}/{total} 步" + return 0, "待执行" + + +def _workflow_link_context(latest): + payload = _safe_json_object(latest["payload"] if latest and "payload" in latest.keys() else "") + input_params = _safe_json_object(latest["input_params"] if latest and "input_params" in latest.keys() else "") + session_id = _first_text( + payload.get("sessionId"), + payload.get("sessionID"), + payload.get("session_id"), + input_params.get("sessionId"), + input_params.get("sessionID"), + input_params.get("session_id"), + ) + message_id = _first_text( + payload.get("messageId"), + payload.get("messageID"), + payload.get("message_id"), + input_params.get("messageId"), + input_params.get("messageID"), + input_params.get("message_id"), + ) + return session_id, message_id + + +def _task_center_workflow_rows(limit=12): + if not WORKFLOW_DB.is_file(): + return [], 0, 0 + try: + with sqlite3.connect(f"file:{WORKFLOW_DB}?mode=ro", uri=True, timeout=1.0) as conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA query_only = ON") + if not ( + _table_exists(conn, "workflow_stats") + or _table_exists(conn, "workflow_executions") + ): + return [], 0, 0 + today_start, tomorrow_start = _today_bounds() + today_start_ms = int(today_start.timestamp() * 1000) + tomorrow_start_ms = int(tomorrow_start.timestamp() * 1000) + workflow_ids = set() + if _table_exists(conn, "workflow_stats"): + workflow_ids.update( + row[0] + for row in conn.execute("SELECT workflow_id FROM workflow_stats").fetchall() + if row[0] + ) + if _table_exists(conn, "workflow_executions"): + workflow_ids.update( + row[0] + for row in conn.execute( + "SELECT DISTINCT workflow_id FROM workflow_executions" + ).fetchall() + if row[0] + ) + names = { + **_workflow_manifest_name_map(), + **_workflow_config_name_map(conn), + **WORKFLOW_DISPLAY_NAMES, + } + workflow_ids.update(SOC_PINNED_WORKFLOW_NAMES) + stats_columns = ( + { + row[1] + for row in conn.execute("PRAGMA table_info(workflow_stats)").fetchall() + } + if _table_exists(conn, "workflow_stats") + else set() + ) + stats_success_expr = "success_count" if "success_count" in stats_columns else "0" + stats_error_expr = "error_count" if "error_count" in stats_columns else "0" + stats_updated_expr = "updated_at" if "updated_at" in stats_columns else "0" + execution_columns = ( + { + row[1] + for row in conn.execute("PRAGMA table_info(workflow_executions)").fetchall() + } + if _table_exists(conn, "workflow_executions") + else set() + ) + finished_at_expr = "finished_at" if "finished_at" in execution_columns else "NULL" + updated_at_expr = "updated_at" if "updated_at" in execution_columns else "NULL" + execution_time_expr = f"COALESCE({finished_at_expr}, {updated_at_expr}, started_at, 0)" + latest_select = ", ".join( + [ + "id", + "status", + "started_at", + _workflow_execution_column_expr(execution_columns, "finished_at", "NULL"), + _workflow_execution_column_expr(execution_columns, "updated_at", "0"), + _workflow_execution_column_expr(execution_columns, "current_phase", "''"), + _workflow_execution_column_expr(execution_columns, "current_step_index", "0"), + _workflow_execution_column_expr(execution_columns, "step_count", "0"), + _workflow_execution_column_expr(execution_columns, "input_params", "'{}'"), + _workflow_execution_column_expr(execution_columns, "output_results", "'{}'"), + _workflow_execution_column_expr(execution_columns, "payload", "'{}'"), + _workflow_execution_column_expr(execution_columns, "error_message", "''"), + ] + ) + workflows = [] + for workflow_id in workflow_ids: + workflow_name = names.get(workflow_id, workflow_id) + if UUID_RE.match(str(workflow_id)) and workflow_name == workflow_id: + continue + stats = None + if _table_exists(conn, "workflow_stats"): + stats = conn.execute( + f"SELECT call_count, {stats_success_expr}, {stats_error_expr}, {stats_updated_expr} " + "FROM workflow_stats WHERE workflow_id = ?", + (workflow_id,), + ).fetchone() + latest = None + exec_summary = None + if _table_exists(conn, "workflow_executions"): + exec_summary = conn.execute( + "SELECT COUNT(*) AS execution_count, " + "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count, " + "SUM(CASE WHEN status IN ('running', 'queued', 'pending') THEN 1 ELSE 0 END) AS active_count, " + f"SUM(CASE WHEN {execution_time_expr} >= ? " + f"AND {execution_time_expr} < ? THEN 1 ELSE 0 END) " + "AS today_execution_count " + "FROM workflow_executions WHERE workflow_id = ?", + (today_start_ms, tomorrow_start_ms, workflow_id), + ).fetchone() + latest = conn.execute( + f"SELECT {latest_select} " + "FROM workflow_executions WHERE workflow_id = ? " + f"ORDER BY {execution_time_expr} DESC LIMIT 1", + (workflow_id,), + ).fetchone() + execution_count = max( + _safe_int(stats["call_count"] if stats else 0), + _safe_int(exec_summary["execution_count"] if exec_summary else 0), + ) + success_count = max( + _safe_int(stats["success_count"] if stats else 0), + _safe_int(exec_summary["success_count"] if exec_summary else 0), + ) + active_count = max(_safe_int(exec_summary["active_count"] if exec_summary else 0), 0) + today_execution_count = max( + _safe_int(exec_summary["today_execution_count"] if exec_summary else 0), + 0, + ) + last_run_at = 0 + if latest: + last_run_at = ( + _safe_int(latest["finished_at"]) + or _safe_int(latest["updated_at"]) + or _safe_int(latest["started_at"]) + ) + workflow_total_steps = _workflow_node_count(workflow_id) + latest_alert_name = "" + progress_percent = 0 + progress_label = "待执行" + current_phase = "" + session_id = "" + message_id = "" + if latest: + workflow_total_steps = max(workflow_total_steps, _safe_int(latest["step_count"])) + latest_alert_name = _workflow_latest_alert_name( + workflow_id, + latest["output_results"], + latest["input_params"], + ) + progress_percent, progress_label = _workflow_progress( + latest["status"], + latest["current_step_index"], + latest["step_count"], + workflow_total_steps, + ) + current_phase = str(latest["current_phase"] or "") + session_id, message_id = _workflow_link_context(latest) + workflows.append( + { + "id": workflow_id, + "name": workflow_name, + "executionCount": execution_count, + "todayExecutionCount": today_execution_count, + "successCount": success_count, + "successRate": _ratio(success_count, execution_count), + "activeCount": active_count, + "lastStatus": latest["status"] if latest else "", + "lastRunAt": last_run_at, + "latestExecutionHash": str(latest["id"] if latest else ""), + "latestAlertName": latest_alert_name, + "progressPercent": progress_percent, + "progressLabel": progress_label, + "currentPhase": current_phase, + "sessionId": session_id, + "messageId": message_id, + } + ) + soc_order = { + "stream_alert_denoise": 3, + "stream_alert_triage": 2, + "onesec_kafka_investigation": 1, + "tdp_alert_triage": 1, + "sec_alert_unified_ops": 1, + } + workflows.sort( + key=lambda item: ( + item["activeCount"] > 0, + item["lastRunAt"], + item["executionCount"], + soc_order.get(item["id"], 0), + ), + reverse=True, + ) + return ( + workflows[:limit], + sum(workflow["executionCount"] for workflow in workflows), + sum(workflow["todayExecutionCount"] for workflow in workflows), + ) + except Exception: + return [], 0, 0 + + +def _get_task_center(): + session_count, tasks, scheduled_execution_count, scheduled_today_execution_count = _task_center_task_rows() + workflows, workflow_execution_count, workflow_today_execution_count = _task_center_workflow_rows() + return { + **_task_center_empty(), + "sessionCount": session_count, + "scheduledTasks": tasks, + "scheduledExecutionCount": scheduled_execution_count, + "scheduledTodayExecutionCount": scheduled_today_execution_count, + "workflowExecutionCount": workflow_execution_count, + "workflowTodayExecutionCount": workflow_today_execution_count, + "workflows": workflows, + } + + def _get_activity(params): _ensure_sqlite_schema() _maybe_prune_activity() @@ -1004,11 +1665,10 @@ def _get_activity(params): start_time, end_time, ) - workflow_events = _get_workflow_recent_events( - "stream_alert_denoise", - start_time, - end_time, - ) + workflow_events = [ + *_get_workflow_recent_events("stream_alert_denoise", start_time, end_time), + *_get_workflow_recent_events("stream_alert_triage", start_time, end_time), + ] raw_cursor = str(params.get("cursor") or "").strip() bootstrap = str(params.get("bootstrap") or "").strip().lower() == "latest" limit = max(1, min(_safe_int(params.get("limit") or ACTIVITY_DEFAULT_LIMIT), ACTIVITY_MAX_LIMIT)) @@ -1030,6 +1690,18 @@ def _get_activity(params): with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row conn.execute("PRAGMA query_only = ON") + if not ( + _table_exists(conn, DEFAULT_SQLITE_TABLE) + and _table_exists(conn, ACTIVITY_TABLE) + ): + return _activity_response( + [], + 0, + 0, + cursor_reset=cursor_reset, + workflow_stats=workflow_stats, + workflow_events=workflow_events, + ) latest_row_id, latest_activity_id = _activity_latest_cursor(conn, settings) if bootstrap or cursor is None: @@ -1121,6 +1793,7 @@ def _activity_response( "cursorReset": cursor_reset, "workflowStats": workflow_stats or {"callCount": None, "latestStartedAt": None}, "workflowEvents": workflow_events or [], + "tokenUsage": _read_token_usage(), } @@ -1505,6 +2178,7 @@ def _get_stats(params): if cached is not None: return { **cached, + "tokenUsage": _read_token_usage(), "generatedAt": datetime.now().isoformat(timespec="seconds"), "latencyMs": round((time.time() - started) * 1000), "cacheHit": True, @@ -1584,6 +2258,7 @@ def _get_stats(params): "allowedDatabases": [ _display_path(DEFAULT_SQLITE_DB), _display_path(WORKFLOW_DB), + _display_path(USAGE_DB), ], }, "workflowStatsDb": _display_path(WORKFLOW_DB), @@ -1632,6 +2307,7 @@ def _get_stats(params): 8, ), "riskLevels": _counter_items(triage["riskCounter"], 5), + "tokenUsage": _read_token_usage(), "timeline": { "labels": denoise.get("_timelineLabels") or _series_labels(max(len(denoise["seriesRaw"]), len(triage["seriesTotal"]))), diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml index 8f85fbabb..020897392 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml @@ -9,3 +9,8 @@ routes: handler: handlers.get_activity timeoutMs: 5000 description: Incremental alert denoise and triage activity + - method: GET + path: /task-center + handler: handlers.get_task_center + timeoutMs: 5000 + description: Task center scheduler and workflow execution statistics diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx index 9ebc10b18..fbbab267b 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx @@ -74,6 +74,7 @@ const EMPTY_STATS = { }, sources: [], closedLoop: { autoClosed: 0, resolved: 0, manualDecision: 0, pending: 0, resolutionRate: 0 }, + tokenUsage: { totalTokens: 0, todayTokens: 0, todayRequests: 0, dailySeries: [], dailyLabels: [], source: '' }, verdicts: [], attackProfile: [], topThreatTypes: [], @@ -87,7 +88,14 @@ const EVENT_RAIL_TASK_LIMIT = 10; const ACTIVITY_POLL_MS = 3000; const ACTIVITY_REPLAY_WINDOW_MS = 10 * 60 * 1000; const ACTIVITY_SEEN_KEY = 'soc-dashboard-seen-activity-v1'; +const EVENT_RAIL_DEFAULT_WIDTH = 330; +const EVENT_RAIL_MIN_WIDTH = 280; +const EVENT_RAIL_MAX_WIDTH = 560; +const EVENT_RAIL_COMPACT_WIDTH = 292; const DEFAULT_TIME_RANGE = '7d'; +const DEFAULT_COMMAND_TITLE = 'Flocks AI 智能告警态势中心'; +const CUSTOM_COMMAND_TITLE_KEY = 'soc-dashboard-custom-title-v1'; +const CUSTOM_COMMAND_TITLE_CHANGED_EVENT = 'soc-dashboard:title-changed'; const TIME_RANGE_OPTIONS = [ { value: '15m', label: '最近15分钟' }, { value: '2h', label: '最近2小时' }, @@ -113,6 +121,22 @@ const REFRESH_INTERVAL_MS = { '1h': 3600000, }; +function readCustomCommandTitle() { + if (typeof window === 'undefined') return ''; + try { + return window.localStorage.getItem(CUSTOM_COMMAND_TITLE_KEY)?.trim() || ''; + } catch { + return ''; + } +} + +function defaultEventRailWidth() { + if (typeof window === 'undefined') return EVENT_RAIL_DEFAULT_WIDTH; + if (window.innerWidth <= 1120) return EVENT_RAIL_MIN_WIDTH; + if (window.innerWidth <= 1360) return EVENT_RAIL_COMPACT_WIDTH; + return EVENT_RAIL_DEFAULT_WIDTH; +} + function emptyActivityBatch() { return { mode: 'normal', @@ -142,6 +166,187 @@ function createActivityState() { }; } +function createMockActivityEvent(overrides) { + const now = new Date(); + return { + eventId: overrides.eventId || `mock-${overrides.stage}-${overrides.alert?.id || Math.random().toString(36).slice(2)}`, + stage: overrides.stage, + status: overrides.status || 'running', + occurredAt: overrides.occurredAt || now.toISOString(), + triggerSource: overrides.triggerSource || 'mock', + playbackMode: overrides.playbackMode || 'normal', + playbackStartedAt: overrides.playbackStartedAt || Date.now() - 4200, + sampleCount: overrides.sampleCount || 1, + alert: { + id: overrides.alert?.id || `mock-alert-${overrides.stage}`, + threatName: overrides.alert?.threatName || '模拟告警', + sourceType: overrides.alert?.sourceType || 'mock', + srcIp: overrides.alert?.srcIp || '10.24.8.16', + dstIp: overrides.alert?.dstIp || '172.16.32.20', + requestUri: overrides.alert?.requestUri || '/api/admin/export', + }, + result: { + isDuplicate: false, + clusterId: 'MOCK-03', + riskLevel: 'high', + verdictLabel: '待确认', + durationMs: 18000, + rawCount: 8, + normalizedCount: 8, + reducedCount: 3, + uniqueCount: 5, + filterRemovedCount: 2, + duplicateCount: 1, + reductionRate: 0.375, + ...(overrides.result || {}), + }, + hiddenFromQueue: Boolean(overrides.hiddenFromQueue), + }; +} + +function createMockActivityState() { + const now = Date.now(); + const denoiseCurrent = createMockActivityEvent({ + stage: 'denoise', + eventId: 'mock-denoise-current', + playbackMode: 'burst', + playbackStartedAt: now - 3600, + sampleCount: 6, + alert: { + id: 'mock-alert-login-burst', + threatName: '异常登录爆发(Mock)', + sourceType: 'skyeye', + srcIp: '10.23.18.44', + dstIp: '172.16.8.21', + requestUri: '/login', + }, + result: { + clusterId: 'MOCK-LOGIN-07', + rawCount: 18, + normalizedCount: 18, + reducedCount: 11, + uniqueCount: 7, + duplicateCount: 7, + filterRemovedCount: 0, + reductionRate: 0.6111, + }, + }); + const triageCurrent = createMockActivityEvent({ + stage: 'triage', + eventId: 'mock-triage-current', + playbackStartedAt: now - 6200, + alert: { + id: 'mock-alert-rce', + threatName: '远程命令执行攻击(Mock)', + sourceType: 'tdp', + srcIp: '203.0.113.41', + dstIp: '10.12.4.18', + requestUri: '/cgi-bin/luci/;stok=/locale', + }, + result: { + riskLevel: 'high', + verdictLabel: '待确认', + triageSource: 'llm', + durationMs: 22000, + threatSeverity: 'high', + }, + }); + const triageWaiting = createMockActivityEvent({ + stage: 'triage', + status: 'queued', + eventId: 'mock-triage-waiting', + occurredAt: new Date(now - 18000).toISOString(), + playbackStartedAt: now - 18000, + alert: { + id: 'mock-alert-sql', + threatName: 'SQL 注入探测(Mock)', + sourceType: 'onesec', + srcIp: '198.51.100.12', + dstIp: '10.12.4.32', + requestUri: '/search?q=1%27', + }, + result: { + riskLevel: 'medium', + verdictLabel: '待确认', + durationMs: 0, + }, + }); + const denoiseWaiting = createMockActivityEvent({ + stage: 'denoise', + status: 'queued', + eventId: 'mock-denoise-waiting', + occurredAt: new Date(now - 26000).toISOString(), + playbackStartedAt: now - 26000, + sampleCount: 4, + alert: { + id: 'mock-alert-scan', + threatName: '端口扫描聚类(Mock)', + sourceType: 'qingteng', + srcIp: '192.0.2.88', + dstIp: '10.12.5.10', + requestUri: 'TCP/22,80,443', + }, + result: { + clusterId: 'MOCK-SCAN-02', + rawCount: 12, + normalizedCount: 12, + reducedCount: 8, + uniqueCount: 4, + reductionRate: 0.6667, + }, + }); + return { + ...createActivityState(), + connection: 'online', + mode: 'burst', + denoise: { current: denoiseCurrent, queue: [denoiseWaiting], last: null }, + triage: { current: triageCurrent, queue: [triageWaiting], last: null }, + recent: [denoiseCurrent, triageCurrent, triageWaiting, denoiseWaiting], + batch: { + mode: 'burst', + windowMs: ACTIVITY_POLL_MS, + receivedCount: 22, + duplicateCount: 8, + uniqueCount: 14, + clusterCount: 5, + triageUpdatedCount: 3, + sampledCount: 10, + suppressedCount: 2, + ratePerSecond: 7.3, + }, + batchUpdatedAt: now, + generatedAt: new Date(now).toISOString(), + mock: true, + }; +} + +function activityHasVisibleEvents(activity) { + return Boolean( + activity?.denoise?.current + || activity?.denoise?.last + || activity?.denoise?.queue?.length + || activity?.triage?.current + || activity?.triage?.last + || activity?.triage?.queue?.length + || activity?.recent?.length + ); +} + +function createTaskCenterState() { + return { + connection: 'initializing', + generatedAt: '', + sessionCount: 0, + scheduledExecutionCount: 0, + scheduledTodayExecutionCount: 0, + workflowExecutionCount: 0, + workflowTodayExecutionCount: 0, + scheduledTasks: [], + workflows: [], + error: '', + }; +} + function activityDuration(event) { if (!event) return 0; if (event.stage === 'denoise') { @@ -154,6 +359,11 @@ function activityDuration(event) { return 30000; } +function isRunningWorkflowEvent(event) { + return event?.triggerSource === 'workflow_execution' + && ['running', 'queued', 'pending'].includes(String(event?.status || '').toLowerCase()); +} + function normalizeActivityBatch(raw) { const batch = { ...emptyActivityBatch(), ...(raw || {}) }; for (const key of ['windowMs', 'receivedCount', 'duplicateCount', 'uniqueCount', 'clusterCount', 'triageUpdatedCount', 'sampledCount', 'suppressedCount', 'ratePerSecond']) { @@ -205,8 +415,17 @@ function enqueueActivity(previous, events, generatedAt, recentEvents, rawBatch) ? { ...event, playbackMode: batch.mode, batch } : event; const lane = next[enriched.stage]; - const known = [lane.current, lane.last, ...lane.queue].some((item) => item?.eventId === enriched.eventId); - if (!known) lane.queue.push(enriched); + if (lane.current?.eventId === enriched.eventId) { + lane.current = { ...lane.current, ...enriched, playbackStartedAt: lane.current.playbackStartedAt }; + continue; + } + const queuedIndex = lane.queue.findIndex((item) => item?.eventId === enriched.eventId); + if (queuedIndex >= 0) { + lane.queue[queuedIndex] = { ...lane.queue[queuedIndex], ...enriched }; + continue; + } + const knownLast = lane.last?.eventId === enriched.eventId; + if (!knownLast || isRunningWorkflowEvent(enriched)) lane.queue.push(enriched); } for (const kind of ['denoise', 'triage']) { const lane = next[kind]; @@ -365,6 +584,7 @@ function mergeStats(raw) { triage: { ...EMPTY_STATS.triage, ...((raw || {}).triage || {}) }, pipeline: { ...EMPTY_STATS.pipeline, ...((raw || {}).pipeline || {}) }, closedLoop: { ...EMPTY_STATS.closedLoop, ...((raw || {}).closedLoop || {}) }, + tokenUsage: { ...EMPTY_STATS.tokenUsage, ...((raw || {}).tokenUsage || {}) }, dateRange: { ...EMPTY_STATS.dateRange, ...((raw || {}).dateRange || {}) }, eventRange: { ...EMPTY_STATS.eventRange, ...((raw || {}).eventRange || {}) }, timeline: { ...EMPTY_STATS.timeline, ...((raw || {}).timeline || {}) }, @@ -418,6 +638,12 @@ function compactNumber(value) { return fullNumber(n); } +function formatTokenVolume(value) { + const n = Math.max(Number(value || 0), 0); + if (n >= 1000000000) return `${(n / 1000000000).toFixed(2)}B`; + return `${(n / 1000000).toFixed(2)}M`; +} + function AnimatedNumber({ value, format, tag = 'span', className, duration = 900 }) { const { useEffect, useRef, useState } = getReact(); const target = Number(value || 0); @@ -913,6 +1139,22 @@ function Sparkline({ values, color }) { ]); } +function TokenSparkline({ values, color }) { + const nums = (values || []).map((v) => Math.max(Number(v || 0), 0)); + const series = nums.length > 1 ? nums : [0, nums[0] || 0]; + const max = Math.max(...series, 1); + const min = Math.min(...series); + const span = Math.max(max - min, 1); + const points = series.map((value, index) => { + const x = series.length === 1 ? 0 : (index / (series.length - 1)) * 300; + const y = 80 - ((value - min) / span) * 52; + return `${x},${y}`; + }).join(' '); + return h('svg', { className: 'token-chart', viewBox: '0 0 300 92', role: 'img' }, [ + h('polyline', { key: 'line', className: 'token-chart-line', points, fill: 'none', stroke: color || '#ff674d', strokeWidth: 4, strokeLinecap: 'round', strokeLinejoin: 'round' }), + ]); +} + function polarPoint(cx, cy, radius, angle) { const radians = (angle - 90) * Math.PI / 180; return { @@ -1240,7 +1482,7 @@ function TimeRefreshPopover({ value, refreshValue, open, onToggle, onApply, onCl ]); } -function CommandHeader({ timeFilter, refreshKey, timeMenuOpen, setTimeMenuOpen, applyTimeRefresh, stats, loading, refresh, activity }) { +function CommandHeader({ title, timeFilter, refreshKey, timeMenuOpen, setTimeMenuOpen, applyTimeRefresh, stats, loading, refresh, activity }) { const active = activity.denoise.current || activity.triage.current; const loadActive = activity.mode !== 'normal' && activity.batch?.receivedCount > 0; const status = activity.connection === 'error' @@ -1252,7 +1494,7 @@ function CommandHeader({ timeFilter, refreshKey, timeMenuOpen, setTimeMenuOpen, h('div', { className: 'command-brand', key: 'brand' }, [ h('div', { className: 'command-logo', key: 'logo' }, 'AI'), h('div', { key: 'copy' }, [ - h('strong', { key: 'title' }, 'Flocks AI 智能告警态势中心'), + h('strong', { key: 'title', title }, title), h('span', { key: 'sub' }, '告警汇聚 · 智能降噪 · 自动研判 · 风险聚合'), ]), ]), @@ -1455,12 +1697,34 @@ function CommandMetric({ label, value, format, sub, values, color }) { ]); } +function TokenUsageMetric({ tokenUsage }) { + return h('div', { className: 'command-metric token-usage-metric', style: { '--metric-color': '#ff674d' } }, [ + h('span', { className: 'token-title', key: 'label' }, 'Token 消耗'), + h('div', { className: 'token-summary-row', key: 'summary' }, [ + h(AnimatedNumber, { + tag: 'b', + className: 'token-value', + value: tokenUsage.todayTokens, + format: formatTokenVolume, + duration: 1200, + key: 'value', + }), + h('small', { className: 'token-sub', key: 'sub' }, [ + h('span', { className: 'token-sub-line', key: 'total' }, `累计 ${formatTokenVolume(tokenUsage.totalTokens)}`), + h('span', { className: 'token-sub-line', key: 'today' }, `今日调用 ${compactNumber(tokenUsage.todayRequests)} 次`), + ]), + ]), + h(TokenSparkline, { values: tokenUsage.dailySeries, color: '#ff674d', key: 'chart' }), + ]); +} + function CommandMetrics({ stats }) { + const tokenUsage = stats.tokenUsage || EMPTY_STATS.tokenUsage; return h('section', { className: 'command-metrics' }, [ h(CommandMetric, { label: '原始告警量', value: stats.denoise.totalRaw, sub: `${compactNumber(stats.denoise.totalUnique)} 条进入研判`, values: stats.timeline.denoiseRaw, color: '#2e72ff', key: 'raw' }), h(CommandMetric, { label: '安全事件量', value: stats.triage.attackTotal, sub: `${compactNumber(stats.triage.attackSuccess)} 条攻击成功`, values: stats.timeline.triageAttack, color: '#23ca8e', key: 'events' }), h(CommandMetric, { label: '降噪率', value: stats.denoise.duplicateRate * 100, format: (value) => `${trim(value)}%`, sub: `${compactNumber(stats.denoise.duplicates)} 条告警已过滤/收敛`, values: stats.timeline.denoiseUnique, color: '#21d8a3', key: 'rate' }), - h(CommandMetric, { label: '平均研判时间', value: stats.triage.avgTriageMs, format: (value) => formatDurationMs(value), sub: `${compactNumber(stats.triage.totalRecords)} 条已完成研判`, values: stats.timeline.triageTotal, color: '#ff674d', key: 'mtta' }), + h(TokenUsageMetric, { tokenUsage, key: 'tokens' }), ]); } @@ -1657,7 +1921,251 @@ function EventQueueProgress({ event }) { ]); } -function CommandEventRail({ activity, timeFilter, collapsed, onToggle }) { +function taskCenterPercent(value) { + return `${Math.round(Math.max(Math.min(Number(value || 0), 1), 0) * 100)}%`; +} + +function taskCenterTimeLabel(value) { + if (!value) return '暂无记录'; + const raw = Number(value); + const date = Number.isFinite(raw) && raw > 0 ? new Date(raw) : new Date(value); + if (Number.isNaN(date.getTime())) return '暂无记录'; + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + +function taskCenterStatusLabel(status) { + const value = String(status || '').toLowerCase(); + if (['running', 'queued', 'pending'].includes(value)) return '执行中'; + if (['completed', 'success'].includes(value)) return '成功'; + if (['failed', 'error', 'timeout'].includes(value)) return '失败'; + if (value === 'cancelled') return '取消'; + return '待执行'; +} + +function taskCenterHashLabel(value) { + const text = String(value || '').trim(); + if (!text) return '--'; + if (text.length <= 12) return text; + return `${text.slice(0, 6)}...${text.slice(-4)}`; +} + +function taskCenterHashValue(value) { + const text = String(value || '').trim(); + return text || '--'; +} + +function taskCenterWorkflowName(item) { + const name = String(item?.name || item?.id || '').trim(); + const id = String(item?.id || '').trim(); + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name)) { + return `动态工作流 ${taskCenterHashLabel(name)}`; + } + return name || id || '未命名工作流'; +} + +function taskCenterProgressValue(item) { + return Math.max(Math.min(Number(item?.progressPercent || 0), 1), 0); +} + +function taskCenterProgressLabel(item) { + return String(item?.progressLabel || '').trim() || '待执行'; +} + +function openTaskCenterConversation(item) { + const sessionId = String(item?.sessionId || item?.sessionID || '').trim(); + if (!sessionId || typeof window === 'undefined') return false; + const messageId = String(item?.messageId || item?.messageID || '').trim(); + const params = new URLSearchParams({ session: sessionId }); + if (messageId) params.set('focusMessage', messageId); + window.location.href = `/sessions?${params.toString()}`; + return true; +} + +function openConversationFromEvent(event) { + const sessionId = String(event?.sessionId || event?.sessionID || '').trim(); + if (!sessionId || typeof window === 'undefined') return false; + const messageId = String(event?.messageId || event?.messageID || '').trim(); + const params = new URLSearchParams({ session: sessionId }); + if (messageId) params.set('focusMessage', messageId); + window.location.href = `/sessions?${params.toString()}`; + return true; +} + +function TaskCenterSummary({ taskCenter }) { + const scheduledTasks = taskCenter.scheduledTasks || []; + const workflows = taskCenter.workflows || []; + const activeCount = [ + ...scheduledTasks, + ...workflows, + ].filter((item) => Number(item.activeCount || 0) > 0).length; + const summaryMetric = (key, label, value, sub, className = '') => h('div', { className, key }, [ + h('span', { key: 'label' }, label), + h(AnimatedNumber, { tag: 'b', value: value || 0, duration: 800, key: 'value' }), + sub ? h('small', { key: 'sub' }, sub) : null, + ]); + return h('div', { className: 'task-center-summary' }, [ + summaryMetric('sessions', '会话次数', taskCenter.sessionCount, ''), + summaryMetric('active', '执行中', activeCount, '', activeCount ? 'active' : ''), + summaryMetric('scheduledRuns', '定时执行', taskCenter.scheduledExecutionCount, `今日 ${taskCenter.scheduledTodayExecutionCount || 0}`), + summaryMetric('workflowRuns', '工作流执行', taskCenter.workflowExecutionCount, `今日 ${taskCenter.workflowTodayExecutionCount || 0}`), + ]); +} + +function TaskCenterItem({ item, kind }) { + const successRate = Math.max(Math.min(Number(item.successRate || 0), 1), 0); + const progressValue = taskCenterProgressValue(item); + const progressLabel = taskCenterProgressLabel(item); + const active = Number(item.activeCount || 0) > 0; + const status = taskCenterStatusLabel(item.lastStatus); + const statusClass = String(item.lastStatus || '').toLowerCase(); + const latestTime = taskCenterTimeLabel(item.lastRunAt); + const latestExecutionHash = taskCenterHashValue(item.latestExecutionHash); + const itemName = kind === 'workflow' ? taskCenterWorkflowName(item) : item.name || item.id; + const alertName = String(item.latestAlertName || '').trim(); + const hasConversation = kind === 'workflow' && Boolean(String(item.sessionId || item.sessionID || '').trim()); + const sub = kind === 'scheduled' + ? item.nextRunAt + ? `下次 ${taskCenterTimeLabel(item.nextRunAt)}` + : item.cronDescription || item.cron || taskCenterTimeLabel(item.lastRunAt) + : `最近执行 ${latestTime}`; + const stats = kind === 'workflow' + ? [ + h('span', { key: 'total' }, ['执行 ', h(AnimatedNumber, { tag: 'b', value: item.executionCount || 0, duration: 700, key: 'value' })]), + h('span', { key: 'today' }, ['今日 ', h(AnimatedNumber, { tag: 'b', value: item.todayExecutionCount || 0, duration: 700, key: 'value' })]), + h('span', { key: 'progress' }, ['进度 ', h('b', { key: 'value' }, progressLabel)]), + h('span', { key: 'rate' }, ['成功率 ', h('b', { key: 'value' }, taskCenterPercent(successRate))]), + ] + : [ + h('span', { key: 'total' }, ['执行 ', h(AnimatedNumber, { tag: 'b', value: item.executionCount || 0, duration: 700, key: 'value' })]), + h('span', { key: 'today' }, ['今日 ', h(AnimatedNumber, { tag: 'b', value: item.todayExecutionCount || 0, duration: 700, key: 'value' })]), + h('span', { key: 'success' }, ['成功 ', h(AnimatedNumber, { tag: 'b', value: item.successCount || 0, duration: 700, key: 'value' })]), + h('span', { key: 'rate' }, ['成功率 ', h('b', { key: 'value' }, taskCenterPercent(successRate))]), + ]; + const handleOpen = () => { + if (hasConversation) openTaskCenterConversation(item); + }; + const handleKeyDown = (event) => { + if (!hasConversation) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openTaskCenterConversation(item); + } + }; + return h('article', { + className: cx('task-center-item', active && 'active', hasConversation && 'clickable'), + role: hasConversation ? 'button' : undefined, + tabIndex: hasConversation ? 0 : undefined, + title: hasConversation ? '打开对应对话' : undefined, + onClick: handleOpen, + onKeyDown: handleKeyDown, + }, [ + h('div', { className: 'task-center-item-head', key: 'head' }, [ + h('strong', { title: item.name || item.id, key: 'name' }, itemName), + h('span', { className: cx('task-center-status', active && 'active', statusClass), key: 'status' }, active ? '执行中' : status), + ]), + h('div', { className: 'task-center-item-sub', title: sub, key: 'sub' }, sub), + kind === 'workflow' ? h('div', { + className: cx('task-center-alert', alertName && 'has-alert'), + title: alertName || '暂无告警名称', + key: 'alert', + }, [ + h('span', { key: 'label' }, '研判告警'), + h('b', { key: 'value' }, alertName || '暂无告警名称'), + ]) : null, + kind === 'workflow' ? h('div', { className: 'task-center-hash', title: latestExecutionHash, key: 'hash' }, [ + h('span', { key: 'label' }, '执行哈希'), + h('code', { key: 'value' }, latestExecutionHash), + h('span', { key: 'link-label' }, '更多信息'), + h('code', { className: cx('task-center-jump', hasConversation && 'enabled'), key: 'link' }, hasConversation ? '查看对话' : '暂无关联对话'), + ]) : null, + h('div', { className: cx('task-center-stats', kind === 'workflow' && 'workflow-stats'), key: 'stats' }, stats), + h('div', { + className: cx('task-center-rate', kind === 'workflow' && 'progress-rate'), + style: { '--task-center-rate': kind === 'workflow' ? progressValue : successRate }, + key: 'rateBar', + }, [ + h('i', { key: 'fill' }), + ]), + ]); +} + +function TaskCenterSection({ title, count, items, kind, emptyText, expanded, onToggle, collapsed, onCollapseToggle }) { + const hasOverflow = items.length > 3; + const visibleItems = expanded || !hasOverflow ? items : items.slice(0, 3); + return h('section', { className: 'task-center-section' }, [ + h('div', { className: 'task-center-section-title', key: 'title' }, [ + h('button', { + className: 'task-center-section-toggle', + type: 'button', + 'aria-expanded': !collapsed, + onClick: onCollapseToggle, + key: 'toggle', + }, [ + h('i', { key: 'chevron' }, collapsed ? '›' : '⌄'), + h('strong', { key: 'label' }, title), + ]), + h('span', { key: 'count' }, collapsed ? `${count} 项` : expanded || !hasOverflow ? `${count} 项` : `显示 3/${count}`), + ]), + collapsed ? null : h('div', { className: 'task-center-section-list', key: 'list' }, visibleItems.length + ? visibleItems.map((item) => h(TaskCenterItem, { item, kind, key: `${kind}-${item.id}` })) + : h('div', { className: 'event-rail-empty' }, emptyText)), + !collapsed && hasOverflow ? h('button', { + className: 'task-center-expand', + type: 'button', + onClick: onToggle, + key: 'expand', + }, expanded ? '收起' : `展开全部 ${count} 项`) : null, + ]); +} + +function CommandTaskCenterPanel({ taskCenter }) { + const { useState } = getReact(); + const [scheduledExpanded, setScheduledExpanded] = useState(false); + const [workflowExpanded, setWorkflowExpanded] = useState(false); + const [scheduledCollapsed, setScheduledCollapsed] = useState(false); + const [workflowCollapsed, setWorkflowCollapsed] = useState(false); + const scheduledTasks = taskCenter.scheduledTasks || []; + const workflows = taskCenter.workflows || []; + return h('div', { className: 'task-center-panel', key: 'taskCenterPanel' }, [ + taskCenter.connection === 'error' ? h('div', { + className: 'task-center-inline-warn', + key: 'warn', + }, `任务中心连接异常:${taskCenter.error || '正在重试'}`) : null, + h(TaskCenterSummary, { taskCenter, key: 'summary' }), + h(TaskCenterSection, { + title: '定时执行', + count: scheduledTasks.length, + items: scheduledTasks, + kind: 'scheduled', + emptyText: '暂无定时任务执行记录', + expanded: scheduledExpanded, + onToggle: () => setScheduledExpanded((current) => !current), + collapsed: scheduledCollapsed, + onCollapseToggle: () => setScheduledCollapsed((current) => !current), + key: 'scheduled', + }), + h(TaskCenterSection, { + title: '工作流执行', + count: workflows.length, + items: workflows, + kind: 'workflow', + emptyText: '暂无工作流执行记录', + expanded: workflowExpanded, + onToggle: () => setWorkflowExpanded((current) => !current), + collapsed: workflowCollapsed, + onCollapseToggle: () => setWorkflowCollapsed((current) => !current), + key: 'workflows', + }), + ]); +} + +function CommandAiTaskPanel({ activity, timeFilter }) { const tasks = buildEventQueueTasks(activity, timeFilter); const filterTransitionKey = [timeFilter.mode, timeFilter.range, timeFilter.start, timeFilter.end].join('|'); const visibleTasks = useAnimatedTaskWindow( @@ -1674,11 +2182,7 @@ function CommandEventRail({ activity, timeFilter, collapsed, onToggle }) { : counts.processing ? `AI 正在并行处理 ${counts.processing} 个任务` : counts.waiting ? '最新 10 条待处理任务' : '等待新的降噪或研判任务'; - const content = collapsed ? [] : [ - h('div', { className: 'event-rail-head', key: 'head' }, [ - h('div', { key: 'title' }, [h('strong', { key: 'label' }, 'AI处理任务'), h('span', { key: 'sub' }, '最新 10 条待处理任务')]), - h(AnimatedNumber, { tag: 'b', value: queueCount, duration: 600, key: 'count' }), - ]), + return [ h('div', { className: cx('event-update-banner', activity.connection === 'error' && 'warn'), key: 'banner' }, banner), h('div', { className: 'event-rail-list', key: 'list' }, visibleTasks.length ? visibleTasks.map((task) => { const event = task.event; @@ -1691,13 +2195,31 @@ function CommandEventRail({ activity, timeFilter, collapsed, onToggle }) { ? '处理中' : '等待处理'; const detail = event?.triggerSource === 'workflow_execution' - ? event.result?.isDuplicate ? '重复告警已收敛' : '降噪处理完成' + ? task.stage === 'triage' + ? task.state === 'processing' ? '研判工作流处理中' : '研判工作流待处理' + : event.result?.isDuplicate ? '重复告警已收敛' : '降噪处理完成' : task.state === 'processing' ? task.stage === 'triage' ? '证据关联与结论生成中' : '特征提取与相似聚类中' : '等待 AI 处理'; + const hasConversation = Boolean(String(event?.sessionId || event?.sessionID || '').trim()); + const handleOpen = () => { + if (hasConversation) openConversationFromEvent(event); + }; + const handleKeyDown = (keyboardEvent) => { + if (!hasConversation) return; + if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') { + keyboardEvent.preventDefault(); + openConversationFromEvent(event); + } + }; return h('article', { - className: cx('event-rail-item', `state-${task.state}`, `kind-${task.stage}`, `motion-${task.motion || 'stable'}`), + className: cx('event-rail-item', `state-${task.state}`, `kind-${task.stage}`, `motion-${task.motion || 'stable'}`, hasConversation && 'clickable'), key: task.key, + role: hasConversation ? 'button' : undefined, + tabIndex: hasConversation ? 0 : undefined, + title: hasConversation ? '打开对应对话' : undefined, + onClick: handleOpen, + onKeyDown: handleKeyDown, }, [ h('div', { className: 'event-rail-meta', key: 'meta' }, [ h('span', { className: cx('event-queue-kind', `kind-${task.stage}`), key: 'kind' }, stageLabel), @@ -1706,39 +2228,154 @@ function CommandEventRail({ activity, timeFilter, collapsed, onToggle }) { ]), h('strong', { title, key: 'title' }, title), h('span', { title: eventEndpoint(event), key: 'endpoint' }, eventEndpoint(event)), - h('small', { key: 'result' }, detail), + h('small', { key: 'result' }, hasConversation ? `${detail} · 查看对话` : detail), task.state === 'processing' ? h(EventQueueProgress, { event, key: 'progress' }) : null, ]); }) : h('div', { className: 'event-rail-empty' }, '等待新的降噪或研判任务')), ]; +} + +function CommandEventRail({ activity, timeFilter, taskCenter, view, onViewChange, collapsed, onToggle, railWidth, onResizeStart, onResizeKeyDown }) { + const tasks = buildEventQueueTasks(activity, timeFilter); + const queueCount = tasks.filter((task) => task.state !== 'completed').length; + const taskCenterCount = Number(taskCenter.sessionCount || 0); + const content = collapsed ? [] : [ + h('div', { className: 'event-rail-head rail-view-head', key: 'head' }, [ + h('div', { className: 'rail-view-tabs', role: 'tablist', key: 'tabs' }, [ + h('button', { + className: cx(view === 'aiTasks' && 'active'), + type: 'button', + role: 'tab', + 'aria-selected': view === 'aiTasks', + onClick: () => onViewChange('aiTasks'), + key: 'aiTasks', + }, 'AI处理任务'), + h('button', { + className: cx(view === 'taskCenter' && 'active'), + type: 'button', + role: 'tab', + 'aria-selected': view === 'taskCenter', + onClick: () => onViewChange('taskCenter'), + key: 'taskCenter', + }, '任务中心'), + ]), + h(AnimatedNumber, { tag: 'b', value: view === 'aiTasks' ? queueCount : taskCenterCount, duration: 600, key: 'count' }), + ]), + view === 'taskCenter' + ? h(CommandTaskCenterPanel, { taskCenter, key: 'taskCenterContent' }) + : h(CommandAiTaskPanel, { activity, timeFilter, key: 'aiTaskContent' }), + ]; return h('aside', { className: cx('command-event-rail', collapsed && 'collapsed') }, [ + collapsed ? null : h('div', { + className: 'event-rail-resize', + role: 'separator', + tabIndex: 0, + 'aria-label': '调整任务面板宽度', + 'aria-orientation': 'vertical', + 'aria-valuemin': EVENT_RAIL_MIN_WIDTH, + 'aria-valuemax': EVENT_RAIL_MAX_WIDTH, + 'aria-valuenow': railWidth, + onPointerDown: onResizeStart, + onKeyDown: onResizeKeyDown, + key: 'resize', + }), h('button', { className: 'event-rail-toggle', type: 'button', - title: collapsed ? '展开 AI处理任务' : '向右折叠', - 'aria-label': collapsed ? '展开 AI处理任务' : '向右折叠 AI处理任务', + title: collapsed ? '展开任务面板' : '向右折叠', + 'aria-label': collapsed ? '展开任务面板' : '向右折叠任务面板', onClick: onToggle, key: 'toggle', - }, collapsed ? h('span', { key: 'label' }, ['任', '务', '面', '板'].map((text) => h('i', { key: text }, text))) : '›'), + }, collapsed ? '展开' : '收起'), ...content, ]); } export default function Page() { - const { useCallback, useEffect, useRef, useState } = getReact(); + const { useCallback, useEffect, useMemo, useRef, useState } = getReact(); const [timeFilter, setTimeFilter] = useState(() => createRelativeTimeFilter()); const [refreshKey, setRefreshKey] = useState('off'); const [timeMenuOpen, setTimeMenuOpen] = useState(false); const [eventRailCollapsed, setEventRailCollapsed] = useState(false); + const [eventRailWidth, setEventRailWidth] = useState(defaultEventRailWidth); + const [rightRailView, setRightRailView] = useState('aiTasks'); + const [customCommandTitle, setCustomCommandTitle] = useState(readCustomCommandTitle); const [stats, setStats] = useState(EMPTY_STATS); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [activity, setActivity] = useState(createActivityState); + const [taskCenter, setTaskCenter] = useState(createTaskCenterState); const activityCursor = useRef(''); const workflowProgressByFilter = useRef(new Map()); const statsRequestId = useRef(0); const statsPending = useRef(0); + const clampEventRailWidth = useCallback((value) => { + const viewportLimit = typeof window === 'undefined' + ? EVENT_RAIL_MAX_WIDTH + : Math.max(EVENT_RAIL_MIN_WIDTH, Math.min(EVENT_RAIL_MAX_WIDTH, window.innerWidth - 760)); + return Math.max(EVENT_RAIL_MIN_WIDTH, Math.min(viewportLimit, Math.round(value))); + }, []); + + const startEventRailResize = useCallback((event) => { + if (eventRailCollapsed) return; + const startX = event.clientX; + const startWidth = eventRailWidth; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const move = (moveEvent) => { + setEventRailWidth(clampEventRailWidth(startWidth + startX - moveEvent.clientX)); + }; + const stop = () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', stop); + window.removeEventListener('pointercancel', stop); + }; + + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', stop); + window.addEventListener('pointercancel', stop); + event.preventDefault(); + }, [clampEventRailWidth, eventRailCollapsed, eventRailWidth]); + + const adjustEventRailWidth = useCallback((event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const delta = event.shiftKey ? 48 : 16; + setEventRailWidth((current) => clampEventRailWidth(current + (event.key === 'ArrowLeft' ? delta : -delta))); + event.preventDefault(); + }, [clampEventRailWidth]); + + useEffect(() => { + const handleResize = () => setEventRailWidth((current) => clampEventRailWidth(current)); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [clampEventRailWidth]); + + useEffect(() => { + const refreshTitle = (event) => { + const nextTitle = event?.detail && typeof event.detail.title === 'string' + ? event.detail.title + : readCustomCommandTitle(); + setCustomCommandTitle(nextTitle.trim()); + }; + const handleStorage = (event) => { + if (event.key === CUSTOM_COMMAND_TITLE_KEY) { + setCustomCommandTitle((event.newValue || '').trim()); + } + }; + window.addEventListener(CUSTOM_COMMAND_TITLE_CHANGED_EVENT, refreshTitle); + window.addEventListener('storage', handleStorage); + return () => { + window.removeEventListener(CUSTOM_COMMAND_TITLE_CHANGED_EVENT, refreshTitle); + window.removeEventListener('storage', handleStorage); + }; + }, []); + const loadStats = useCallback(async (filter, options = {}) => { if (options.skipIfBusy && statsPending.current > 0) return; const requestId = ++statsRequestId.current; @@ -1794,6 +2431,53 @@ export default function Page() { setTimeMenuOpen(false); }, []); + useEffect(() => { + let stopped = false; + let timer = 0; + + const schedule = (delay) => { + if (!stopped) timer = window.setTimeout(() => void poll(), delay); + }; + + const poll = async () => { + if (stopped) return; + try { + const response = await getApi().page.get('/task-center'); + const payload = response.data || {}; + if (!stopped) { + setTaskCenter({ + ...createTaskCenterState(), + ...payload, + scheduledTasks: Array.isArray(payload.scheduledTasks) ? payload.scheduledTasks : [], + workflows: Array.isArray(payload.workflows) ? payload.workflows : [], + sessionCount: Math.max(Number(payload.sessionCount || 0), 0), + scheduledExecutionCount: Math.max(Number(payload.scheduledExecutionCount || 0), 0), + scheduledTodayExecutionCount: Math.max(Number(payload.scheduledTodayExecutionCount || 0), 0), + workflowExecutionCount: Math.max(Number(payload.workflowExecutionCount || 0), 0), + workflowTodayExecutionCount: Math.max(Number(payload.workflowTodayExecutionCount || 0), 0), + connection: 'online', + error: '', + }); + } + } catch (taskCenterError) { + if (!stopped) { + setTaskCenter((previous) => ({ + ...previous, + connection: 'error', + error: taskCenterError instanceof Error ? taskCenterError.message : 'task center api failed', + })); + } + } + schedule(ACTIVITY_POLL_MS); + }; + + void poll(); + return () => { + stopped = true; + window.clearTimeout(timer); + }; + }, []); + useEffect(() => { let stopped = false; let timer = 0; @@ -1833,11 +2517,20 @@ export default function Page() { if (payload.error) throw new Error(payload.error); activityCursor.current = payload.cursor || activityCursor.current; if (!stopped) { + if (payload.tokenUsage) { + setStats((previous) => mergeStats({ ...previous, tokenUsage: payload.tokenUsage })); + } const rawIncomingEvents = bootstrap ? recentUnseenActivity(payload.recentEvents) : (payload.events || []); const incomingEvents = rawIncomingEvents.filter((event) => event?.stage !== 'denoise'); const workflowEvents = Array.isArray(payload.workflowEvents) ? payload.workflowEvents : []; + for (const workflowEvent of workflowEvents) { + const hasConversation = Boolean(String(workflowEvent?.sessionId || workflowEvent?.sessionID || '').trim()); + if (hasConversation) { + incomingEvents.push(workflowEvent); + } + } const rawCallCount = payload.workflowStats?.callCount; const hasWorkflowCount = rawCallCount !== null && rawCallCount !== undefined @@ -1924,20 +2617,22 @@ export default function Page() { useEffect(() => { const event = activity.denoise.current; if (!event) return undefined; + if (isRunningWorkflowEvent(event)) return undefined; const id = window.setTimeout(() => { setActivity((previous) => completeActivity(previous, 'denoise', event)); }, activityDuration(event)); return () => window.clearTimeout(id); - }, [activity.denoise.current?.eventId]); + }, [activity.denoise.current?.eventId, activity.denoise.current?.status]); useEffect(() => { const event = activity.triage.current; if (!event) return undefined; + if (isRunningWorkflowEvent(event)) return undefined; const id = window.setTimeout(() => { setActivity((previous) => completeActivity(previous, 'triage', event)); }, activityDuration(event)); return () => window.clearTimeout(id); - }, [activity.triage.current?.eventId]); + }, [activity.triage.current?.eventId, activity.triage.current?.status]); useEffect(() => { if (!activity.batchUpdatedAt) return undefined; @@ -1951,28 +2646,60 @@ export default function Page() { return () => window.clearTimeout(id); }, [activity.batchUpdatedAt]); - const activityBusy = Boolean( - activity.denoise.current - || activity.triage.current - || activity.denoise.queue.length - || activity.triage.queue.length - || activity.batch?.receivedCount - || activity.batch?.triageUpdatedCount + const displayActivity = useMemo( + () => (activityHasVisibleEvents(activity) ? activity : createMockActivityState()), + [activity], + ); + const displayActivityBusy = Boolean( + displayActivity.denoise.current + || displayActivity.triage.current + || displayActivity.denoise.queue.length + || displayActivity.triage.queue.length + || displayActivity.batch?.receivedCount + || displayActivity.batch?.triageUpdatedCount ); return h('div', { - className: cx('adtd-root command-root', activityBusy && 'command-is-processing', eventRailCollapsed && 'event-rail-is-collapsed'), + className: cx('adtd-root command-root', displayActivityBusy && 'command-is-processing', eventRailCollapsed && 'event-rail-is-collapsed'), 'data-animations': 'on', + style: { '--event-rail-width': `${eventRailWidth}px` }, }, [ h('style', { key: 'style' }, CSS), - h(CommandHeader, { key: 'header', timeFilter, refreshKey, timeMenuOpen, setTimeMenuOpen, applyTimeRefresh, stats, loading, refresh, activity }), + h(CommandHeader, { + key: 'header', + title: customCommandTitle || DEFAULT_COMMAND_TITLE, + timeFilter, + refreshKey, + timeMenuOpen, + setTimeMenuOpen, + applyTimeRefresh, + stats, + loading, + refresh, + activity: displayActivity, + }), error ? h('div', { className: 'error-banner', key: 'error' }, `统计接口异常:${error}`) : null, - h('main', { className: cx('command-shell', eventRailCollapsed && 'event-rail-collapsed'), key: 'main' }, [ + h('main', { + className: cx('command-shell', eventRailCollapsed && 'event-rail-collapsed'), + key: 'main', + }, [ h('div', { className: 'command-main', key: 'workspace' }, [ - h(CommandGraph, { key: 'graph', stats, activity }), + h(CommandGraph, { key: 'graph', stats, activity: displayActivity }), h(CommandMetrics, { key: 'metrics', stats }), ]), - h(CommandEventRail, { key: 'events', activity, timeFilter, collapsed: eventRailCollapsed, onToggle: () => setEventRailCollapsed((current) => !current) }), + h(CommandEventRail, { + key: 'events', + activity: displayActivity, + timeFilter, + taskCenter, + view: rightRailView, + onViewChange: setRightRailView, + collapsed: eventRailCollapsed, + onToggle: () => setEventRailCollapsed((current) => !current), + railWidth: eventRailWidth, + onResizeStart: startEventRailResize, + onResizeKeyDown: adjustEventRailWidth, + }), ]), ]); } @@ -3310,7 +4037,7 @@ const CSS = ` .command-root:before { content: ""; position: absolute; - inset: 68px 330px 142px 0; + inset: 68px var(--event-rail-width, 330px) 142px 0; pointer-events: none; opacity: .16; background-image: @@ -3514,7 +4241,7 @@ const CSS = ` position: relative; z-index: 2; display: grid; - grid-template-columns: minmax(0, 1fr) 330px; + grid-template-columns: minmax(0, 1fr) var(--event-rail-width, 330px); height: calc(100vh - 68px); min-height: 652px; transition: grid-template-columns .24s ease; @@ -4010,6 +4737,62 @@ const CSS = ` .command-metric .sparkline { width: 100%; height: 43px; margin-top: 6px; opacity: .75; } .command-metric .spark-grid { opacity: 0; } .command-metric .spark-line { stroke-width: 4; filter: drop-shadow(0 0 5px var(--metric-color)); } +.token-usage-metric { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + row-gap: 7px; + padding-bottom: 8px; +} +.token-title { + display: block; + color: #b6bdbb; + font-size: 12px; +} +.token-summary-row { + display: grid; + grid-template-columns: minmax(96px, 48%) minmax(0, 1fr); + align-items: center; + column-gap: 8px; + min-width: 0; +} +.token-value { + display: block; + min-width: 0; + min-height: 30px; + overflow: hidden; + color: #f1f4f3; + font-size: 25px; + font-weight: 700; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} +.token-sub { + display: block; + min-width: 0; + overflow: hidden; + color: var(--metric-color); + font-size: 9px; + line-height: 13.5px; +} +.token-chart { + display: block; + width: 100%; + height: 42px; + margin: 0; + overflow: visible; +} +.token-chart-line { + filter: drop-shadow(0 0 5px var(--metric-color)); + animation: commandMetricGlow 2.8s ease-in-out infinite; +} +.token-sub-line { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .command-event-rail { position: relative; z-index: 6; @@ -4026,56 +4809,86 @@ const CSS = ` background: transparent; overflow: visible; } +.event-rail-resize { + position: absolute; + z-index: 14; + top: 0; + bottom: 0; + left: -5px; + width: 10px; + cursor: col-resize; + outline: none; + touch-action: none; +} +.event-rail-resize:before { + content: ""; + position: absolute; + top: 12px; + bottom: 12px; + left: 4px; + width: 2px; + border-radius: 999px; + background: rgba(43,231,255,.16); + opacity: 0; + transition: opacity .16s ease, background .16s ease, box-shadow .16s ease; +} +.event-rail-resize:hover:before, +.event-rail-resize:focus-visible:before { + background: rgba(43,231,255,.58); + box-shadow: 0 0 12px rgba(43,231,255,.32); + opacity: 1; +} .event-rail-toggle { position: absolute; z-index: 12; top: 50%; - left: -14px; - width: 28px; - height: 44px; - border: 0; - color: #6ee8ff; - background: transparent; + left: -25px; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 64px; + border: 1px solid rgba(43,231,255,.34); + border-right: 0; + border-radius: 7px 0 0 7px; + color: #8df0ff; + background: linear-gradient(180deg, rgba(8,34,53,.96), rgba(5,22,38,.96)); transform: translateY(-50%); - opacity: .72; + opacity: .9; padding: 0; - font-size: 28px; - line-height: 1; - text-shadow: 0 0 9px rgba(43,231,255,.48); + font-size: 12px; + font-weight: 700; + line-height: 1.15; + text-orientation: upright; + writing-mode: vertical-rl; + box-shadow: -4px 0 14px rgba(0,0,0,.28), 0 0 12px rgba(43,231,255,.1); cursor: pointer; - transition: color .16s ease, opacity .16s ease, transform .16s ease; + transition: border-color .1s ease, color .1s ease, background .1s ease, box-shadow .1s ease, transform .1s ease; } -.event-rail-toggle:hover { +.event-rail-toggle:hover, +.event-rail-toggle:focus-visible { + border-color: rgba(70,240,255,.72); color: #fff; - opacity: 1; - transform: translate(2px, -50%); + background: rgba(7,39,62,.98); + box-shadow: -5px 0 16px rgba(0,0,0,.32), 0 0 16px rgba(43,231,255,.24); + outline: none; + transform: translate(-1px, -50%); +} +.event-rail-toggle:active { + transform: translate(0, -50%) scale(.98); } .command-event-rail.collapsed .event-rail-toggle { - left: -40px; - width: 40px; - height: 104px; - border-radius: 12px 0 0 12px; - color: #f4f7f9; - background: rgba(65, 73, 82, .96); - box-shadow: -5px 0 16px rgba(0,0,0,.22); - opacity: .96; - text-shadow: none; -} -.command-event-rail.collapsed .event-rail-toggle:hover { + left: -26px; + width: 26px; + height: 64px; + color: #dffcff; + background: linear-gradient(180deg, rgba(8,34,53,.96), rgba(5,22,38,.96)); +} +.command-event-rail.collapsed .event-rail-toggle:hover, +.command-event-rail.collapsed .event-rail-toggle:focus-visible { color: #fff; - background: rgba(78, 88, 98, .98); - transform: translate(-2px, -50%); -} -.command-event-rail.collapsed .event-rail-toggle span { - display: grid; - place-items: center; - gap: 2px; -} -.command-event-rail.collapsed .event-rail-toggle i { - font-style: normal; - font-size: 14px; - font-weight: 700; - line-height: 1.12; + background: rgba(11,52,78,.98); + transform: translate(-1px, -50%); } .event-rail-head { display: flex; @@ -4096,6 +4909,38 @@ const CSS = ` text-align: center; font-size: 11px; } +.rail-view-head { padding: 0 14px; } +.rail-view-head .rail-view-tabs { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + min-width: 0; + border: 1px solid rgba(88,166,255,.18); + border-radius: 6px; + background: rgba(3,14,26,.72); + padding: 3px; +} +.rail-view-tabs button { + height: 28px; + min-width: 0; + border: 0; + border-radius: 4px; + color: #7e8f99; + background: transparent; + padding: 0 9px; + font: inherit; + font-size: 11px; + font-weight: 650; + white-space: nowrap; + cursor: pointer; +} +.rail-view-tabs button:hover { color: #d9f7ff; background: rgba(43,231,255,.08); } +.rail-view-tabs button.active { + color: #061725; + background: linear-gradient(135deg, #35d4ff, #40e1bd); + box-shadow: 0 0 13px rgba(43,231,255,.14); +} .event-update-banner { margin: 10px 14px 4px; padding: 10px 12px; @@ -4107,6 +4952,319 @@ const CSS = ` } .event-update-banner:before { content: "ⓘ"; margin-right: 7px; color: #6ba4fb; } .event-update-banner.warn { border-color: rgba(255,174,52,.42); color: #f1c67d; background: rgba(139,88,22,.2); } +.task-center-panel { + min-height: 0; + padding: 8px 14px 18px; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: #3a403e transparent; +} +.task-center-inline-warn { + margin-bottom: 10px; + border: 1px solid rgba(255,174,52,.42); + border-radius: 6px; + color: #f1c67d; + background: rgba(139,88,22,.2); + padding: 9px 11px; + font-size: 11px; +} +.task-center-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.task-center-summary > div { + min-width: 0; + min-height: 64px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + border: 1px solid rgba(43,231,255,.14); + border-radius: 6px; + background: rgba(10,33,51,.46); + padding: 10px 11px; +} +.task-center-summary > div.active { + border-color: rgba(46,230,166,.32); + background: rgba(20,75,57,.32); + animation: commandEventActive 1.8s ease-in-out infinite; +} +.task-center-summary span { + overflow: hidden; + color: #7f8e99; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-summary b { + color: #e9edeb; + font-size: 18px; + font-variant-numeric: tabular-nums; +} +.task-center-summary small { + overflow: hidden; + color: #49e4c8; + font-size: 10px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-section { margin-top: 12px; } +.task-center-section-title { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 28px; + border-bottom: 1px solid rgba(43,231,255,.12); + margin-bottom: 3px; +} +.task-center-section-toggle { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 6px; + border: 0; + color: #dce1df; + background: transparent; + padding: 0; + font: inherit; + cursor: pointer; +} +.task-center-section-toggle:hover, +.task-center-section-toggle:focus-visible { color: #66e6d6; outline: none; } +.task-center-section-toggle i { + display: grid; + place-items: center; + width: 14px; + height: 14px; + color: #53e9c4; + font-style: normal; + font-size: 15px; + line-height: 1; +} +.task-center-section-title strong { color: currentColor; font-size: 12px; } +.task-center-section-title span { color: #66716d; font-size: 10px; } +.task-center-section-list { display: grid; gap: 0; } +.task-center-item { + position: relative; + min-height: 118px; + display: flex; + flex-direction: column; + gap: 8px; + border-bottom: 1px solid rgba(255,255,255,.09); + padding: 13px 2px 13px 12px; +} +.task-center-item:before { + content: ""; + position: absolute; + top: 16px; + bottom: 16px; + left: 1px; + width: 1px; + background: rgba(105,129,143,.25); +} +.task-center-item:after { + content: ""; + position: absolute; + top: 20px; + left: -2px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #2be7ff; + box-shadow: 0 0 9px rgba(43,231,255,.54); +} +.task-center-item.active { + background: linear-gradient(90deg, rgba(43,231,255,.06), transparent 64%); + animation: commandEventActive 1.8s ease-in-out infinite; +} +.task-center-item.clickable { + cursor: pointer; +} +.task-center-item.clickable:hover { + background: linear-gradient(90deg, rgba(43,231,255,.085), rgba(43,231,255,.025) 58%, transparent); +} +.task-center-item.clickable:focus-visible { + outline: 1px solid rgba(85,232,255,.68); + outline-offset: -2px; +} +.task-center-item.active:after { + background: #46e4af; + box-shadow: 0 0 10px rgba(70,228,175,.78); +} +.task-center-item-head, +.task-center-stats { + display: flex; + align-items: center; + min-width: 0; +} +.task-center-item-head { gap: 8px; } +.task-center-item-head strong { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: #dce1df; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-status { + flex: 0 0 auto; + max-width: 74px; + overflow: hidden; + border-radius: 4px; + color: #83aef1; + background: rgba(52,86,143,.5); + padding: 4px 7px; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-status.active, +.task-center-status.running { color: #57e1b5; background: rgba(23,111,83,.48); } +.task-center-status.failed, +.task-center-status.error, +.task-center-status.timeout { color: #ff9a76; background: rgba(133,57,33,.42); } +.task-center-item-sub { + min-width: 0; + overflow: hidden; + color: #7b8581; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-alert { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 7px; + min-width: 0; + color: #74827d; + font-size: 10px; +} +.task-center-alert span { + white-space: nowrap; +} +.task-center-alert b { + min-width: 0; + overflow: hidden; + color: #dce1df; + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-alert.has-alert b { + color: #f0f6f4; +} +.task-center-hash { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + border: 1px solid rgba(43,231,255,.16); + border-radius: 5px; + background: rgba(4,22,36,.64); + padding: 5px 6px; +} +.task-center-hash:after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -38%; + width: 32%; + background: linear-gradient(90deg, transparent, rgba(43,231,255,.18), transparent); + animation: commandBannerSweep 3.6s ease-in-out infinite; +} +.task-center-hash span { + color: #66716d; + font-size: 9px; + line-height: 1.2; + white-space: nowrap; +} +.task-center-hash code { + display: block; + min-width: 0; + overflow: hidden; + color: #77e8ff; + font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; +} +.task-center-hash .task-center-jump { + color: #7b8581; + user-select: none; +} +.task-center-hash .task-center-jump.enabled { + color: #57e1b5; +} +.task-center-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + color: #7f8e99; + font-size: 10px; +} +.task-center-stats.workflow-stats { + grid-template-columns: .7fr .7fr 1.05fr .85fr; + gap: 6px; +} +.task-center-stats span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-center-stats b { + color: #4dcfa7; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.task-center-rate { + position: relative; + height: 3px; + overflow: hidden; + border-radius: 3px; + background: rgba(127,111,255,.14); + box-shadow: inset 0 0 0 1px rgba(155,140,255,.11); +} +.task-center-rate i { + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(90deg, #38d39f 0%, #55e8ff 100%); + box-shadow: 0 0 8px rgba(43,231,255,.55); + transform: scaleX(var(--task-center-rate, 0)); + transform-origin: left center; + transition: transform .5s ease; +} +.task-center-rate.progress-rate { + background: rgba(43,231,255,.12); +} +.task-center-expand { + width: 100%; + height: 30px; + margin-top: 8px; + border: 1px solid rgba(43,231,255,.2); + border-radius: 5px; + color: #73e7ff; + background: rgba(10,31,51,.52); + font: inherit; + font-size: 11px; + cursor: pointer; +} +.task-center-expand:hover { + border-color: rgba(43,231,255,.46); + background: rgba(20,57,82,.62); +} .event-rail-list { min-height: 0; padding: 4px 16px 18px 20px; @@ -4149,6 +5307,16 @@ const CSS = ` .event-rail-item.state-processing { background: linear-gradient(90deg, rgba(35,214,157,.07), transparent 72%); } +.event-rail-item.clickable { + cursor: pointer; +} +.event-rail-item.clickable:hover { + background: linear-gradient(90deg, rgba(155,140,255,.12), rgba(43,231,255,.04) 60%, transparent); +} +.event-rail-item.clickable:focus-visible { + outline: 1px solid rgba(155,140,255,.72); + outline-offset: -2px; +} .event-rail-meta { display: flex; align-items: center; width: 100%; gap: 7px; } .event-rail-meta time { margin-left: auto; color: #646d69; font-size: 10px; } .event-queue-kind, @@ -4237,8 +5405,8 @@ const CSS = ` @keyframes laneResult { to { opacity: 1; } } @media (max-width: 1360px) { .command-header { grid-template-columns: minmax(330px, 1fr) auto minmax(500px, 1fr); gap: 12px; padding: 0 14px; } - .command-shell { grid-template-columns: minmax(0, 1fr) 292px; } - .command-root:before { right: 292px; } + .command-shell { grid-template-columns: minmax(0, 1fr) var(--event-rail-width, 292px); } + .command-root:before { right: var(--event-rail-width, 292px); } .command-core { width: 288px; height: 288px; min-width: 288px; min-height: 288px; } .command-time-trigger { max-width: 320px; } .command-clock { min-width: 92px; } @@ -4249,7 +5417,7 @@ const CSS = ` .command-root { padding: 0; } .command-header { grid-template-columns: 330px 1fr; } .command-live { display: none; } - .command-shell { grid-template-columns: minmax(850px, 1fr) 280px; } + .command-shell { grid-template-columns: minmax(850px, 1fr) var(--event-rail-width, 280px); } .command-core { width: 270px; height: 270px; min-width: 270px; min-height: 270px; } .agent-badge { min-width: 96px; padding: 7px 8px; } .command-lanes { right: 12%; left: 12%; } diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_test.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_test.yaml index bb430e9c9..104af326d 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_test.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_test.yaml @@ -12,14 +12,6 @@ connectivity: # `label` is the default (English) display string; `label_cn` is the # optional Chinese override picked when the WebUI runs in zh-CN. fixtures: - qingteng_login: - - label: "Login and retrieve session token" - label_cn: "登录并获取会话 Token" - tags: [smoke] - params: {} - assert: - success: true - qingteng_system_audit: - label: "List audit logs (page 1)" label_cn: "查询系统审计日志(第 1 页)" diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng.handler.py b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng.handler.py index e14a510cd..b4e95bc5e 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng.handler.py +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng.handler.py @@ -1599,21 +1599,6 @@ async def _dispatch_group(ctx: ToolContext, group: str, action: str, **params: A return _request_signed_json(spec.method, path, query=query, body=body, action=f"{group}.{action}") -async def login(ctx: ToolContext, **kwargs: Any) -> ToolResult: - del ctx, kwargs - config = _load_runtime_config() - if not config: - return ToolResult( - success=False, - error="Missing configuration: qingteng base_url/qingteng_host, qingteng_username, qingteng_password", - ) - conn_cls, host, port, base_path, username, password = config - ok, result, payload = _login_request(conn_cls, host, port, base_path, username, password) - if not ok: - return ToolResult(success=False, error=str(result), output=payload) - return ToolResult(success=True, output=result, metadata={"source": "Qingteng", "api": "login", "path": "/v1/api/auth"}) - - async def system_audit( ctx: ToolContext, eventName: str | None = None, diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_login.handler.py b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_login.handler.py deleted file mode 100644 index 695df3dff..000000000 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_login.handler.py +++ /dev/null @@ -1,20 +0,0 @@ -import importlib.util -from pathlib import Path - -from flocks.tool.registry import ToolContext, ToolResult - - -def _load_core_module(): - script_path = Path(__file__).with_name("qingteng.handler.py") - spec = importlib.util.spec_from_file_location("_flocks_qingteng_core", str(script_path)) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot create import spec for {script_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -async def login(ctx: ToolContext, **kwargs) -> ToolResult: - del kwargs - core = _load_core_module() - return await core.login(ctx) diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_login.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_login.yaml deleted file mode 100644 index 433b6ee7f..000000000 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_login.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: qingteng_login -description: > - 青藤云安全平台登录认证。首次使用需调用此接口获取认证凭证, - 返回的 jwt、signKey、comId 用于后续 API 请求的签名验证。 - 当用户提到青藤云主机安全、漏洞检测、基线检查时需要先调用此工具。 -category: custom -enabled: true -requires_confirmation: false -provider: qingteng - -inputSchema: - type: object - properties: {} - required: [] - -handler: - type: script - script_file: qingteng_login.handler.py - function: login - -notes: | - 返回数据包含: - - signKey: 签名密钥,用于后续请求签名 - - jwt: JWT token - - comId: 公司ID - - 提示:用户名密码应配置在 SecretManager 中 diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index c35f13b49..16885ef7e 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -2548,14 +2548,14 @@ async def test_provider_credentials( # Rank tools by required-parameter count (fewer = simpler); # prefer lightweight query/scan tools and avoid file/upload handlers. # - # NOTE: For services that expose a dedicated login/auth probe - # (e.g. qingteng_login, skyeye_login), we want it tried *first* — + # NOTE: For services that expose a dedicated login/auth probe, we + # want it tried *first* — # it is parameter-free and exercises the credential pipeline end # to end without invoking business APIs that may need extra # required fields beyond the JSON schema (e.g. assets.refresh # which the handler validates needs `resource`/`os_type`). # Match either the bare keyword (e.g. "login") or the conventional - # `_` suffix (e.g. "qingteng_login"). A loose + # `_` suffix (e.g. "service_login"). A loose # substring match would over-trigger on business tools whose names # merely contain the word — for example tdp_login_api_list and # tdp_login_weakpwd_list are query endpoints, not probes. diff --git a/flocks/server/routes/workflow.py b/flocks/server/routes/workflow.py index 0ac9d0429..dce2cb020 100644 --- a/flocks/server/routes/workflow.py +++ b/flocks/server/routes/workflow.py @@ -1136,6 +1136,14 @@ async def _run_workflow_execution_task( "currentStepIndex": 0, "stepCount": 0, } + if req.session_id or req.message_id: + execution_summary.update( + { + "sessionId": req.session_id, + "messageId": req.message_id, + "agent": req.agent, + } + ) def _write_progress(update_fields: Dict[str, Any]) -> None: try: @@ -1737,6 +1745,16 @@ async def run_workflow_endpoint(workflow_id: str, req: WorkflowRunRequest): workflow_id, input_params=req.inputs or {}, ) + if req.session_id or req.message_id: + exec_data.update( + { + "sessionId": req.session_id, + "messageId": req.message_id, + "agent": req.agent, + "updatedAt": int(time.time() * 1000), + } + ) + await WorkflowStore.upsert_execution(compact_execution_summary(exec_data)) exec_id = str(exec_data["id"]) cancel_event = threading.Event() diff --git a/tests/hub/test_soc_dashboard_schema.py b/tests/hub/test_soc_dashboard_schema.py index 30e87ce49..4b4516f3f 100644 --- a/tests/hub/test_soc_dashboard_schema.py +++ b/tests/hub/test_soc_dashboard_schema.py @@ -2,7 +2,7 @@ import json import sqlite3 import sys -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path @@ -438,6 +438,652 @@ def test_soc_dashboard_activity_exposes_live_denoise_workflow_progress(tmp_path: } +def test_soc_dashboard_activity_exposes_triage_workflow_link_context(tmp_path: Path): + workflow_db = tmp_path / "workflow.db" + now_ms = int(datetime.now().timestamp() * 1000) + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_executions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + status TEXT NOT NULL, + input_params TEXT NOT NULL DEFAULT '{}', + output_results TEXT NOT NULL DEFAULT '{}', + started_at INTEGER NOT NULL, + payload TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + conn.execute( + """ + INSERT INTO workflow_executions + (id, workflow_id, status, input_params, output_results, started_at, payload) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "triage-running", + "stream_alert_triage", + "running", + json.dumps({"input_date": "2026-07-23"}), + json.dumps( + { + "enriched_alerts_with_triage": [ + {"threat_name": "远程命令执行攻击"} + ] + }, + ensure_ascii=False, + ), + now_ms, + json.dumps({"sessionId": "session-triage-1", "messageId": "msg-triage-1"}), + ), + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.WORKFLOW_DB = workflow_db + handlers.DEFAULT_SQLITE_DB = tmp_path / "missing-soc.db" + handlers._activity_pruned_at = float("inf") + + payload = handlers._get_activity({"bootstrap": "latest"}) + triage_events = [ + event for event in payload["workflowEvents"] + if event["workflowId"] == "stream_alert_triage" + ] + + assert len(triage_events) == 1 + triage = triage_events[0] + assert triage["stage"] == "triage" + assert triage["status"] == "running" + assert triage["alert"]["threatName"] == "远程命令执行攻击" + assert triage["sessionId"] == "session-triage-1" + assert triage["messageId"] == "msg-triage-1" + + +def test_soc_dashboard_activity_tolerates_empty_soc_db_with_workflow_events(tmp_path: Path): + soc_db = tmp_path / "soc.db" + soc_db.touch() + workflow_db = tmp_path / "workflow.db" + now_ms = int(datetime.now().timestamp() * 1000) + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_executions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + status TEXT NOT NULL, + input_params TEXT NOT NULL DEFAULT '{}', + output_results TEXT NOT NULL DEFAULT '{}', + started_at INTEGER NOT NULL, + payload TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + conn.execute( + "INSERT INTO workflow_executions VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + "triage-running", + "stream_alert_triage", + "running", + "{}", + json.dumps({"triage_results": [{"alert_name": "Mock 研判"}]}, ensure_ascii=False), + now_ms, + json.dumps({"sessionId": "session-1", "messageId": "message-1"}), + ), + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.DEFAULT_SQLITE_DB = soc_db + handlers.WORKFLOW_DB = workflow_db + handlers._activity_pruned_at = float("inf") + + payload = handlers._get_activity({"bootstrap": "latest"}) + + assert "error" not in payload + assert payload["workflowEvents"][0]["alert"]["threatName"] == "Mock 研判" + assert payload["workflowEvents"][0]["sessionId"] == "session-1" + + +def test_soc_dashboard_task_center_summarizes_tasks_and_workflows(tmp_path: Path): + tasks_db = tmp_path / "tasks.db" + today_at_1100 = datetime.now().astimezone().replace( + hour=11, + minute=0, + second=0, + microsecond=0, + ) + today_at_1105 = today_at_1100.replace(minute=5) + today_at_1110 = today_at_1100.replace(minute=10) + tomorrow_at_1205 = (today_at_1100 + timedelta(days=1)).replace(hour=12, minute=5) + yesterday_at_1100 = today_at_1100 - timedelta(days=1) + yesterday_at_1105 = today_at_1105 - timedelta(days=1) + today_ms = int(today_at_1100.timestamp() * 1000) + yesterday_ms = int(yesterday_at_1100.timestamp() * 1000) + with sqlite3.connect(tasks_db) as conn: + conn.execute( + """ + CREATE TABLE task_schedulers ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + mode TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + execution_mode TEXT NOT NULL, + workflow_id TEXT, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE task_executions ( + id TEXT PRIMARY KEY, + scheduler_id TEXT NOT NULL, + status TEXT NOT NULL, + queued_at TEXT, + started_at TEXT, + completed_at TEXT, + updated_at TEXT, + created_at TEXT, + session_id TEXT + ) + """ + ) + conn.execute( + "INSERT INTO task_schedulers VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + "sched-1", + "每日告警巡检", + "cron", + "active", + json.dumps({"cron": "*/5 * * * *", "nextRun": tomorrow_at_1205.isoformat(timespec="seconds")}), + "workflow", + "stream_alert_denoise", + today_at_1100.isoformat(timespec="seconds"), + ), + ) + conn.executemany( + "INSERT INTO task_executions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + ( + "exec-1", + "sched-1", + "completed", + yesterday_at_1100.isoformat(timespec="seconds"), + (yesterday_at_1100 + timedelta(seconds=1)).isoformat(timespec="seconds"), + (yesterday_at_1100 + timedelta(seconds=10)).isoformat(timespec="seconds"), + (yesterday_at_1100 + timedelta(seconds=10)).isoformat(timespec="seconds"), + yesterday_at_1100.isoformat(timespec="seconds"), + "session-a", + ), + ( + "exec-2", + "sched-1", + "failed", + yesterday_at_1105.isoformat(timespec="seconds"), + (yesterday_at_1105 + timedelta(seconds=1)).isoformat(timespec="seconds"), + (yesterday_at_1105 + timedelta(seconds=10)).isoformat(timespec="seconds"), + (yesterday_at_1105 + timedelta(seconds=10)).isoformat(timespec="seconds"), + yesterday_at_1105.isoformat(timespec="seconds"), + "session-a", + ), + ( + "exec-3", + "sched-1", + "running", + today_at_1110.isoformat(timespec="seconds"), + (today_at_1110 + timedelta(seconds=1)).isoformat(timespec="seconds"), + None, + (today_at_1110 + timedelta(seconds=1)).isoformat(timespec="seconds"), + today_at_1110.isoformat(timespec="seconds"), + "session-b", + ), + ], + ) + conn.commit() + + workflow_db = tmp_path / "workflow.db" + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_stats ( + workflow_id TEXT PRIMARY KEY, + call_count INTEGER NOT NULL, + success_count INTEGER NOT NULL, + error_count INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE workflow_executions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + status TEXT NOT NULL, + current_phase TEXT, + current_node_id TEXT, + current_node_type TEXT, + current_step_index INTEGER, + step_count INTEGER NOT NULL DEFAULT 0, + input_params TEXT NOT NULL DEFAULT '{}', + output_results TEXT NOT NULL DEFAULT '{}', + error_message TEXT, + started_at INTEGER NOT NULL, + finished_at INTEGER, + updated_at INTEGER, + payload TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + conn.execute( + """ + CREATE TABLE workflow_configs ( + workflow_id TEXT NOT NULL, + kind TEXT NOT NULL, + version INTEGER, + config TEXT NOT NULL, + updated_at INTEGER, + PRIMARY KEY (workflow_id, kind) + ) + """ + ) + conn.executemany( + "INSERT INTO workflow_stats VALUES (?, ?, ?, ?, ?)", + [ + ("custom_workflow", 2, 1, 1, 1784775000000), + ("stream_alert_denoise", 10, 9, 1, today_ms), + ("stream_alert_triage", 1, 0, 0, yesterday_ms + 5000), + ("e6d5581a-b105-4c75-a102-1d8e6c97e1c1", 1, 0, 0, 1784775700000), + ], + ) + conn.executemany( + """ + INSERT INTO workflow_executions ( + id, workflow_id, status, current_phase, current_node_id, current_node_type, + current_step_index, step_count, input_params, output_results, error_message, + started_at, finished_at, updated_at, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + "wf-1", + "custom_workflow", + "error", + "error", + "node-1", + "python", + 1, + 1, + "{}", + "{}", + "boom", + yesterday_ms, + yesterday_ms + 1000, + yesterday_ms + 1000, + "{}", + ), + ( + "wf-2", + "stream_alert_denoise", + "success", + "success", + "dedup", + "python", + 1, + 1, + json.dumps({"alerts": [{"threat_name": "SQL注入攻击"}]}), + json.dumps({"unique_alerts": [{"threat_name": "SQL注入攻击"}]}), + None, + today_ms, + today_ms + 1000, + today_ms + 1000, + "{}", + ), + ( + "wf-triage", + "stream_alert_triage", + "running", + "running", + "concurrent_triage", + "python", + 2, + 1, + json.dumps({"input_date": yesterday_at_1100.date().isoformat()}), + json.dumps( + { + "enriched_alerts_with_triage": [ + { + "threat_name": "远程命令执行攻击", + "report_title": "高危RCE研判", + } + ] + }, + ensure_ascii=False, + ), + None, + yesterday_ms + 5000, + None, + yesterday_ms + 6000, + json.dumps({"sessionId": "session-triage-1", "messageId": "msg-triage-1"}), + ), + ( + "wf-dynamic", + "e6d5581a-b105-4c75-a102-1d8e6c97e1c1", + "running", + "running", + "node-1", + "python", + 1, + 0, + "{}", + "{}", + None, + 1784775700000, + None, + 1784775700000, + "{}", + ), + ], + ) + conn.executemany( + "INSERT INTO workflow_configs VALUES (?, ?, ?, ?, ?)", + [ + ( + "custom_workflow", + "workflow.integration-config", + 1, + json.dumps({"workflow": {"id": "custom_workflow", "name": "自定义处置流"}}), + 1784775000000, + ), + ( + "stream_alert_denoise", + "workflow.integration-config", + 1, + json.dumps({"workflow": {"id": "stream_alert_denoise", "name": "流式告警降噪"}}), + 1784775600000, + ), + ], + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.TASK_DB = tasks_db + handlers.WORKFLOW_DB = workflow_db + + payload = handlers._get_task_center() + + assert payload["sessionCount"] == 2 + assert payload["scheduledTasks"] == [ + { + "id": "sched-1", + "name": "每日告警巡检", + "mode": "cron", + "status": "active", + "executionMode": "workflow", + "workflowId": "stream_alert_denoise", + "executionCount": 3, + "todayExecutionCount": 1, + "successCount": 1, + "successRate": 0.3333, + "activeCount": 1, + "lastStatus": "running", + "lastRunAt": (today_at_1110 + timedelta(seconds=1)).isoformat(timespec="seconds"), + "nextRunAt": tomorrow_at_1205.isoformat(timespec="seconds"), + "cron": "*/5 * * * *", + "cronDescription": "", + } + ] + assert payload["scheduledExecutionCount"] == 3 + assert payload["scheduledTodayExecutionCount"] == 1 + assert payload["workflowExecutionCount"] == 13 + assert payload["workflowTodayExecutionCount"] == 1 + assert [workflow["id"] for workflow in payload["workflows"]] == [ + "stream_alert_triage", + "stream_alert_denoise", + "custom_workflow", + ] + assert [workflow["name"] for workflow in payload["workflows"]] == [ + "告警研判工作流", + "告警降噪工作流", + "自定义处置流", + ] + triage = payload["workflows"][0] + assert triage["latestExecutionHash"] == "wf-triage" + assert triage["latestAlertName"] == "远程命令执行攻击" + assert triage["progressLabel"] == "第 2/3 步" + assert triage["progressPercent"] == 0.6667 + assert triage["sessionId"] == "session-triage-1" + assert triage["messageId"] == "msg-triage-1" + denoise = payload["workflows"][1] + assert denoise["executionCount"] == 10 + assert denoise["todayExecutionCount"] == 1 + assert denoise["successCount"] == 9 + assert denoise["successRate"] == 0.9 + assert denoise["latestExecutionHash"] == "wf-2" + assert denoise["latestAlertName"] == "SQL注入攻击" + assert denoise["progressLabel"] == "已完成" + + +def test_soc_dashboard_task_center_supports_legacy_workflow_execution_schema(tmp_path: Path): + workflow_db = tmp_path / "workflow.db" + today_at_1100 = datetime.now().astimezone().replace( + hour=11, + minute=0, + second=0, + microsecond=0, + ) + today_ms = int(today_at_1100.timestamp() * 1000) + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_stats ( + workflow_id TEXT PRIMARY KEY, + call_count INTEGER NOT NULL, + success_count INTEGER NOT NULL, + error_count INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE workflow_executions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + "INSERT INTO workflow_stats VALUES (?, ?, ?, ?)", + ("stream_alert_denoise", 1, 0, 0), + ) + conn.execute( + "INSERT INTO workflow_executions VALUES (?, ?, ?, ?)", + ("wf-running", "stream_alert_denoise", "running", today_ms), + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.TASK_DB = tmp_path / "missing-tasks.db" + handlers.WORKFLOW_DB = workflow_db + + payload = handlers._get_task_center() + + denoise = next( + workflow for workflow in payload["workflows"] if workflow["id"] == "stream_alert_denoise" + ) + assert payload["workflowExecutionCount"] == 1 + assert payload["workflowTodayExecutionCount"] == 1 + assert denoise["executionCount"] == 1 + assert denoise["todayExecutionCount"] == 1 + assert denoise["activeCount"] == 1 + assert denoise["latestExecutionHash"] == "wf-running" + assert denoise["lastRunAt"] == today_ms + + +def test_soc_dashboard_task_center_orders_dynamic_rows(tmp_path: Path): + now = datetime.now().astimezone().replace(microsecond=0) + older = now - timedelta(hours=3) + recent = now - timedelta(minutes=5) + future = now + timedelta(hours=2) + older_ms = int(older.timestamp() * 1000) + recent_ms = int(recent.timestamp() * 1000) + + tasks_db = tmp_path / "tasks.db" + with sqlite3.connect(tasks_db) as conn: + conn.execute( + """ + CREATE TABLE task_schedulers ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + mode TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + execution_mode TEXT NOT NULL, + workflow_id TEXT, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE task_executions ( + id TEXT PRIMARY KEY, + scheduler_id TEXT NOT NULL, + status TEXT NOT NULL, + queued_at TEXT, + started_at TEXT, + completed_at TEXT, + updated_at TEXT, + created_at TEXT, + session_id TEXT + ) + """ + ) + conn.executemany( + "INSERT INTO task_schedulers VALUES (?, ?, 'cron', 'active', ?, 'agent', '', ?)", + [ + ("sched-active", "正在执行任务", "{}", older.isoformat()), + ("sched-recent", "最近完成任务", "{}", recent.isoformat()), + ("sched-popular", "高频历史任务", "{}", older.isoformat()), + ( + "sched-future", + "未来待执行任务", + json.dumps({"nextRun": future.isoformat(timespec="seconds")}), + now.isoformat(), + ), + ], + ) + task_exec_rows = [ + ( + "exec-active", + "sched-active", + "running", + older.isoformat(timespec="seconds"), + older.isoformat(timespec="seconds"), + None, + older.isoformat(timespec="seconds"), + older.isoformat(timespec="seconds"), + "s-active", + ), + ( + "exec-recent", + "sched-recent", + "completed", + recent.isoformat(timespec="seconds"), + recent.isoformat(timespec="seconds"), + (recent + timedelta(seconds=3)).isoformat(timespec="seconds"), + (recent + timedelta(seconds=3)).isoformat(timespec="seconds"), + recent.isoformat(timespec="seconds"), + "s-recent", + ), + ] + for index in range(5): + when = older - timedelta(minutes=index) + task_exec_rows.append( + ( + f"exec-popular-{index}", + "sched-popular", + "completed", + when.isoformat(timespec="seconds"), + when.isoformat(timespec="seconds"), + (when + timedelta(seconds=2)).isoformat(timespec="seconds"), + (when + timedelta(seconds=2)).isoformat(timespec="seconds"), + when.isoformat(timespec="seconds"), + "s-popular", + ) + ) + conn.executemany( + "INSERT INTO task_executions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + task_exec_rows, + ) + conn.commit() + + workflow_db = tmp_path / "workflow.db" + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_stats ( + workflow_id TEXT PRIMARY KEY, + call_count INTEGER NOT NULL, + success_count INTEGER NOT NULL, + error_count INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE workflow_executions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + updated_at INTEGER + ) + """ + ) + conn.executemany( + "INSERT INTO workflow_stats VALUES (?, ?, ?, ?, ?)", + [ + ("wf-active", 1, 0, 0, older_ms), + ("wf-recent", 1, 1, 0, recent_ms), + ("wf-popular", 6, 6, 0, older_ms), + ], + ) + conn.executemany( + "INSERT INTO workflow_executions VALUES (?, ?, ?, ?, ?, ?)", + [ + ("run-active", "wf-active", "running", older_ms, None, older_ms), + ("run-recent", "wf-recent", "success", recent_ms, recent_ms + 1000, recent_ms + 1000), + ("run-popular", "wf-popular", "success", older_ms, older_ms + 1000, older_ms + 1000), + ], + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.TASK_DB = tasks_db + handlers.WORKFLOW_DB = workflow_db + + payload = handlers._get_task_center() + + assert [task["id"] for task in payload["scheduledTasks"][:4]] == [ + "sched-active", + "sched-recent", + "sched-popular", + "sched-future", + ] + assert [workflow["id"] for workflow in payload["workflows"][:3]] == [ + "wf-active", + "wf-recent", + "wf-popular", + ] + + def test_soc_dashboard_workflow_progress_marks_unavailable_database(tmp_path: Path): handlers = _load_dashboard_handlers() handlers.WORKFLOW_DB = tmp_path / "missing-workflow.db" diff --git a/tests/provider/test_test_credentials.py b/tests/provider/test_test_credentials.py index 783692ded..e1687bf96 100644 --- a/tests/provider/test_test_credentials.py +++ b/tests/provider/test_test_credentials.py @@ -303,23 +303,23 @@ async def test_onesec_service_prefers_threat_probe_and_uses_enum_action(self): @pytest.mark.asyncio async def test_service_prefers_login_probe_over_action_dispatch_tool(self): - """When a parameter-free login tool exists (e.g. qingteng_login), it - should be tried before action-dispatch tools whose handler-side validation - requires extra fields beyond the JSON schema (e.g. qingteng_assets which - needs `resource` + `os_type` for `assets.refresh`). + """When a parameter-free login tool exists, it + should be tried before an action-dispatch tool whose handler-side validation + requires extra fields beyond the JSON schema (e.g. a grouped assets tool + that needs `resource` + `os_type` for `assets.refresh`). """ from flocks.server.routes.provider import test_provider_credentials login_tool = ToolInfo( - name="qingteng_login", - description="Qingteng login probe", + name="example_login", + description="Example login probe", category=ToolCategory.CUSTOM, parameters=[], requires_confirmation=False, ) assets_tool = ToolInfo( - name="qingteng_assets", - description="Qingteng assets dispatch", + name="example_assets", + description="Example assets dispatch", category=ToolCategory.CUSTOM, parameters=[ ToolParameter( @@ -340,27 +340,28 @@ async def test_service_prefers_login_probe_over_action_dispatch_tool(self): patch(_PATCH_SECRET_MGR, return_value=mock_secrets), patch(_PATCH_PROVIDER) as mock_provider_cls, patch(_PATCH_TOOL_REGISTRY) as mock_tr, - patch(_PATCH_TOOL_SOURCE, return_value=("api", "qingteng")), + patch(_PATCH_TOOL_SOURCE, return_value=("api", "example")), ): mock_provider_cls._ensure_initialized = MagicMock() mock_provider_cls.apply_config = AsyncMock() mock_provider_cls.get.return_value = None mock_tr.init = MagicMock() + mock_tr.init_async = AsyncMock() mock_tr.list_tools.return_value = [assets_tool, login_tool] mock_tr._dynamic_tools_by_module = { - "flocks.tool.generated.qingteng": ["qingteng_assets", "qingteng_login"], + "flocks.tool.generated.example": ["example_assets", "example_login"], } mock_tr.execute = AsyncMock(return_value=ToolResult( success=True, - output={"jwt": "fake", "signKey": "fake", "comId": "1"}, + output={"authenticated": True}, )) - result = await test_provider_credentials("qingteng") + result = await test_provider_credentials("example") assert result["success"] is True, result - assert result["tool_tested"] == "qingteng_login" - mock_tr.execute.assert_awaited_once_with(tool_name="qingteng_login") + assert result["tool_tested"] == "example_login" + mock_tr.execute.assert_awaited_once_with(tool_name="example_login") @pytest.mark.asyncio async def test_declared_manifest_probe_is_used_before_heuristic_tool_selection(self): diff --git a/tests/tool/test_probe_loader.py b/tests/tool/test_probe_loader.py index edb96950c..0c561f699 100644 --- a/tests/tool/test_probe_loader.py +++ b/tests/tool/test_probe_loader.py @@ -240,6 +240,13 @@ def test_onesec_manifest_uses_threat_connectivity_probe(self): assert spec.tool == "onesec_threat" assert spec.params == {"action": "threat_query_bd_version"} + def test_qingteng_manifest_uses_system_audit_connectivity_probe(self): + spec = get_connectivity_spec("qingteng_v3_4_1_66") + + assert spec is not None + assert spec.tool == "qingteng_system_audit" + assert spec.params == {} + class TestFixtureParsing: def setup_method(self): diff --git a/tests/tool/test_qingteng_api_tool.py b/tests/tool/test_qingteng_api_tool.py index 138617086..a32f5f441 100644 --- a/tests/tool/test_qingteng_api_tool.py +++ b/tests/tool/test_qingteng_api_tool.py @@ -19,7 +19,7 @@ def _load_tool(yaml_name: str): / ".flocks" / "plugins" / "tools" - / "api" + / "device" / _QINGTENG_PLUGIN_DIR / yaml_name ) @@ -33,7 +33,7 @@ def _load_handler_module(script_name: str, module_name: str): / ".flocks" / "plugins" / "tools" - / "api" + / "device" / _QINGTENG_PLUGIN_DIR / script_name ) @@ -743,6 +743,7 @@ async def test_qingteng_system_audit_uses_shared_handler_logic(): ] module.get_secret_manager = lambda: mock_secret_manager + module.ConfigWriter.get_api_service_raw = lambda service_id: {} module.httplib.HTTPConnection = _FakeHTTPConnection module.time.time = lambda: 1700000000 @@ -759,17 +760,7 @@ async def test_qingteng_system_audit_uses_shared_handler_logic(): assert result.success is True assert result.metadata["api"] == "system.audit" assert result.output["total"] == 1 - - -@pytest.mark.asyncio -async def test_qingteng_login_returns_clear_error_when_configuration_missing(): - module = _load_handler_module("qingteng.handler.py", "qingteng_login_handler_test") - mock_secret_manager = MagicMock() - mock_secret_manager.get.return_value = None - module.get_secret_manager = lambda: mock_secret_manager - module.ConfigWriter.get_api_service_raw = lambda service_id: {} - - result = await module.login(ToolContext(session_id="test", message_id="test")) - - assert result.success is False - assert result.error == "Missing configuration: qingteng base_url/qingteng_host, qingteng_username, qingteng_password" + assert len(_FakeHTTPConnection.created) == 2 + login_call = _FakeHTTPConnection.created[0].calls[0] + assert login_call["method"] == "POST" + assert login_call["url"] == "/v1/api/auth" diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index 20788ca0f..511ed75d4 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -453,6 +453,40 @@ describe('SessionChat message loading state', () => { }); }); +describe('SessionChat focused message deep links', () => { + it('scrolls to and consumes a rendered focus target', async () => { + const onFocusMessageConsumed = vi.fn(); + useSessionMessagesMock.mockReturnValue({ + messages: [ + makeMessage({ + id: 'target-message', + parts: [{ id: 'part-1', type: 'text', text: 'target' }], + }), + ], + loading: false, + error: null, + refetch: vi.fn(), + addMessage: vi.fn(), + updateMessage: vi.fn(), + updateMessagePart: vi.fn(), + removeMessage: vi.fn(), + clearMessages: vi.fn(), + replaceMessageText: vi.fn(), + markMessageStopped: vi.fn(), + truncateAfterMessage: vi.fn(), + }); + + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + focusMessageId: 'target-message', + onFocusMessageConsumed, + })); + + await waitFor(() => expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled()); + expect(onFocusMessageConsumed).toHaveBeenCalledTimes(1); + }); +}); + describe('ChatToolPart file operation titles', () => { it('shows only the filename in the header while preserving the full path in details', () => { const fullPath = '/Users/example/.flocks/workspace/outputs/2026-07-24/gold_price_retrieval_plan.md'; diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index 693b04cca..6a8045dc8 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -161,6 +161,10 @@ export interface SessionChatProps { initialDisplayText?: string | null; /** Called immediately after initialMessage has been consumed (sent) */ onInitialMessageConsumed?: () => void; + /** Scroll to this existing message after messages load. */ + focusMessageId?: string | null; + /** Called after focusMessageId is consumed. */ + onFocusMessageConsumed?: () => void; /** Agent name to include in prompt_async requests */ agentName?: string; /** Model override to include in prompt_async requests */ @@ -1529,6 +1533,8 @@ export default function SessionChat({ onCreateAndSend, onCreateNewSession, onInitialMessageConsumed, + focusMessageId, + onFocusMessageConsumed, supportsVision, toolbarSlot, composerAddMenuSlot, @@ -1695,6 +1701,7 @@ export default function SessionChat({ const messagesEndRef = useRef(null); const messagesContentRef = useRef(null); const scrollContainerRef = useRef(null); + const focusedMessageRef = useRef(''); const isAtBottomRef = useRef(true); const scrollToBottomRafRef = useRef(null); const textareaRef = useRef(null); @@ -1823,6 +1830,28 @@ export default function SessionChat({ truncateAfterMessage, } = useSessionMessages(sessionId || undefined); + + useEffect(() => { + const targetId = String(focusMessageId || '').trim(); + if (!targetId || focusedMessageRef.current === targetId) return; + if (loading) return; + const target = messagesContentRef.current?.querySelector( + `[data-message-id="${CSS.escape(targetId)}"]`, + ); + if (!target) { + focusedMessageRef.current = targetId; + onFocusMessageConsumed?.(); + return; + } + focusedMessageRef.current = targetId; + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + target.classList.add('ring-2', 'ring-sky-400', 'ring-offset-2', 'ring-offset-white', 'dark:ring-offset-zinc-950'); + window.setTimeout(() => { + target.classList.remove('ring-2', 'ring-sky-400', 'ring-offset-2', 'ring-offset-white', 'dark:ring-offset-zinc-950'); + }, 1800); + onFocusMessageConsumed?.(); + }, [focusMessageId, loading, messages.length, onFocusMessageConsumed]); + const contextUsageMessages = contextUsageRefreshing && !contextUsageSnapshot ? [] : messages; const contextUsageBreakdown = useMemo( () => buildContextUsageBreakdown(contextUsageMessages, input, contextUsageSnapshot), @@ -4198,33 +4227,34 @@ function ChatMessageTimelineInner({ return ( <> {items.map(({ message, isActive }) => ( - +
+ +
))} ); diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index 8428a991b..4b35498b7 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import Layout from './Layout'; @@ -20,6 +20,7 @@ const { getNotificationAckStatus, flocksproUsersApi, consoleUpgradeApi, + productNameContextValue, updateModalMock, useAuth, useStats, @@ -55,6 +56,18 @@ const { consoleUpgradeApi: { getProPackageStatus: vi.fn(), }, + productNameContextValue: { + productName: 'Flocks', + proProductName: 'Flocks Pro', + configuredDisplayName: null as string | null, + faviconUrl: '/favicon.svg', + hasCustomFavicon: false, + loading: false, + refreshProductName: vi.fn(), + updateProductName: vi.fn(), + uploadProductFavicon: vi.fn(), + resetProductFavicon: vi.fn(), + }, updateModalMock: vi.fn(() => null), useAuth: vi.fn(), useStats: vi.fn(), @@ -118,6 +131,10 @@ vi.mock('@/contexts/AuthContext', () => ({ useAuth, })); +vi.mock('@/contexts/ProductNameContext', () => ({ + useProductName: () => productNameContextValue, +})); + vi.mock('@/hooks/useStats', () => ({ useStats, })); @@ -242,6 +259,9 @@ describe('Layout onboarding entry', () => { vi.clearAllMocks(); vi.useRealTimers(); localStorage.clear(); + productNameContextValue.productName = 'Flocks'; + productNameContextValue.proProductName = 'Flocks Pro'; + productNameContextValue.configuredDisplayName = null; checkUpdate.mockResolvedValue({ has_update: false, @@ -590,6 +610,61 @@ describe('Layout onboarding entry', () => { expect(screen.queryByText('newVersion')).not.toBeInTheDocument(); }); + it('adapts the sidebar for a custom long workbench name and hides the product update mark', async () => { + const customName = '超长的威胁研判自动化工作台名称用于验证左侧菜单栏不会溢出'; + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + productNameContextValue.productName = customName; + productNameContextValue.proProductName = customName; + productNameContextValue.configuredDisplayName = customName; + checkUpdate.mockResolvedValue({ + has_update: true, + latest_version: '2026.04.29', + current_version: '2026.04.28', + release_notes: 'Release line', + release_url: 'https://example.com/release', + error: null, + }); + + const { container } = renderHomeWithLayout(); + + const aside = container.querySelector('aside') as HTMLElement | null; + expect(aside).not.toBeNull(); + await waitFor(() => { + expect(Number.parseInt(aside!.style.width, 10)).toBeGreaterThan(208); + }); + + const sidebarShell = container.querySelector('aside > div'); + const logoRow = sidebarShell?.firstElementChild as HTMLElement | null; + expect(logoRow).not.toBeNull(); + const productLabel = within(logoRow!).getByText(customName); + expect(productLabel.className).toContain('[overflow-wrap:anywhere]'); + expect(screen.queryByText('newVersion')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'hasNewVersion v2026.04.29' })).not.toBeInTheDocument(); + }); + + it('resizes and persists the expanded desktop sidebar width', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + + const { container } = renderHomeWithLayout(); + + const aside = container.querySelector('aside') as HTMLElement | null; + expect(aside).not.toBeNull(); + const resizeHandle = await screen.findByRole('separator', { name: 'resizeNav' }); + + fireEvent.pointerDown(resizeHandle, { pointerId: 1, clientX: 208 }); + await act(async () => { + const pointerMove = new Event('pointermove') as PointerEvent; + Object.defineProperty(pointerMove, 'clientX', { value: 320 }); + window.dispatchEvent(pointerMove); + }); + await act(async () => { + window.dispatchEvent(new Event('pointerup')); + }); + + expect(aside).toHaveStyle({ width: '320px' }); + expect(localStorage.getItem('flocks_layout_sidebar_width')).toBe('320'); + }); + it('opens the account menu with settings and logout actions', async () => { const user = userEvent.setup(); const logout = vi.fn().mockResolvedValue(undefined); @@ -1267,4 +1342,78 @@ describe('Layout WebUI contract pages navigation', () => { `/sessions?session=session-soc-custom-page&message=${encodeURIComponent('workspace.socCustomPageInitialMessage')}&display=${encodeURIComponent('workspace.socCustomPageDisplayLabel')}`, ); }); + + it('customizes the SOC dashboard title from the SOC workspace menu', async () => { + const user = userEvent.setup(); + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + const titleChanged = vi.fn(); + window.addEventListener('soc-dashboard:title-changed', titleChanged); + + const socPages = [ + { + id: 'soc-dashboard', + title: '告警态势', + route: '/contracts/webui/soc-dashboard', + icon: 'Activity', + order: 10, + enabled: true, + placement: 'home.after', + buildHash: 'ready', + buildStatus: 'ready' as const, + workspaceId: 'soc_ui', + workspaceTitle: 'SOC 工作区', + workspaceRoute: '/contracts/webui/workspaces/soc_ui', + }, + ]; + useWebUIContractPages.mockReturnValue({ + pages: socPages, + workspaces: [ + { + id: 'soc_ui', + title: 'SOC 工作区', + route: '/contracts/webui/workspaces/soc_ui', + icon: 'ShieldCheck', + order: 10, + enabled: true, + placement: 'sceneWorkspace', + defaultPageId: 'soc-dashboard', + sections: [ + { + id: 'posture', + label: '态势', + pageIds: ['soc-dashboard'], + defaultPageId: 'soc-dashboard', + contentPadding: 'none', + themeOverride: 'dark', + }, + ], + pages: socPages, + }, + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + + try { + renderHomeWithLayout(); + + await user.click(await screen.findByRole('link', { name: 'SOC 工作区' })); + const workspaceMenu = screen.getByRole('navigation', { name: 'workspace.sectionNavigation' }); + await user.click(within(workspaceMenu).getByRole('button', { name: 'workspace.customTitle' })); + + const input = screen.getByLabelText('workspace.customTitle'); + await user.clear(input); + await user.type(input, '自定义 SOC 态势中心'); + await user.click(screen.getByRole('button', { name: 'workspace.customTitleSave' })); + + expect(localStorage.getItem('soc-dashboard-custom-title-v1')).toBe('自定义 SOC 态势中心'); + expect(titleChanged).toHaveBeenCalledTimes(1); + expect(titleChanged.mock.calls[0][0]).toMatchObject({ + detail: { title: '自定义 SOC 态势中心' }, + }); + } finally { + window.removeEventListener('soc-dashboard:title-changed', titleChanged); + } + }); }); diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index f361c9a41..5df046b9e 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -26,7 +26,7 @@ import { type LucideIcon, } from 'lucide-react'; import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; -import type { ComponentType } from 'react'; +import type { ComponentType, CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; // Modals are only rendered after the user clicks/triggers them; pulling them // into the eager Layout chunk costs ~1.7k LOC + i18n keys + lucide icons that @@ -35,6 +35,13 @@ import { useTranslation } from 'react-i18next'; // would force Rollup to bundle the whole module eagerly). const ONBOARDING_DISMISSED_KEY = 'flocks_onboarding_dismissed'; const COLLAPSED_NAV_SECTIONS_KEY = 'flocks_layout_collapsed_nav_sections'; +const SIDEBAR_WIDTH_KEY = 'flocks_layout_sidebar_width'; +const SIDEBAR_DEFAULT_WIDTH = 208; +const SIDEBAR_MIN_WIDTH = 176; +const SIDEBAR_MAX_WIDTH = 520; +const SOC_DASHBOARD_TITLE_KEY = 'soc-dashboard-custom-title-v1'; +const SOC_DASHBOARD_TITLE_CHANGED_EVENT = 'soc-dashboard:title-changed'; +const SOC_DASHBOARD_TITLE_MAX_LENGTH = 64; type LazyLayoutModule = { default: ComponentType }; @@ -78,6 +85,65 @@ function saveCollapsedNavSectionIds(sectionIds: Set): void { } } +function clampSidebarWidth(width: number): number { + if (!Number.isFinite(width)) return SIDEBAR_DEFAULT_WIDTH; + return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width))); +} + +function estimateSidebarWidthForName(name?: string | null): number { + const normalized = (name || '').trim(); + if (!normalized) return SIDEBAR_DEFAULT_WIDTH; + + const weightedLength = Array.from(normalized).reduce((total, char) => { + return total + (char.charCodeAt(0) > 255 ? 1 : 0.58); + }, 0); + + return clampSidebarWidth(weightedLength * 18 + 112); +} + +function readSidebarWidth(): number { + try { + const rawValue = localStorage.getItem(SIDEBAR_WIDTH_KEY); + if (!rawValue) return SIDEBAR_DEFAULT_WIDTH; + const parsedValue = Number.parseInt(rawValue, 10); + return Number.isFinite(parsedValue) ? clampSidebarWidth(parsedValue) : SIDEBAR_DEFAULT_WIDTH; + } catch { + return SIDEBAR_DEFAULT_WIDTH; + } +} + +function saveSidebarWidth(width: number): void { + try { + localStorage.setItem(SIDEBAR_WIDTH_KEY, String(clampSidebarWidth(width))); + } catch { + // Local storage can be unavailable in restricted browser contexts. + } +} + +function readSocDashboardTitle(): string { + try { + return localStorage.getItem(SOC_DASHBOARD_TITLE_KEY)?.trim() || ''; + } catch { + return ''; + } +} + +function saveSocDashboardTitle(title: string): void { + const normalizedTitle = title.trim(); + try { + if (normalizedTitle) { + localStorage.setItem(SOC_DASHBOARD_TITLE_KEY, normalizedTitle); + } else { + localStorage.removeItem(SOC_DASHBOARD_TITLE_KEY); + } + } catch { + // Local storage can be unavailable in restricted browser contexts. + } + window.dispatchEvent(new CustomEvent(SOC_DASHBOARD_TITLE_CHANGED_EVENT, { + detail: { title: normalizedTitle || null }, + })); +} + const OnboardingModal = lazyLayoutComponent(() => import('@/components/common/OnboardingModal')); const UpdateModal = lazyLayoutComponent(() => import('@/components/common/UpdateModal'), ['update']); const NotificationModal = lazyLayoutComponent(() => import('@/components/common/NotificationModal'), ['notification']); @@ -189,6 +255,8 @@ export default function Layout() { const { user, logout } = useAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); const [collapsed, setCollapsed] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(readSidebarWidth); + const [resizingSidebar, setResizingSidebar] = useState(false); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const accountMenuRef = useRef(null); const isHome = location.pathname === '/'; @@ -198,7 +266,7 @@ export default function Layout() { const { t: tWebUIContractPage } = useTranslation('webuiContractPage'); const { t: tAuth } = useTranslation('auth'); const toast = useToast(); - const { productName, proProductName } = useProductName(); + const { productName, proProductName, configuredDisplayName } = useProductName(); const [hasUpdate, setHasUpdate] = useState(false); const [latestVersion, setLatestVersion] = useState(null); const [currentVersion, setCurrentVersion] = useState(null); @@ -225,7 +293,14 @@ export default function Layout() { const [collapsedNavSectionIds, setCollapsedNavSectionIds] = useState>(readCollapsedNavSectionIds); const [collapsedWorkspaceSectionIds, setCollapsedWorkspaceSectionIds] = useState>(() => new Set()); const [creatingWorkspaceCustomPageSession, setCreatingWorkspaceCustomPageSession] = useState(false); + const [socTitleDialogOpen, setSocTitleDialogOpen] = useState(false); + const [socTitleDraft, setSocTitleDraft] = useState(readSocDashboardTitle); const workspaceMenuCloseTimerRef = useRef(null); + const hasCustomDisplayName = Boolean(configuredDisplayName?.trim()); + const expandedSidebarWidth = sidebarWidth; + const sidebarOffsetStyle = { + '--layout-sidebar-width': `${expandedSidebarWidth}px`, + } as CSSProperties; useEffect(() => { if (!accountMenuOpen) return undefined; @@ -239,6 +314,72 @@ export default function Layout() { return () => document.removeEventListener('pointerdown', handlePointerDown); }, [accountMenuOpen]); + useEffect(() => { + if (!hasCustomDisplayName) return; + const estimatedWidth = estimateSidebarWidthForName(configuredDisplayName); + setSidebarWidth((currentWidth) => { + const nextWidth = Math.max(currentWidth, estimatedWidth); + if (nextWidth !== currentWidth) { + saveSidebarWidth(nextWidth); + } + return nextWidth; + }); + }, [configuredDisplayName, hasCustomDisplayName]); + + const updateSidebarWidth = useCallback((width: number) => { + const nextWidth = clampSidebarWidth(width); + setSidebarWidth(nextWidth); + saveSidebarWidth(nextWidth); + }, []); + + const handleSidebarResizePointerDown = useCallback((event: ReactPointerEvent) => { + if (collapsed) return; + event.preventDefault(); + setResizingSidebar(true); + + const target = event.currentTarget; + const pointerId = event.pointerId; + try { + target.setPointerCapture?.(pointerId); + } catch { + // Pointer capture is best-effort; dragging still works through window listeners. + } + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const handlePointerMove = (moveEvent: PointerEvent) => { + updateSidebarWidth(moveEvent.clientX); + }; + + const stopResize = () => { + try { + target.releasePointerCapture?.(pointerId); + } catch { + // The pointer may already be released by the browser. + } + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + setResizingSidebar(false); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('pointercancel', stopResize); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('pointercancel', stopResize); + }, [collapsed, updateSidebarWidth]); + + const handleSidebarResizeKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (collapsed) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + updateSidebarWidth(sidebarWidth + (event.key === 'ArrowRight' ? 16 : -16)); + }, [collapsed, sidebarWidth, updateSidebarWidth]); + // useLayoutEffect runs synchronously before paint, so there's no flash on initial load. // It also re-runs when the user navigates back to /, covering both cases in one place. useLayoutEffect(() => { @@ -584,8 +725,9 @@ export default function Layout() { const accountInitial = (user?.username || productName || 'F').trim().charAt(0).toUpperCase(); const accountRoleLabel = user?.role === 'admin' ? tAuth('admin.roleAdmin') : tAuth('admin.roleMember'); const hasVisibleUpdate = hasUpdate && canManageUpdates; + const hasVisibleProductUpdate = hasVisibleUpdate && !hasCustomDisplayName; const showFlocksproUpgradeEntry = canManageUpdates; - const productUpdateTitle = hasVisibleUpdate + const productUpdateTitle = hasVisibleProductUpdate ? t('hasNewVersion', { version: formatUpdateVersion(latestVersion) || '' }) : productName; const settingsReturnState = { @@ -610,6 +752,7 @@ export default function Layout() { ? getLocalizedWebUIContractTitle(activeWorkspaceMenu, i18n.language) : ''; const showWorkspaceCustomPageAction = canCreateWorkspaceCustomPage && isSocWorkspace(activeWorkspaceMenu); + const showSocDashboardTitleAction = isSocWorkspace(activeWorkspaceMenu); const cancelWorkspaceMenuClose = useCallback(() => { if (workspaceMenuCloseTimerRef.current === null) return; @@ -703,6 +846,33 @@ export default function Layout() { toast, ]); + const openSocTitleDialog = useCallback(() => { + setSocTitleDraft(readSocDashboardTitle()); + setSocTitleDialogOpen(true); + setOpenWorkspaceMenuId(null); + }, []); + + const closeSocTitleDialog = useCallback(() => { + setSocTitleDialogOpen(false); + }, []); + + const handleSaveSocTitle = useCallback(() => { + saveSocDashboardTitle(socTitleDraft); + setSocTitleDialogOpen(false); + if (location.pathname === '/contracts/webui/workspaces/soc_ui/soc-dashboard') { + window.setTimeout(() => window.location.reload(), 0); + } + }, [location.pathname, socTitleDraft]); + + const handleResetSocTitle = useCallback(() => { + setSocTitleDraft(''); + saveSocDashboardTitle(''); + setSocTitleDialogOpen(false); + if (location.pathname === '/contracts/webui/workspaces/soc_ui/soc-dashboard') { + window.setTimeout(() => window.location.reload(), 0); + } + }, [location.pathname]); + const openManualUpdateCheck = useCallback(() => { setAccountMenuOpen(false); setUpdateInfo(null); @@ -742,6 +912,64 @@ export default function Layout() { + {socTitleDialogOpen && ( +
{ + if (event.target === event.currentTarget) { + closeSocTitleDialog(); + } + }} + > +
{ + event.preventDefault(); + handleSaveSocTitle(); + }} + > +

+ {tWebUIContractPage('workspace.customTitleDialogTitle')} +

+ +
+ +
+ + +
+
+
+
+ )} + {sidebarOpen && (
{/* Logo */} -
+
{collapsed ? (
- {hasVisibleUpdate && ( + {hasVisibleProductUpdate && (
+ {!collapsed && ( +
+ )} + {/* Collapse tab (desktop) */}
) : null} + + {showSocDashboardTitleAction ? ( +
+ +
+ ) : null}
)} @@ -1154,7 +1417,8 @@ export default function Layout() { {/* Main content area */}
{isFullScreenPage ? ( diff --git a/webui/src/locales/en-US/nav.json b/webui/src/locales/en-US/nav.json index 8c42318b3..e5fe0542f 100644 --- a/webui/src/locales/en-US/nav.json +++ b/webui/src/locales/en-US/nav.json @@ -63,6 +63,7 @@ "logout": "Log out", "expandNav": "Expand navigation", "collapseNav": "Collapse navigation", + "resizeNav": "Resize navigation", "switchLanguage": "Switch Language", "switchToDarkTheme": "Switch to dark mode", "switchToLightTheme": "Switch to light mode", diff --git a/webui/src/locales/en-US/webuiContractPage.json b/webui/src/locales/en-US/webuiContractPage.json index 1a8bedffb..b927794b5 100644 --- a/webui/src/locales/en-US/webuiContractPage.json +++ b/webui/src/locales/en-US/webuiContractPage.json @@ -28,6 +28,12 @@ "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar", "customPage": "Custom Page", + "customTitle": "Custom Title", + "customTitleDialogTitle": "Customize posture page title", + "customTitlePlaceholder": "Flocks AI Alert Posture Center", + "customTitleReset": "Reset", + "customTitleCancel": "Cancel", + "customTitleSave": "Save", "customPageSessionTitle": "{{workspace}} Custom Page", "customPageCreateError": "Failed to create a custom page session. Please try again later.", "socCustomPageDisplayLabel": "Create SOC Custom Page", diff --git a/webui/src/locales/zh-CN/nav.json b/webui/src/locales/zh-CN/nav.json index 59590281e..dcf0c076d 100644 --- a/webui/src/locales/zh-CN/nav.json +++ b/webui/src/locales/zh-CN/nav.json @@ -63,6 +63,7 @@ "logout": "退出登录", "expandNav": "展开导航", "collapseNav": "收起导航", + "resizeNav": "调整导航宽度", "switchLanguage": "切换语言", "switchToDarkTheme": "切换到深色模式", "switchToLightTheme": "切换到浅色模式", diff --git a/webui/src/locales/zh-CN/webuiContractPage.json b/webui/src/locales/zh-CN/webuiContractPage.json index fdebbc304..6be57f46c 100644 --- a/webui/src/locales/zh-CN/webuiContractPage.json +++ b/webui/src/locales/zh-CN/webuiContractPage.json @@ -28,6 +28,12 @@ "collapseSidebar": "折叠侧边栏", "expandSidebar": "展开侧边栏", "customPage": "自定义页面", + "customTitle": "自定义标题", + "customTitleDialogTitle": "自定义态势页标题", + "customTitlePlaceholder": "Flocks AI 智能告警态势中心", + "customTitleReset": "恢复默认", + "customTitleCancel": "取消", + "customTitleSave": "保存", "customPageSessionTitle": "{{workspace}} 自定义页面", "customPageCreateError": "无法创建自定义页面会话,请稍后重试", "socCustomPageDisplayLabel": "创建 SOC 自定义页面", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 5db9684c1..41de74130 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -155,6 +155,7 @@ vi.mock('@/components/common/SessionChat', () => ({ welcomeContent, initialMessage, initialDisplayText, + focusMessageId, onCreateAndSend, onSSEEvent, agentName, @@ -180,6 +181,7 @@ vi.mock('@/components/common/SessionChat', () => ({ welcomeContent?: React.ReactNode | ((setInput: (text: string) => void) => React.ReactNode); initialMessage?: string | null; initialDisplayText?: string | null; + focusMessageId?: string | null; model?: { providerID: string; modelID: string } | null; executionMode?: 'build' | 'plan' | 'goal'; onExecutionModeAccepted?: (mode: 'build' | 'plan' | 'goal') => void; @@ -220,6 +222,7 @@ vi.mock('@/components/common/SessionChat', () => ({ data-hide-input={String(Boolean(hideInput))} data-initial-message={initialMessage ?? ''} data-initial-display={initialDisplayText ?? ''} + data-focus-message={focusMessageId ?? ''} > {sessionId ?? 'no-session'}