Merge remote-tracking branch 'origin/develop' into wangzuwang_branch

wangzuwang_branch
Wzw 4 months ago
commit 98923e96ce

@ -0,0 +1,49 @@
name: frontend-vue-ci
on:
push:
paths:
- "frontend-vue/**"
- ".github/workflows/frontend-vue-ci.yml"
pull_request:
paths:
- "frontend-vue/**"
- ".github/workflows/frontend-vue-ci.yml"
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend-vue
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: frontend-vue/pnpm-lock.yaml
- name: Enable corepack
run: corepack enable
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck
- name: Test
run: pnpm run test
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps chromium
- name: E2E
run: pnpm run e2e:ui
- name: Upload Playwright Report
if: failure()
uses: actions/upload-artifact@v4
with:
name: frontend-vue-playwright-report
path: |
frontend-vue/playwright-report
frontend-vue/test-results
- name: Build
run: pnpm run build

2
.gitignore vendored

@ -3,6 +3,8 @@ src/fronted/vue3/
frontend-vue/node_modules/
frontend-vue/dist/
frontend-vue/.vite/
frontend-vue/test-results/
frontend-vue/playwright-report/
.venv/
__pycache__/
*.pyc

@ -3,7 +3,8 @@ from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from ..services.llm import LLMClient
from ..services.ops_tools import openai_tools_schema, tool_read_log, tool_start_cluster, tool_stop_cluster
from ..services.ops_tools import openai_tools_schema, tool_read_log, tool_start_cluster, tool_stop_cluster, tool_read_cluster_log, tool_detect_cluster_faults, tool_run_cluster_command
import json
async def run_diagnose_and_repair(db: AsyncSession, operator: str, context: Dict[str, Any], auto: bool = True, max_steps: int = 3, model: Optional[str] = None) -> Dict[str, Any]:
@ -44,10 +45,48 @@ async def run_diagnose_and_repair(db: AsyncSession, operator: str, context: Dict
for tc in tool_calls:
fn = (tc.get("function") or {})
name = fn.get("name")
args = fn.get("arguments") or {}
raw_args = fn.get("arguments") or {}
if isinstance(raw_args, str):
try:
args = json.loads(raw_args)
except Exception:
args = {}
elif isinstance(raw_args, dict):
args = raw_args
else:
args = {}
result: Dict[str, Any]
if name == "read_log":
result = await tool_read_log(db, operator, args.get("node"), args.get("path"), int(args.get("lines", 200)), args.get("pattern"), args.get("sshUser"))
elif name == "read_cluster_log":
result = await tool_read_cluster_log(
db=db,
user_name=operator,
cluster_uuid=args.get("cluster_uuid"),
log_type=args.get("log_type"),
node_hostname=args.get("node_hostname"),
lines=int(args.get("lines", 100)),
)
elif name == "detect_cluster_faults":
result = await tool_detect_cluster_faults(
db=db,
user_name=operator,
cluster_uuid=args.get("cluster_uuid"),
components=args.get("components"),
node_hostname=args.get("node_hostname"),
lines=int(args.get("lines", 200)),
)
elif name == "run_cluster_command":
result = await tool_run_cluster_command(
db=db,
user_name=operator,
cluster_uuid=args.get("cluster_uuid"),
command_key=args.get("command_key"),
target=args.get("target"),
node_hostname=args.get("node_hostname"),
timeout=int(args.get("timeout", 30)),
limit_nodes=int(args.get("limit_nodes", 20)),
)
elif name == "start_cluster":
result = await tool_start_cluster(db, operator, args.get("cluster_uuid"))
elif name == "stop_cluster":
@ -57,4 +96,3 @@ async def run_diagnose_and_repair(db: AsyncSession, operator: str, context: Dict
actions.append({"name": name, "args": args, "result": result})
messages.append({"role": "tool", "content": str(result), "name": name})
return {"rootCause": root_cause, "actions": actions, "residualRisk": residual_risk}

@ -2,9 +2,18 @@ import os
import json
from dotenv import load_dotenv
from typing import Dict, Tuple
from datetime import datetime
from zoneinfo import ZoneInfo
load_dotenv()
# Timezone Configuration
APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
BJ_TZ = ZoneInfo(APP_TIMEZONE)
def now_bj() -> datetime:
return datetime.now(BJ_TZ)
# Database Configuration
_db_url = os.getenv("DATABASE_URL")
if not _db_url:
@ -19,6 +28,7 @@ if not _db_url:
_db_url = "postgresql+asyncpg://postgres:password@localhost:5432/hadoop_fault_db"
DATABASE_URL = _db_url
SYNC_DATABASE_URL = _db_url.replace("postgresql+asyncpg://", "postgresql://")
# JWT Configuration
JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")

@ -1,7 +1,12 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from .config import DATABASE_URL
from .config import DATABASE_URL, APP_TIMEZONE
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_pre_ping=True,
connect_args={"server_settings": {"timezone": APP_TIMEZONE}},
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_db() -> AsyncSession:

@ -3,13 +3,14 @@ import time
import uuid
import datetime
from typing import Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker, AsyncEngine
from .log_reader import log_reader
from .ssh_utils import ssh_manager
from .db import SessionLocal
from .models.hadoop_logs import HadoopLog
from sqlalchemy import text
import asyncio
from .config import BJ_TZ, DATABASE_URL, APP_TIMEZONE
class LogCollector:
"""Real-time log collector for Hadoop cluster"""
@ -19,15 +20,19 @@ class LogCollector:
self.is_running: bool = False
self.collection_interval: int = 5 # 默认采集间隔,单位:秒
self._loops: Dict[str, asyncio.AbstractEventLoop] = {}
self._engines: Dict[str, AsyncEngine] = {}
self._session_locals: Dict[str, async_sessionmaker[AsyncSession]] = {}
self._intervals: Dict[str, int] = {}
self._cluster_name_cache: Dict[str, str] = {}
self._targets: Dict[str, str] = {}
self._line_counts: Dict[str, int] = {}
self.max_bytes_per_pull: int = 256 * 1024
def start_collection(self, node_name: str, log_type: str, ip: Optional[str] = None, interval: Optional[int] = None) -> bool:
"""Start real-time log collection for a specific node and log type"""
if interval:
self.collection_interval = interval
collector_id = f"{node_name}_{log_type}"
if interval is not None:
self._intervals[collector_id] = max(1, int(interval))
if collector_id in self.collectors and self.collectors[collector_id].is_alive():
print(f"Collector {collector_id} is already running")
@ -56,6 +61,7 @@ class LogCollector:
# Threads are daemon, so they will exit when main process exits
# We just remove it from our tracking
del self.collectors[collector_id]
self._intervals.pop(collector_id, None)
print(f"Stopped collector {collector_id}")
else:
print(f"Collector {collector_id} is not running")
@ -80,10 +86,10 @@ class LogCollector:
if timestamp_end > 0:
timestamp_str = line[1:timestamp_end]
try:
timestamp = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S,%f").replace(tzinfo=datetime.timezone.utc)
timestamp = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S,%f").replace(tzinfo=BJ_TZ)
except ValueError:
# If parsing fails, use current time
timestamp = datetime.datetime.now(datetime.timezone.utc)
timestamp = datetime.datetime.now(BJ_TZ)
# Extract log level
log_levels = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]
@ -93,7 +99,7 @@ class LogCollector:
break
return {
"timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc),
"timestamp": timestamp or datetime.datetime.now(BJ_TZ),
"log_level": log_level,
"message": message,
"host": node_name,
@ -101,19 +107,24 @@ class LogCollector:
"raw_log": line
}
async def _save_log_to_db(self, log_data: Dict):
async def _save_log_to_db(self, log_data: Dict, collector_id: str | None = None):
"""Save log data to database"""
try:
async with SessionLocal() as session:
session_local = self._session_locals.get(collector_id) if collector_id else None
async with (session_local() if session_local else SessionLocal()) as session:
# 获取集群名称
cluster_res = await session.execute(text("""
SELECT c.name
FROM clusters c
JOIN nodes n ON c.id = n.cluster_id
WHERE n.hostname = :hn LIMIT 1
"""), {"hn": log_data["host"]})
cluster_row = cluster_res.first()
cluster_name = cluster_row[0] if cluster_row else "default_cluster"
host = log_data["host"]
cluster_name = self._cluster_name_cache.get(host)
if not cluster_name:
cluster_res = await session.execute(text("""
SELECT c.name
FROM clusters c
JOIN nodes n ON c.id = n.cluster_id
WHERE n.hostname = :hn LIMIT 1
"""), {"hn": host})
cluster_row = cluster_res.first()
cluster_name = cluster_row[0] if cluster_row else "default_cluster"
self._cluster_name_cache[host] = cluster_name
# Create HadoopLog instance
hadoop_log = HadoopLog(
@ -130,27 +141,34 @@ class LogCollector:
except Exception as e:
print(f"Error saving log to database: {e}")
async def _save_logs_to_db_batch(self, logs: List[Dict]):
async def _save_logs_to_db_batch(self, logs: List[Dict], collector_id: str | None = None):
"""Save a batch of logs to database in one transaction"""
try:
async with SessionLocal() as session:
for log_data in logs:
session_local = self._session_locals.get(collector_id) if collector_id else None
async with (session_local() if session_local else SessionLocal()) as session:
host = logs[0]["host"] if logs else None
cluster_name = self._cluster_name_cache.get(host) if host else None
if host and not cluster_name:
cluster_res = await session.execute(text("""
SELECT c.name
FROM clusters c
JOIN nodes n ON c.id = n.cluster_id
SELECT c.name
FROM clusters c
JOIN nodes n ON c.id = n.cluster_id
WHERE n.hostname = :hn LIMIT 1
"""), {"hn": log_data["host"]})
"""), {"hn": host})
cluster_row = cluster_res.first()
cluster_name = cluster_row[0] if cluster_row else "default_cluster"
hadoop_log = HadoopLog(
self._cluster_name_cache[host] = cluster_name
objs: list[HadoopLog] = []
for log_data in logs:
objs.append(HadoopLog(
log_time=log_data["timestamp"],
node_host=log_data["host"],
title=log_data["service"],
info=log_data["message"],
cluster_name=cluster_name
)
session.add(hadoop_log)
cluster_name=cluster_name or "default_cluster",
))
session.add_all(objs)
await session.commit()
except Exception as e:
print(f"Error batch saving logs: {e}")
@ -163,16 +181,26 @@ class LogCollector:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._loops[collector_id] = loop
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_pre_ping=True,
connect_args={"server_settings": {"timezone": APP_TIMEZONE}},
pool_size=1,
max_overflow=0,
)
self._engines[collector_id] = engine
self._session_locals[collector_id] = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
last_file_size = 0
last_line_count = self._line_counts.get(collector_id, 0)
last_remote_size = 0
retry_count = 0
max_retries = 3
while collector_id in self.collectors:
try:
# Wait for next collection interval
time.sleep(self.collection_interval)
interval = self._intervals.get(collector_id, self.collection_interval)
time.sleep(interval)
# Resolve target file once and reuse
target = self._targets.get(collector_id)
@ -207,40 +235,41 @@ class LogCollector:
retry_count += 1
continue
# Read current log content
ssh_client = ssh_manager.get_connection(node_name, ip=ip)
current_log_content = ""
out2, err2 = ssh_client.execute_command(f"cat {target} 2>/dev/null")
if not err2:
current_log_content = out2
current_file_size = len(current_log_content)
# Check if log file has new content
if current_file_size > last_file_size:
# Extract new content
new_content = current_log_content[last_file_size:]
# Save new content to database
self._save_log_chunk(node_name, log_type, new_content)
# Update last file size
last_file_size = current_file_size
print(f"Collected {len(new_content.splitlines())} new lines from {node_name}_{log_type}")
size_out, size_err = ssh_client.execute_command(f"stat -c %s {target} 2>/dev/null")
if size_err:
retry_count += 1
continue
try:
remote_size = int((size_out or "").strip())
except Exception:
retry_count += 1
continue
if remote_size < last_remote_size:
last_remote_size = 0
if remote_size > last_remote_size:
delta = remote_size - last_remote_size
if delta > self.max_bytes_per_pull:
start_pos = remote_size - self.max_bytes_per_pull + 1
last_remote_size = remote_size - self.max_bytes_per_pull
else:
start_pos = last_remote_size + 1
out2, err2 = ssh_client.execute_command(f"tail -c +{start_pos} {target} 2>/dev/null")
if err2:
out2, err2 = ssh_client.execute_command(f"dd if={target} bs=1 skip={max(0, start_pos - 1)} 2>/dev/null")
if not err2 and out2 and out2.strip():
self._save_log_chunk(node_name, log_type, out2)
print(f"Collected new logs from {node_name}_{log_type} bytes={len(out2)}")
last_remote_size = remote_size
# Reset retry count on successful collection
retry_count = 0
# Also track by line count to cover same-length encodings
lines = current_log_content.splitlines()
if len(lines) > last_line_count:
tail = "\n".join(lines[last_line_count:])
if tail.strip():
self._save_log_chunk(node_name, log_type, tail)
print(f"Collected {len(lines) - last_line_count} new lines (by count) from {node_name}_{log_type}")
last_line_count = len(lines)
self._line_counts[collector_id] = last_line_count
except Exception as e:
print(f"Error collecting logs from {node_name}_{log_type}: {e}")
retry_count += 1
@ -254,6 +283,10 @@ class LogCollector:
try:
loop = self._loops.pop(collector_id, None)
engine = self._engines.pop(collector_id, None)
self._session_locals.pop(collector_id, None)
if engine and loop:
loop.run_until_complete(engine.dispose())
if loop and loop.is_running():
loop.stop()
if loop:
@ -277,7 +310,7 @@ class LogCollector:
collector_id = f"{node_name}_{log_type}"
loop = self._loops.get(collector_id)
if loop:
loop.run_until_complete(self._save_logs_to_db_batch(log_batch))
loop.run_until_complete(self._save_logs_to_db_batch(log_batch, collector_id=collector_id))
else:
asyncio.run(self._save_logs_to_db_batch(log_batch))
@ -291,6 +324,8 @@ class LogCollector:
def set_collection_interval(self, interval: int):
"""Set the collection interval"""
self.collection_interval = max(1, interval) # Ensure interval is at least 1 second
for k in list(self._intervals.keys()):
self._intervals[k] = self.collection_interval
print(f"Set collection interval to {self.collection_interval} seconds")
def set_log_dir(self, log_dir: str):

@ -3,13 +3,13 @@ import time
import datetime
import time as _time
from typing import Dict, List, Optional, Tuple
from sqlalchemy import text, select, func
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from .ssh_utils import ssh_manager
from .db import SessionLocal
from .models.nodes import Node
from .models.clusters import Cluster
import asyncio
from .config import BJ_TZ
class MetricsCollector:
def __init__(self):
@ -87,16 +87,13 @@ class MetricsCollector:
return cpu_pct, mem_pct
async def _save_metrics(self, node_id: int, hostname: str, cluster_id: int, cpu: float, mem: float):
async with SessionLocal() as session:
now = datetime.datetime.now(datetime.timezone.utc)
# 这里的 SessionLocal 绑定的 engine 可能在主线程 loop 中初始化
# 在 asyncio.run() 开启的新 loop 中使用它会报 Loop 冲突
from .db import engine
async with AsyncSession(engine) as session:
now = datetime.datetime.now(BJ_TZ)
await session.execute(text("UPDATE nodes SET cpu_usage=:cpu, memory_usage=:mem, last_heartbeat=:hb WHERE id=:nid"), {"cpu": cpu, "mem": mem, "hb": now, "nid": node_id})
await session.commit()
rows = await session.execute(select(func.avg(Node.cpu_usage), func.avg(Node.memory_usage)).where(Node.cluster_id == cluster_id))
avg_row = rows.first()
ca = float(avg_row[0] or 0.0)
ma = float(avg_row[1] or 0.0)
await session.execute(text("UPDATE clusters SET cpu_avg=:ca, memory_avg=:ma WHERE id=:cid"), {"ca": round(ca, 2), "ma": round(ma, 2), "cid": cluster_id})
await session.commit()
def _collect_node_metrics(self, node_id: int, hostname: str, ip: str, cluster_id: int):
cid = hostname

@ -1,6 +1,7 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from datetime import datetime
from ..config import BJ_TZ
from . import Base
class ChatSession(Base):
@ -9,8 +10,8 @@ class ChatSession(Base):
id = Column(String, primary_key=True, index=True) # UUID
user_id = Column(Integer, nullable=True, index=True) # Can be linked to a user
title = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(BJ_TZ))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(BJ_TZ), onupdate=lambda: datetime.now(BJ_TZ))
messages = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan", lazy="selectin")
@ -21,7 +22,7 @@ class ChatMessage(Base):
session_id = Column(String, ForeignKey("chat_sessions.id"), nullable=False)
role = Column(String, nullable=False) # system, user, assistant, tool
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(BJ_TZ))
# Optional: store tool calls or extra metadata if needed
# For now, we store JSON in content if it's complex, or just text.

@ -13,7 +13,7 @@ from ..models.hadoop_logs import HadoopLog
from ..models.chat import ChatSession, ChatMessage
from ..agents.diagnosis_agent import run_diagnose_and_repair
from ..services.llm import LLMClient
from ..services.ops_tools import openai_tools_schema, tool_web_search
from ..services.ops_tools import openai_tools_schema, tool_web_search, tool_start_cluster, tool_stop_cluster, tool_read_log, tool_read_cluster_log, tool_detect_cluster_faults, tool_run_cluster_command
router = APIRouter()
@ -91,11 +91,10 @@ async def ai_chat(req: ChatReq, user=Depends(get_current_user), db: AsyncSession
db.add(session)
system_prompt = (
"You are a helpful Hadoop diagnostic assistant. "
"Please provide clear, structured, and well-formatted responses using Markdown. "
"Use headers (##, ###) for sections, bullet points for lists, and code blocks for logs or commands. "
"Ensure proper line breaks between paragraphs and sections to enhance readability. "
"If you are providing a diagnosis, use a 'Diagnosis' and 'Recommendation' structure."
"你是 Hadoop 运维诊断助手。输出中文,优先给出根因、影响范围、证据与建议。"
"当用户询问“故障/异常/报错/不可用/打不开/任务失败”等问题时,优先调用 detect_cluster_faults"
"必要时再用 read_cluster_log 补充读取对应组件日志。"
"当用户询问进程/端口/资源/版本等日常运维信息时,优先调用 run_cluster_command例如 jps/df/free/hdfs_report/yarn_node_list"
)
if req.context:
if req.context.get("agent"):
@ -117,14 +116,14 @@ async def ai_chat(req: ChatReq, user=Depends(get_current_user), db: AsyncSession
llm = LLMClient()
target_model = req.context.get("model") if req.context else None
web_search_enabled = bool(req.context and req.context.get("webSearch"))
chat_tools = None
if web_search_enabled:
tools = openai_tools_schema()
chat_tools = [t for t in tools if t["function"]["name"] == "web_search"]
if req.stream and not web_search_enabled:
return await handle_streaming_chat(llm, messages, internal_id, db, tools=None, model=target_model)
# 默认加载所有可用运维工具
chat_tools = openai_tools_schema()
if req.stream:
# 流式暂不支持工具调用后的二次生成(为了简化),如果检测到可能需要工具,先走非流式
# 或者这里可以根据需求调整,目前先保持非流式处理工具逻辑
pass
resp = await llm.chat(messages, tools=chat_tools, stream=False, model=target_model)
choices = resp.get("choices") or []
@ -145,8 +144,44 @@ async def ai_chat(req: ChatReq, user=Depends(get_current_user), db: AsyncSession
args = {}
tool_result = {"error": "unknown_tool"}
uname = _get_username(user)
if name == "web_search":
tool_result = await tool_web_search(args.get("query"), args.get("max_results", 5))
elif name == "start_cluster":
tool_result = await tool_start_cluster(db, uname, args.get("cluster_uuid"))
elif name == "stop_cluster":
tool_result = await tool_stop_cluster(db, uname, args.get("cluster_uuid"))
elif name == "read_log":
tool_result = await tool_read_log(db, uname, args.get("node"), args.get("path"), int(args.get("lines", 200)), args.get("pattern"), args.get("sshUser"))
elif name == "read_cluster_log":
tool_result = await tool_read_cluster_log(
db,
uname,
args.get("cluster_uuid"),
args.get("log_type"),
args.get("node_hostname"),
int(args.get("lines", 100))
)
elif name == "detect_cluster_faults":
tool_result = await tool_detect_cluster_faults(
db,
uname,
args.get("cluster_uuid"),
args.get("components"),
args.get("node_hostname"),
int(args.get("lines", 200)),
)
elif name == "run_cluster_command":
tool_result = await tool_run_cluster_command(
db,
uname,
args.get("cluster_uuid"),
args.get("command_key"),
args.get("target"),
args.get("node_hostname"),
int(args.get("timeout", 30)),
int(args.get("limit_nodes", 20)),
)
messages.append({
"role": "tool",

@ -9,6 +9,7 @@ from ..config import JWT_SECRET, JWT_EXPIRE_MINUTES
import jwt
from datetime import datetime, timedelta, timezone
import re
from ..config import now_bj
router = APIRouter()
@ -88,7 +89,7 @@ async def _get_role_permissions(db: AsyncSession, role_keys: list[str]) -> list[
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
demo = {"admin": "admin123", "ops": "ops123", "obs": "obs123"}
if req.username in demo and req.password == demo[req.username]:
exp = datetime.now(timezone.utc) + timedelta(minutes=JWT_EXPIRE_MINUTES)
exp = now_bj() + timedelta(minutes=JWT_EXPIRE_MINUTES)
token = jwt.encode({"sub": req.username, "exp": exp}, JWT_SECRET, algorithm="HS256")
# 为 demo 账号获取角色和权限
@ -127,7 +128,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
roles = await _get_user_roles(db, user.id)
permissions = await _get_role_permissions(db, roles)
exp = datetime.now(timezone.utc) + timedelta(minutes=JWT_EXPIRE_MINUTES)
exp = now_bj() + timedelta(minutes=JWT_EXPIRE_MINUTES)
token = jwt.encode({"sub": user.username, "exp": exp}, JWT_SECRET, algorithm="HS256")
return {
"ok": True,
@ -185,8 +186,8 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
full_name=req.fullName,
is_active=True,
last_login=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_at=now_bj(),
updated_at=now_bj(),
)
db.add(user)
await db.flush()
@ -194,7 +195,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await _map_user_role(db, req.username, "observer")
permissions = await _get_role_permissions(db, ["observer"])
exp = datetime.now(timezone.utc) + timedelta(minutes=JWT_EXPIRE_MINUTES)
exp = now_bj() + timedelta(minutes=JWT_EXPIRE_MINUTES)
token = jwt.encode({"sub": user.username, "exp": exp}, JWT_SECRET, algorithm="HS256")
return {
"ok": True,

@ -9,6 +9,7 @@ from ..services.ssh_probe import check_ssh_connectivity, get_hdfs_cluster_id
from pydantic import BaseModel
from datetime import datetime, timezone
import uuid as uuidlib
from ..config import now_bj
router = APIRouter()
@ -156,8 +157,8 @@ async def create_cluster(
rm_psw=req.rm_psw,
description=req.description,
config_info={},
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_at=now_bj(),
updated_at=now_bj(),
)
db.add(c)
await db.flush() # 获取 c.id
@ -173,8 +174,8 @@ async def create_cluster(
ssh_user=n_req.ssh_user,
ssh_password=n_req.ssh_password,
status="unknown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_at=now_bj(),
updated_at=now_bj(),
)
db.add(node)

@ -6,8 +6,10 @@ from ..models.hadoop_logs import HadoopLog
from ..models.clusters import Cluster
from ..deps.auth import get_current_user
from pydantic import BaseModel
from datetime import datetime, timezone
from datetime import datetime
import json
from ..config import now_bj
from ..config import BJ_TZ
router = APIRouter()
@ -17,7 +19,7 @@ def _get_username(u) -> str:
def _now():
return datetime.now(timezone.utc)
return now_bj()
def _map_level(level: str) -> str:
@ -70,6 +72,10 @@ async def list_faults(
if time_from:
try:
tf = datetime.fromisoformat(time_from.replace("Z", "+00:00"))
if tf.tzinfo is None:
tf = tf.replace(tzinfo=BJ_TZ)
else:
tf = tf.astimezone(BJ_TZ)
stmt = stmt.where(HadoopLog.log_time >= tf)
count_stmt = count_stmt.where(HadoopLog.log_time >= tf)
except Exception:
@ -124,7 +130,11 @@ async def create_fault(req: FaultCreate, user=Depends(get_current_user), db: Asy
ts = _now()
if req.created:
try:
ts = datetime.fromisoformat(req.created.replace("Z", "+00:00"))
dt = datetime.fromisoformat(req.created.replace("Z", "+00:00"))
if dt.tzinfo is None:
ts = dt.replace(tzinfo=BJ_TZ)
else:
ts = dt.astimezone(BJ_TZ)
except Exception:
pass

@ -7,6 +7,8 @@ from ..models.users import User
from ..deps.auth import get_current_user
from pydantic import BaseModel
from datetime import datetime, timezone
from ..config import now_bj
from ..config import BJ_TZ
router = APIRouter()
@ -26,16 +28,17 @@ class ExecLogUpdate(BaseModel):
def _now() -> datetime:
return datetime.now(timezone.utc)
return now_bj()
def _parse_time(s: str | None) -> datetime | None:
if not s:
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is None:
return dt.replace(tzinfo=BJ_TZ)
return dt.astimezone(BJ_TZ)
except Exception:
return None

@ -15,6 +15,9 @@ import time
from ..models.node_metrics import NodeMetric
from ..models.cluster_metrics import ClusterMetric
from datetime import timedelta
from ..config import now_bj
from ..config import BJ_TZ
from zoneinfo import ZoneInfo
from ..schemas import (
LogRequest,
LogResponse,
@ -64,9 +67,10 @@ def _parse_time(s: str | None) -> datetime | None:
if not s:
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is None:
return dt.replace(tzinfo=BJ_TZ)
return dt.astimezone(BJ_TZ)
except Exception:
return None
@ -410,7 +414,7 @@ async def sync_metrics(cluster_uuid: str, user=Depends(get_current_user), db: As
cid, cname = row
nodes_res = await db.execute(select(Node.id, Node.hostname, Node.ip_address).where(Node.cluster_id == cid))
rows = nodes_res.all()
now = datetime.now(timezone.utc)
now = now_bj()
details = []
for nid, hn, ip in rows:
ssh_client = ssh_manager.get_connection(hn, ip=str(ip))

@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from ..db import get_db
from ..deps.auth import get_current_user
from ..metrics_collector import metrics_collector
from ..models.nodes import Node
from ..models.clusters import Cluster
from datetime import datetime, timezone
@ -29,6 +30,113 @@ async def _ensure_access(db: AsyncSession, username: str, cluster_uuid: str) ->
return cid
@router.post("/metrics/collectors/start-by-cluster/{cluster_uuid}")
async def start_collectors_by_cluster(
cluster_uuid: str,
interval: int = Query(5, ge=1, le=3600),
user=Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
name = _get_username(user)
cid = await _ensure_access(db, name, cluster_uuid)
if not cid:
raise HTTPException(status_code=403, detail="not_allowed")
res = await db.execute(select(Node.id, Node.hostname, Node.ip_address).where(Node.cluster_id == cid))
rows = res.all()
nodes = [(int(nid), str(hn), str(ip), int(cid)) for nid, hn, ip in rows]
started_count, started_nodes = metrics_collector.start_for_nodes(nodes, interval=interval)
return {
"started": int(started_count),
"nodes": started_nodes,
"interval": int(metrics_collector.collection_interval),
}
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="server_error")
@router.get("/metrics/collectors/status")
async def get_collectors_status(
cluster: str | None = Query(None),
user=Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查询指标采集器的状态"""
try:
name = _get_username(user)
# 即使校验失败或发生错误,也返回一个 200 结构的友好响应,而不是让接口崩掉
try:
status = metrics_collector.get_collectors_status()
errors = metrics_collector.get_errors()
interval = int(metrics_collector.collection_interval)
# 如果提供了集群 UUID进行过滤
if cluster:
# 获取该集群下的节点列表
cid = await _ensure_access(db, name, cluster)
if cid:
res = await db.execute(select(Node.hostname).where(Node.cluster_id == cid))
cluster_nodes = set(str(hn) for (hn,) in res.all())
status = {k: v for k, v in status.items() if k in cluster_nodes}
errors = {k: v for k, v in errors.items() if k in cluster_nodes}
else:
# 权限不足时,返回空结果而非报错
status = {}
errors = {}
return {
"is_running": any(status.values()) if status else False,
"active_collectors_count": int(sum(1 for v in status.values() if v)),
"interval": interval,
"collectors": status,
"errors": errors
}
except Exception as inner_e:
return {
"is_running": False,
"active_collectors_count": 0,
"interval": 5,
"collectors": {},
"errors": {"system": str(inner_e)}
}
except Exception as e:
# 顶层异常捕获
return {
"is_running": False,
"active_collectors_count": 0,
"interval": 5,
"collectors": {},
"errors": {"fatal": str(e)}
}
@router.post("/metrics/collectors/stop-by-cluster/{cluster_uuid}")
async def stop_collectors_by_cluster(
cluster_uuid: str,
user=Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
name = _get_username(user)
cid = await _ensure_access(db, name, cluster_uuid)
if not cid:
raise HTTPException(status_code=403, detail="not_allowed")
res = await db.execute(select(Node.hostname).where(Node.cluster_id == cid))
hostnames = [str(hn) for (hn,) in res.all()]
stopped = []
for hn in hostnames:
if hn in metrics_collector.collectors:
metrics_collector.stop(hn)
stopped.append(hn)
return {"stopped": int(len(stopped)), "nodes": stopped}
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="server_error")
@router.get("/metrics/cpu_trend")
async def cpu_trend(cluster: str = Query(...), user=Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""获取指定集群的 CPU 使用率趋势数据。"""

@ -7,6 +7,7 @@ from ..models.nodes import Node
from ..models.clusters import Cluster
from pydantic import BaseModel
from datetime import datetime, timezone
from ..config import now_bj
router = APIRouter()
@ -32,7 +33,7 @@ def _fmt_percent(v: float | None) -> str:
def _fmt_updated(ts: datetime | None) -> str:
if not ts:
return "-"
now = datetime.now(timezone.utc)
now = now_bj()
diff = int((now - ts).total_seconds())
if diff < 60:
return "刚刚"
@ -117,4 +118,3 @@ async def node_detail(name: str, user=Depends(get_current_user), db: AsyncSessio
raise HTTPException(status_code=500, detail="server_error")

@ -15,6 +15,7 @@ from ..models.sys_exec_logs import SysExecLog
from ..models.hadoop_exec_logs import HadoopExecLog
from ..services.runner import run_remote_command
from ..ssh_utils import SSHClient
from ..config import now_bj
router = APIRouter()
@ -22,7 +23,7 @@ router = APIRouter()
def _now() -> datetime:
"""返回当前 UTC 时间。"""
return datetime.now(timezone.utc)
return now_bj()
def _get_username(u) -> str:
@ -138,6 +139,12 @@ async def start_cluster(
):
"""启动集群:在 NameNode 执行 hsfsstart在 ResourceManager 执行 yarnstart。"""
try:
# UUID 格式校验
try:
uuidlib.UUID(cluster_uuid)
except ValueError:
raise HTTPException(status_code=400, detail="invalid_uuid_format")
uname = _get_username(user)
user_id = getattr(user, "id", 1)
@ -179,8 +186,14 @@ async def start_cluster(
end_time = _now()
# 5. 更新集群状态
cluster.health_status = "healthy"
# 5. 更新集群状态 (仅当所有尝试都未抛出异常时)
# 改进:检查是否有失败日志
has_failed = any("failed" in log.lower() for log in logs)
if not has_failed:
cluster.health_status = "healthy"
else:
cluster.health_status = "error"
cluster.updated_at = end_time
await db.flush()
@ -204,6 +217,12 @@ async def stop_cluster(
):
"""停止集群:在 NameNode 执行 hsfsstop在 ResourceManager 执行 yarnstop。"""
try:
# UUID 格式校验
try:
uuidlib.UUID(cluster_uuid)
except ValueError:
raise HTTPException(status_code=400, detail="invalid_uuid_format")
uname = _get_username(user)
user_id = getattr(user, "id", 1)
@ -266,4 +285,3 @@ async def stop_cluster(

@ -8,6 +8,7 @@ from ..deps.auth import get_current_user
from passlib.hash import bcrypt
from datetime import datetime, timezone
import re
from ..config import now_bj
router = APIRouter()
@ -140,7 +141,7 @@ async def create_user(req: CreateUserRequest, user=Depends(get_current_user), db
temp_password = "TempPass#123"
password_hash = bcrypt.hash(temp_password)
now = datetime.now(timezone.utc)
now = now_bj()
user_obj = User(
username=req.username,
email=req.email,

@ -0,0 +1,51 @@
from __future__ import annotations
from ..config import SSH_TIMEOUT
from ..ssh_utils import SSHClient
def collect_cluster_uuid(host: str, user: str, password: str, timeout: int | None = None) -> tuple[str | None, str | None, str | None]:
cli = None
try:
cli = SSHClient(str(host), user or "", password or "")
out, err = cli.execute_command_with_timeout(
"hdfs getconf -confKey dfs.namenode.name.dir",
timeout or SSH_TIMEOUT,
)
if not out or not out.strip():
return None, "probe_name_dirs", (err or "empty_output")
name_dir = out.strip().split(",")[0]
if name_dir.startswith("file://"):
name_dir = name_dir[7:]
version_path = f"{name_dir.rstrip('/')}/current/VERSION"
version_out, version_err = cli.execute_command_with_timeout(
f"cat {version_path}",
timeout or SSH_TIMEOUT,
)
if not version_out or not version_out.strip():
return None, "read_version", (version_err or "empty_output")
cluster_id = None
for line in version_out.splitlines():
if "clusterID" in line:
parts = line.strip().split("=", 1)
if len(parts) == 2 and parts[0].strip() == "clusterID":
cluster_id = parts[1].strip()
break
if not cluster_id:
return None, "parse_cluster_id", version_out.strip()
if cluster_id.startswith("CID-"):
cluster_id = cluster_id[4:]
return cluster_id, None, None
except Exception as e:
return None, "connect_or_exec", str(e)
finally:
try:
if cli:
cli.close()
except Exception:
pass

@ -3,6 +3,7 @@ import asyncio
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, timezone
import json
import re
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
@ -16,13 +17,14 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
from ..models.nodes import Node
from ..models.clusters import Cluster
from ..models.hadoop_exec_logs import HadoopExecLog
from ..ssh_utils import SSHClient
from .runner import run_remote_command
from ..ssh_utils import SSHClient, ssh_manager
from ..log_reader import log_reader
from ..config import now_bj
def _now() -> datetime:
"""返回当前 UTC 时间。"""
return datetime.now(timezone.utc)
return now_bj()
async def _find_accessible_node(db: AsyncSession, user_name: str, hostname: str) -> Optional[Node]:
@ -39,6 +41,18 @@ async def _find_accessible_node(db: AsyncSession, user_name: str, hostname: str)
return res.scalars().first()
async def _user_has_cluster_access(db: AsyncSession, user_name: str, cluster_id: int) -> bool:
uid_res = await db.execute(text("SELECT id FROM users WHERE username=:un LIMIT 1"), {"un": user_name})
uid_row = uid_res.first()
if not uid_row:
return False
ok_res = await db.execute(
text("SELECT 1 FROM user_cluster_mapping WHERE user_id=:uid AND cluster_id=:cid LIMIT 1"),
{"uid": uid_row[0], "cid": cluster_id},
)
return ok_res.first() is not None
async def _write_exec_log(db: AsyncSession, exec_id: str, command_type: str, status: str, start: datetime, end: Optional[datetime], exit_code: Optional[int], operator: str, stdout: Optional[str] = None, stderr: Optional[str] = None):
"""写入执行审计日志。"""
# 查找 from_user_id 和 cluster_name
@ -73,13 +87,26 @@ async def tool_read_log(db: AsyncSession, user_name: str, node: str, path: str,
n = await _find_accessible_node(db, user_name, node)
if not n:
return {"error": "node_not_found"}
if not getattr(n, "ssh_password", None):
return {"error": "ssh_password_not_configured"}
path_q = shlex.quote(path)
cmd = f"tail -n {lines} {path_q}"
if pattern:
pat_q = shlex.quote(pattern)
cmd = f"{cmd} | grep -E {pat_q}"
start = _now()
code, out, err = await run_remote_command(str(getattr(n, "ip_address", "")), ssh_user or "", cmd, timeout=timeout)
bash_cmd = f"bash -lc {shlex.quote(cmd)}"
def _run():
client = ssh_manager.get_connection(
str(getattr(n, "hostname", node)),
ip=str(getattr(n, "ip_address", "")),
username=(ssh_user or getattr(n, "ssh_user", None) or "hadoop"),
password=str(getattr(n, "ssh_password", "")),
)
return client.execute_command_with_timeout_and_status(bash_cmd, timeout=timeout)
code, out, err = await asyncio.to_thread(_run)
end = _now()
exec_id = f"tool_{start.timestamp():.0f}"
await _write_exec_log(db, exec_id, "read_log", ("success" if code == 0 else "failed"), start, end, code, user_name, out, err)
@ -216,8 +243,13 @@ async def tool_start_cluster(db: AsyncSession, user_name: str, cluster_uuid: str
end_time = _now()
# 6. 更新集群状态
cluster.health_status = "healthy"
# 6. 更新集群状态 (改进:检查是否有失败日志)
has_failed = any("failed" in log.lower() for log in logs)
if not has_failed:
cluster.health_status = "healthy"
else:
cluster.health_status = "error"
cluster.updated_at = end_time
await db.flush()
@ -294,6 +326,377 @@ async def tool_stop_cluster(db: AsyncSession, user_name: str, cluster_uuid: str)
return {"status": "success", "logs": logs}
async def tool_read_cluster_log(
db: AsyncSession,
user_name: str,
cluster_uuid: str,
log_type: str,
node_hostname: Optional[str] = None,
lines: int = 100,
) -> Dict[str, Any]:
"""读取集群中特定服务类型的日志。"""
import uuid as uuidlib
try:
uuidlib.UUID(cluster_uuid)
except ValueError:
return {"status": "error", "message": "invalid_uuid_format"}
stmt = select(Cluster).where(Cluster.uuid == cluster_uuid)
result = await db.execute(stmt)
cluster = result.scalar_one_or_none()
if not cluster:
return {"status": "error", "message": "cluster_not_found"}
if not await _user_has_cluster_access(db, user_name, int(cluster.id)):
return {"status": "error", "message": "cluster_forbidden"}
target_ip: Optional[str] = None
target_hostname: Optional[str] = node_hostname
ssh_user: Optional[str] = None
ssh_password: Optional[str] = None
if log_type.lower() == "namenode":
target_ip = str(cluster.namenode_ip) if cluster.namenode_ip else None
ssh_password = cluster.namenode_psw
if not target_hostname:
node_stmt = select(Node).where(Node.ip_address == cluster.namenode_ip)
node_res = await db.execute(node_stmt)
node_obj = node_res.scalar_one_or_none()
target_hostname = node_obj.hostname if node_obj else "namenode"
if node_obj and node_obj.ssh_user:
ssh_user = node_obj.ssh_user
elif log_type.lower() == "resourcemanager":
target_ip = str(cluster.rm_ip) if cluster.rm_ip else None
ssh_password = cluster.rm_psw
if not target_hostname:
node_stmt = select(Node).where(Node.ip_address == cluster.rm_ip)
node_res = await db.execute(node_stmt)
node_obj = node_res.scalar_one_or_none()
target_hostname = node_obj.hostname if node_obj else "resourcemanager"
if node_obj and node_obj.ssh_user:
ssh_user = node_obj.ssh_user
if not target_ip and target_hostname:
node = await _find_accessible_node(db, user_name, target_hostname)
if not node:
return {"status": "error", "message": "node_not_found"}
target_ip = str(node.ip_address)
ssh_user = node.ssh_user or ssh_user
ssh_password = node.ssh_password or ssh_password
if not target_ip:
return {"status": "error", "message": f"could_not_determine_node_for_{log_type}"}
if not target_hostname:
target_hostname = target_ip
def _tail_via_ssh() -> Dict[str, Any]:
ip = str(target_ip)
hn = str(target_hostname)
log_reader.find_working_log_dir(hn, ip)
ssh_client = ssh_manager.get_connection(hn, ip=ip, username=ssh_user, password=ssh_password)
paths = log_reader.get_log_file_paths(hn, log_type.lower())
for p in paths:
p_q = shlex.quote(p)
out, err = ssh_client.execute_command(f"ls -la {p_q} 2>/dev/null")
if err or not out.strip():
continue
out2, err2 = ssh_client.execute_command(f"tail -n {int(lines)} {p_q} 2>/dev/null")
if err2:
continue
return {"status": "success", "node": hn, "log_type": log_type, "path": p, "content": out2}
base_dir = log_reader._node_log_dir.get(hn, log_reader.log_dir)
base_q = shlex.quote(base_dir)
out, err = ssh_client.execute_command(f"ls -1 {base_q} 2>/dev/null")
if err or not out.strip():
return {"status": "error", "message": "log_dir_not_found", "node": hn}
for fn in out.splitlines():
f = (fn or "").strip()
lf = f.lower()
if not f:
continue
if log_type.lower() in lf and hn.lower() in lf and (lf.endswith(".log") or lf.endswith(".out") or lf.endswith(".out.1")):
full = f"{base_dir}/{f}"
full_q = shlex.quote(full)
out2, err2 = ssh_client.execute_command(f"tail -n {int(lines)} {full_q} 2>/dev/null")
if not err2:
return {"status": "success", "node": hn, "log_type": log_type, "path": full, "content": out2}
return {"status": "error", "message": "log_file_not_found", "node": hn}
return await asyncio.to_thread(_tail_via_ssh)
_FAULT_RULES: List[Dict[str, Any]] = [
{
"id": "hdfs_safemode",
"severity": "high",
"title": "NameNode 处于 SafeMode",
"patterns": [r"SafeModeException", r"NameNode is in safe mode", r"Safe mode is ON"],
"advice": "检查 DataNode 是否全部注册、磁盘与网络是否正常;必要时执行 hdfs dfsadmin -safemode leave。",
},
{
"id": "hdfs_standby",
"severity": "high",
"title": "访问到 Standby NameNode",
"patterns": [r"StandbyException", r"Operation category READ is not supported in state standby"],
"advice": "确认客户端的 fs.defaultFS/HA 配置;确认 active/standby 切换状态是否正确。",
},
{
"id": "rpc_connection_refused",
"severity": "high",
"title": "RPC 连接被拒绝或目标服务未启动",
"patterns": [r"java\.net\.ConnectException:\s*Connection refused", r"Call to .* failed on local exception", r"Connection refused"],
"advice": "确认对应守护进程是否存活、端口是否监听、iptables/安全组是否放通。",
},
{
"id": "dns_or_route",
"severity": "high",
"title": "DNS/网络不可达",
"patterns": [r"UnknownHostException", r"No route to host", r"Network is unreachable", r"Connection timed out"],
"advice": "检查 DNS 解析、/etc/hosts、一致的主机名配置与网络连通性。",
},
{
"id": "disk_no_space",
"severity": "high",
"title": "磁盘空间不足",
"patterns": [r"No space left on device", r"DiskOutOfSpaceException", r"ENOSPC"],
"advice": "清理磁盘、检查日志/临时目录增长;确认 DataNode 存储目录剩余空间。",
},
{
"id": "permission_denied",
"severity": "medium",
"title": "权限不足或 HDFS ACL/权限问题",
"patterns": [r"Permission denied", r"AccessControlException"],
"advice": "检查用户/组映射、HDFS 权限与 ACL确认相关目录权限与 umask。",
},
{
"id": "kerberos_auth",
"severity": "high",
"title": "Kerberos 认证失败",
"patterns": [r"GSSException", r"Failed to find any Kerberos tgt", r"Client cannot authenticate via:\s*\[TOKEN, KERBEROS\]"],
"advice": "检查 KDC、keytab、principal、时间同步确认客户端已 kinit 且票据未过期。",
},
{
"id": "oom",
"severity": "high",
"title": "Java 内存溢出",
"patterns": [r"OutOfMemoryError", r"Java heap space", r"GC overhead limit exceeded"],
"advice": "检查相关服务 JVM 参数(-Xmx/-Xms、容器/节点内存;结合 GC 日志定位内存泄漏或峰值。",
},
{
"id": "jvm_exit_killed",
"severity": "medium",
"title": "进程异常退出或被杀",
"patterns": [r"ExitCodeException exitCode=143", r"Killed by signal", r"Container killed"],
"advice": "检查是否被资源管理器/系统 OOM killer 杀死;核对 YARN 队列资源与节点资源。",
},
]
def _detect_faults_from_log_text(text: str, max_examples_per_rule: int = 3) -> List[Dict[str, Any]]:
lines = (text or "").splitlines()
hits: List[Dict[str, Any]] = []
for rule in _FAULT_RULES:
patterns = rule.get("patterns") or []
compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
examples: List[Dict[str, Any]] = []
for idx, line in enumerate(lines):
if not line:
continue
if any(rgx.search(line) for rgx in compiled):
examples.append({"lineNo": idx + 1, "line": line[:500]})
if len(examples) >= max_examples_per_rule:
break
if examples:
hits.append(
{
"id": rule.get("id"),
"severity": rule.get("severity"),
"title": rule.get("title"),
"advice": rule.get("advice"),
"examples": examples,
"matchCountApprox": len(examples),
}
)
return hits
async def tool_detect_cluster_faults(
db: AsyncSession,
user_name: str,
cluster_uuid: str,
components: Optional[List[str]] = None,
node_hostname: Optional[str] = None,
lines: int = 200,
) -> Dict[str, Any]:
import uuid as uuidlib
try:
uuidlib.UUID(cluster_uuid)
except ValueError:
return {"status": "error", "message": "invalid_uuid_format"}
comps = components or ["namenode", "resourcemanager"]
comps = [c for c in comps if isinstance(c, str) and c.strip()]
comps = [c.strip().lower() for c in comps]
if not comps:
return {"status": "error", "message": "no_components"}
reads: List[Dict[str, Any]] = []
faults: List[Dict[str, Any]] = []
for comp in comps:
r = await tool_read_cluster_log(
db=db,
user_name=user_name,
cluster_uuid=cluster_uuid,
log_type=comp,
node_hostname=node_hostname,
lines=lines,
)
reads.append({k: r.get(k) for k in ("status", "node", "log_type", "path", "message")})
if r.get("status") != "success":
continue
content = r.get("content") or ""
comp_faults = _detect_faults_from_log_text(content)
for f in comp_faults:
f2 = dict(f)
f2["component"] = comp
f2["node"] = r.get("node")
f2["path"] = r.get("path")
faults.append(f2)
severity_order = {"high": 0, "medium": 1, "low": 2}
faults.sort(key=lambda x: (severity_order.get((x.get("severity") or "").lower(), 9), x.get("id") or ""))
return {
"status": "success",
"cluster_uuid": cluster_uuid,
"components": comps,
"reads": reads,
"faults": faults[:20],
}
_OPS_COMMANDS: Dict[str, Dict[str, Any]] = {
"jps": {"cmd": "jps -lm", "target": "all_nodes"},
"hadoop_version": {"cmd": "hadoop version", "target": "namenode"},
"hdfs_report": {"cmd": "hdfs dfsadmin -report", "target": "namenode"},
"hdfs_safemode_get": {"cmd": "hdfs dfsadmin -safemode get", "target": "namenode"},
"hdfs_ls_root": {"cmd": "hdfs dfs -ls / | head -n 200", "target": "namenode"},
"yarn_node_list": {"cmd": "yarn node -list 2>/dev/null || yarn node -list -all", "target": "resourcemanager"},
"yarn_application_list": {"cmd": "yarn application -list 2>/dev/null || yarn application -list -appStates RUNNING,ACCEPTED,SUBMITTED", "target": "resourcemanager"},
"df_h": {"cmd": "df -h", "target": "all_nodes"},
"free_h": {"cmd": "free -h", "target": "all_nodes"},
"uptime": {"cmd": "uptime", "target": "all_nodes"},
}
async def tool_run_cluster_command(
db: AsyncSession,
user_name: str,
cluster_uuid: str,
command_key: str,
target: Optional[str] = None,
node_hostname: Optional[str] = None,
timeout: int = 30,
limit_nodes: int = 20,
) -> Dict[str, Any]:
import uuid as uuidlib
try:
uuidlib.UUID(cluster_uuid)
except ValueError:
return {"status": "error", "message": "invalid_uuid_format"}
spec = _OPS_COMMANDS.get((command_key or "").strip())
if not spec:
return {"status": "error", "message": "unsupported_command_key"}
stmt = select(Cluster).where(Cluster.uuid == cluster_uuid)
result = await db.execute(stmt)
cluster = result.scalar_one_or_none()
if not cluster:
return {"status": "error", "message": "cluster_not_found"}
if not await _user_has_cluster_access(db, user_name, int(cluster.id)):
return {"status": "error", "message": "cluster_forbidden"}
tgt = (target or spec.get("target") or "namenode").strip().lower()
cmd = str(spec.get("cmd") or "").strip()
if not cmd:
return {"status": "error", "message": "empty_command"}
bash_cmd = f"bash -lc {shlex.quote(cmd)}"
async def _exec_on_node(hostname: str, ip: str, ssh_user: Optional[str], ssh_password: Optional[str]) -> Dict[str, Any]:
def _run():
client = ssh_manager.get_connection(hostname, ip=ip, username=ssh_user, password=ssh_password)
exit_code, out, err = client.execute_command_with_timeout_and_status(bash_cmd, timeout=timeout)
return exit_code, out, err
exit_code, out, err = await asyncio.to_thread(_run)
return {
"node": hostname,
"ip": ip,
"exitCode": int(exit_code),
"stdout": out,
"stderr": err,
}
results: List[Dict[str, Any]] = []
if tgt == "namenode":
if not cluster.namenode_ip or not cluster.namenode_psw:
return {"status": "error", "message": "namenode_not_configured"}
ip = str(cluster.namenode_ip)
node_stmt = select(Node).where(Node.ip_address == cluster.namenode_ip).limit(1)
node_obj = (await db.execute(node_stmt)).scalars().first()
hostname = node_obj.hostname if node_obj else "namenode"
ssh_user = (node_obj.ssh_user if node_obj and node_obj.ssh_user else "hadoop")
results.append(await _exec_on_node(hostname, ip, ssh_user, cluster.namenode_psw))
elif tgt == "resourcemanager":
if not cluster.rm_ip or not cluster.rm_psw:
return {"status": "error", "message": "resourcemanager_not_configured"}
ip = str(cluster.rm_ip)
node_stmt = select(Node).where(Node.ip_address == cluster.rm_ip).limit(1)
node_obj = (await db.execute(node_stmt)).scalars().first()
hostname = node_obj.hostname if node_obj else "resourcemanager"
ssh_user = (node_obj.ssh_user if node_obj and node_obj.ssh_user else "hadoop")
results.append(await _exec_on_node(hostname, ip, ssh_user, cluster.rm_psw))
elif tgt == "node":
if not node_hostname:
return {"status": "error", "message": "node_hostname_required"}
node = await _find_accessible_node(db, user_name, node_hostname)
if not node:
return {"status": "error", "message": "node_not_found"}
results.append(await _exec_on_node(node.hostname, str(node.ip_address), node.ssh_user or "hadoop", node.ssh_password))
elif tgt == "all_nodes":
nodes_stmt = select(Node).where(Node.cluster_id == cluster.id).limit(limit_nodes)
nodes = (await db.execute(nodes_stmt)).scalars().all()
for n in nodes:
n2 = await _find_accessible_node(db, user_name, n.hostname)
if not n2:
continue
results.append(await _exec_on_node(n2.hostname, str(n2.ip_address), n2.ssh_user or "hadoop", n2.ssh_password))
else:
return {"status": "error", "message": "invalid_target"}
start = _now()
exec_id = f"tool_{start.timestamp():.0f}"
await _write_exec_log(db, exec_id, "run_cluster_command", "success", start, _now(), 0, user_name)
return {
"status": "success",
"cluster_uuid": cluster_uuid,
"command_key": command_key,
"target": tgt,
"executed": cmd,
"results": results,
}
def openai_tools_schema() -> List[Dict[str, Any]]:
"""返回 OpenAI 兼容的工具定义Function Calling"""
return [
@ -358,4 +761,60 @@ def openai_tools_schema() -> List[Dict[str, Any]]:
},
},
},
]
{
"type": "function",
"function": {
"name": "read_cluster_log",
"description": "读取集群中特定组件的日志(如 namenode, datanode, resourcemanager",
"parameters": {
"type": "object",
"properties": {
"cluster_uuid": {"type": "string", "description": "集群的 UUID"},
"log_type": {
"type": "string",
"description": "组件类型,例如 namenode, datanode, resourcemanager, nodemanager, historyserver"
},
"node_hostname": {"type": "string", "description": "可选:指定节点的主机名。如果是 datanode 等非唯一组件,建议提供。"},
"lines": {"type": "integer", "default": 100, "description": "读取的行数"},
},
"required": ["cluster_uuid", "log_type"],
},
},
},
{
"type": "function",
"function": {
"name": "detect_cluster_faults",
"description": "基于集群组件日志识别常见故障并输出结构化结果",
"parameters": {
"type": "object",
"properties": {
"cluster_uuid": {"type": "string", "description": "集群的 UUID"},
"components": {"type": "array", "items": {"type": "string"}, "description": "要分析的组件列表,例如 [namenode, resourcemanager, datanode]"},
"node_hostname": {"type": "string", "description": "可选:指定节点主机名(适用于 datanode 等多实例组件)"},
"lines": {"type": "integer", "default": 200, "description": "每个组件读取的行数"},
},
"required": ["cluster_uuid"],
},
},
},
{
"type": "function",
"function": {
"name": "run_cluster_command",
"description": "在集群节点上执行常用运维命令(白名单)并返回结果",
"parameters": {
"type": "object",
"properties": {
"cluster_uuid": {"type": "string", "description": "集群的 UUID"},
"command_key": {"type": "string", "description": "命令标识,例如 jps, hdfs_report, yarn_node_list, df_h"},
"target": {"type": "string", "description": "执行目标namenode/resourcemanager/node/all_nodes不传则按命令默认目标"},
"node_hostname": {"type": "string", "description": "target=node 时必填"},
"timeout": {"type": "integer", "default": 30},
"limit_nodes": {"type": "integer", "default": 20, "description": "target=all_nodes 时最多执行的节点数"},
},
"required": ["cluster_uuid", "command_key"],
},
},
},
]

@ -65,6 +65,12 @@ class SSHClient:
stdin, stdout, stderr = self.client.exec_command(command)
return stdout.read().decode(), stderr.read().decode()
def execute_command_with_status(self, command: str) -> tuple:
self._ensure_connected()
stdin, stdout, stderr = self.client.exec_command(command)
exit_code = stdout.channel.recv_exit_status()
return exit_code, stdout.read().decode(), stderr.read().decode()
def execute_command_with_timeout(self, command: str, timeout: int = 30) -> tuple:
"""Execute command with timeout"""
@ -72,6 +78,12 @@ class SSHClient:
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
return stdout.read().decode(), stderr.read().decode()
def execute_command_with_timeout_and_status(self, command: str, timeout: int = 30) -> tuple:
self._ensure_connected()
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
exit_code = stdout.channel.recv_exit_status()
return exit_code, stdout.read().decode(), stderr.read().decode()
def read_file(self, file_path: str) -> str:
"""Read file content from remote server"""
@ -111,16 +123,37 @@ class SSHConnectionManager:
def get_connection(self, node_name: str, ip: str = None, username: str = None, password: str = None) -> SSHClient:
"""Get or create SSH connection for a node"""
if node_name in self.connections:
client = self.connections[node_name]
if ip and getattr(client, "hostname", None) != ip:
try:
client.close()
except Exception:
pass
del self.connections[node_name]
elif username and getattr(client, "username", None) != username:
try:
client.close()
except Exception:
pass
del self.connections[node_name]
elif password and getattr(client, "password", None) != password:
try:
client.close()
except Exception:
pass
del self.connections[node_name]
if node_name not in self.connections:
if not ip:
raise ValueError(f"IP address required for new connection to {node_name}")
_user = username or DEFAULT_SSH_USER
_pass = password or DEFAULT_SSH_PASSWORD
client = SSHClient(ip, _user, _pass)
self.connections[node_name] = client
return self.connections[node_name]
def close_all(self) -> None:

@ -0,0 +1,242 @@
# AI 工具调用测试提示词(/api/v1/ai/chat
本页用于前端/测试同学对接与验证 AI 工具调用能力。所有提示词都建议通过 `/api/v1/ai/chat` 发送,并在消息里带上必要的 `cluster_uuid / node / log_type / path` 等信息,确保模型能直接触发工具调用。
## 通用前置条件
- 已能正常登录并拿到 token
- 已有至少 1 个可用集群 UUID记为 `<CLUSTER_UUID>`
- 集群已录入 NameNode/RM 的 IP 与密码(否则涉及 Namenode/RM 的命令会返回未配置)
- 当前登录用户已被映射到该集群(否则会返回 forbidden
- 如需指定节点:准备 1 个节点 hostname记为 `<NODE_HOSTNAME>`
- 如需读取具体文件:准备 1 个路径(记为 `<LOG_PATH>`
## 使用方式(推荐)
- 接口:`POST /api/v1/ai/chat`
- Body 示例(非流式):
```json
{
"sessionId": "test-ai-tools",
"message": "这里放测试提示词",
"stream": false,
"context": { "model": "可选:你的模型名" }
}
```
## 1. 故障识别detect_cluster_faults
### 1.1 默认组件namenode + resourcemanager
提示词:
> 我在集群 `<CLUSTER_UUID>` 上怀疑出现故障。请先调用 detect_cluster_faults 分析 namenode 和 resourcemanager 的最近 200 行日志,给出根因、证据行与建议。
期望:
- 模型触发 `detect_cluster_faults`,并返回 `faults` 列表(可能为空)
- 回答中包含根因推断、影响范围、证据examples与建议advice
### 1.2 指定组件列表
提示词:
> 集群 `<CLUSTER_UUID>` 的 YARN 任务提交失败。请调用 detect_cluster_faultscomponents 传入 ["resourcemanager"]lines=200输出结构化故障结论。
期望:
- tools: `detect_cluster_faults(components=["resourcemanager"])`
### 1.3 负向UUID 格式错误
提示词:
> 请对集群 `not-a-uuid` 调用 detect_cluster_faults 分析故障。
期望:
- 工具返回 `invalid_uuid_format`,模型应解释 UUID 不合法并提示正确格式
## 2. 组件日志读取read_cluster_log
### 2.1 读取 NameNode 日志
提示词:
> 读取集群 `<CLUSTER_UUID>` 的 namenode 日志最近 100 行。请调用 read_cluster_log。
期望:
- 工具返回 `status=success` 且包含 `content`(若找不到日志应给出原因 message
### 2.2 读取 ResourceManager 日志
提示词:
> 读取集群 `<CLUSTER_UUID>` 的 resourcemanager 日志最近 200 行lines=200。请调用 read_cluster_log 并把关键错误行摘出来。
期望:
- `read_cluster_log(log_type="resourcemanager", lines=200)`
### 2.3 指定节点主机名(多实例组件建议)
提示词:
> 集群 `<CLUSTER_UUID>` 的 datanode 日志我只想看 `<NODE_HOSTNAME>` 这台。请调用 read_cluster_loglog_type=datanodenode_hostname=`<NODE_HOSTNAME>`lines=120。
期望:
- `read_cluster_log` 能根据 node_hostname 定位到节点并读取匹配日志文件(可能返回 log_file_not_found但应给出解释
## 3. 单节点任意文件读取read_log
### 3.1 读取指定路径尾部
提示词:
> 在节点 `<NODE_HOSTNAME>` 上读取文件 `<LOG_PATH>` 的最后 200 行。请调用 read_log。
期望:
- 工具返回 `exitCode=0` 且 stdout 含日志内容
### 3.2 带正则筛选grep -E
提示词:
> 在节点 `<NODE_HOSTNAME>` 上读取 `<LOG_PATH>` 最后 500 行,并筛选包含 "ERROR|Exception" 的行。请调用 read_loglines=500pattern="ERROR|Exception"。
期望:
- stdout 更聚焦于错误行
### 3.3 负向:无权限节点
提示词:
> 在节点 `some_other_node` 上读取 `/var/log/messages` 最后 50 行。请调用 read_log。
期望:
- 工具返回 `node_not_found`(代表当前用户不可访问或节点不存在),模型应解释权限限制
## 4. 集群运维命令run_cluster_command白名单
说明:此工具只能执行白名单 `command_key`,无法执行任意命令字符串。
### 4.1 进程检查jps默认 all_nodes
提示词:
> 请对集群 `<CLUSTER_UUID>` 执行 jps 检查所有节点的 Java 进程run_cluster_commandcommand_key=jps。输出每个节点的关键进程。
期望:
- 返回 `results` 数组,每个元素包含 node/ip/exitCode/stdout/stderr
### 4.2 版本信息hadoop_versionnamenode
提示词:
> 我想确认集群 `<CLUSTER_UUID>` 的 Hadoop 版本。请调用 run_cluster_commandcommand_key=hadoop_version并总结版本号。
### 4.3 HDFS 总览hdfs_reportnamenode
提示词:
> 对集群 `<CLUSTER_UUID>` 执行 hdfs_report给出 DataNode 数量、总容量、已用容量的摘要。
### 4.4 SafeMode 状态hdfs_safemode_getnamenode
提示词:
> 对集群 `<CLUSTER_UUID>` 执行 hdfs_safemode_get告诉我当前是否处于 SafeMode并给出下一步建议。
### 4.5 YARN 节点yarn_node_listresourcemanager
提示词:
> 对集群 `<CLUSTER_UUID>` 执行 yarn_node_list输出节点数量并列出前 5 个节点信息。
### 4.6 YARN 应用yarn_application_listresourcemanager
提示词:
> 对集群 `<CLUSTER_UUID>` 执行 yarn_application_list给出当前 RUNNING 的应用数量和应用 ID 列表(最多 20 个)。
### 4.7 系统资源df_h / free_h / uptimeall_nodes
提示词(任选其一):
> 对集群 `<CLUSTER_UUID>` 执行 df_h汇总磁盘使用率最高的 3 台节点。
> 对集群 `<CLUSTER_UUID>` 执行 free_h汇总内存剩余最少的 3 台节点。
> 对集群 `<CLUSTER_UUID>` 执行 uptime给出平均负载最高的 3 台节点。
### 4.8 指定单节点执行target=node
提示词:
> 只在节点 `<NODE_HOSTNAME>` 上执行 df_h。请调用 run_cluster_commandcommand_key=df_htarget=nodenode_hostname=`<NODE_HOSTNAME>`。
期望:
- `results` 只有 1 条
### 4.9 负向:不支持的 command_key
提示词:
> 对集群 `<CLUSTER_UUID>` 执行 run_cluster_commandcommand_key=netstat_listen。
期望:
- 工具返回 `unsupported_command_key`,模型应解释目前只支持白名单键值
## 5. 集群启停start_cluster / stop_cluster
### 5.1 启动
提示词:
> 请启动集群 `<CLUSTER_UUID>`(调用 start_cluster。启动后再调用 run_cluster_command 的 jps 进行验证,并给出结论。
期望:
- 工具调用顺序start_cluster → run_cluster_command(jps)
### 5.2 停止
提示词:
> 请停止集群 `<CLUSTER_UUID>`(调用 stop_cluster。停止后再调用 run_cluster_command 的 jps 进行验证,并给出结论。
期望:
- 工具调用顺序stop_cluster → run_cluster_command(jps)
### 5.3 负向:集群 UUID 不存在
提示词:
> 请启动集群 `00000000-0000-0000-0000-000000000000`
期望:
- 工具返回 `cluster_not_found`,模型提示检查 UUID 与权限
## 6. 联网搜索web_search
### 6.1 查错误码/异常解释
提示词:
> 我看到报错 “StandbyException: Operation category READ is not supported in state standby”。请调用 web_search 查一下这类异常常见原因和处理方式,并结合 Hadoop 场景给出建议。
期望:
- 工具返回 resultstitle/href/body/full_content模型整合为中文结论

@ -0,0 +1,72 @@
# Hadoop 集群启动与停止接口前端联调指南
本文档提供了 Hadoop 集群启动与停止相关 API 的详细说明,用于指导前端开发人员进行接口对接。
## 1. 接口基本信息
| 功能 | 请求方法 | 接口路径 | 权限要求 |
| :--- | :--- | :--- | :--- |
| **启动集群** | `POST` | `/api/v1/ops/clusters/{cluster_uuid}/start` | `cluster:start` |
| **停止集群** | `POST` | `/api/v1/ops/clusters/{cluster_uuid}/stop` | `cluster:stop` |
- **Base URL**: `http://<server-ip>:<port>`
- **Content-Type**: `application/json`
- **认证方式**: 需要在 Header 中携带有效 JWT Token`Authorization: Bearer <your_token>`
## 2. 请求参数 (Path Parameters)
| 参数名 | 类型 | 必选 | 说明 |
| :--- | :--- | :--- | :--- |
| `cluster_uuid` | `string` | 是 | 集群的唯一标识符UUID可从 `/api/v1/clusters` 接口获取。 |
## 3. 响应结构
### 3.1 成功响应 (200 OK)
接口执行时间较长(涉及远程 SSH 指令),建议前端超时时间设置为 **60s**
```json
{
"status": "success",
"logs": [
"NameNode (192.168.1.10) start: Starting namenodes on [localhost]\nlocalhost: starting namenode...",
"ResourceManager (192.168.1.11) start: Starting resourcemanager..."
]
}
```
**字段说明:**
- `status`: 固定为 `"success"`
- `logs`: 字符串数组包含各关键组件NameNode, ResourceManager执行脚本后的标准输出与错误信息。
### 3.2 错误响应
- **401 Unauthorized**: 未提供 Token 或 Token 已失效。
- **403 Forbidden**: 权限不足(仅 `admin``ops` 角色可操作)。
- **404 Not Found**: 集群 UUID 不存在。
- **400 Bad Request**: 请求参数错误。
- `{"detail": "invalid_uuid_format"}`: 传入的 UUID 格式不正确(例如前端误传了 `[object Object]`)。
- **500 Internal Server Error**: 后端连接 SSH 超时或内部逻辑错误。
## 4. 前端联调建议
1. **Loading 状态**: 由于是长耗时操作UI 必须提供明确的加载提示,并禁用操作按钮以防重发。
2. **日志展示**: 建议将返回的 `logs` 数组内容渲染在侧边栏或弹窗的日志终端组件中。
3. **超时处理**: 请务必在 Axios 或 Fetch 配置中显式设置 `timeout: 60000`
4. **状态刷新**: 操作成功后,建议前端重新触发一次集群状态列表查询,以获取最新的 `health_status`
## 5. 代码参考 (JavaScript/Axios)
```javascript
import axios from 'axios';
const clusterApi = {
async controlCluster(uuid, action) {
// action: 'start' 或 'stop'
const response = await axios.post(`/api/v1/ops/clusters/${uuid}/${action}`, {}, {
timeout: 60000
});
return response.data;
}
};
```

@ -0,0 +1,96 @@
# Metrics 采集器前端联调指南
## 1. 概述
本文档旨在指导前端开发人员如何调用新增的 Metrics 采集器接口,实现对集群节点 CPU、内存等指标的实时持续采集。该功能通过后台线程运行每隔固定周期默认 5 秒)自动更新数据库中的节点状态。
## 2. 接口说明
所有接口均需在 Header 中携带有效的 JWT Token 进行认证。
### 2.1 启动集群采集
**接口地址**: `POST /api/v1/metrics/collectors/start-by-cluster/{cluster_uuid}`
**功能描述**: 启动指定集群下所有节点的后台采集线程。如果采集已在运行,此操作将重启采集并应用新的 `interval`
**Query 参数**:
- `interval` (int, 可选): 采集周期,单位为秒。默认为 `5`
**请求示例**:
`POST /api/v1/metrics/collectors/start-by-cluster/550e8400-e29b-41d4-a716-446655440000?interval=10`
**响应示例**:
```json
{
"ok": true,
"message": "Metrics collection started for cluster 550e8400-e29b-41d4-a716-446655440000 with interval 10s"
}
```
---
### 2.2 获取采集器状态
**接口地址**: `GET /api/v1/metrics/collectors/status`
**功能描述**: 查询当前后台采集器的运行状态,包括活跃的采集线程数、周期以及最近的错误信息。
**Query 参数**:
- `cluster` (string, 可选): 指定集群 UUID 过滤状态。
**请求示例**:
`GET /api/v1/metrics/collectors/status?cluster=550e8400-e29b-41d4-a716-446655440000`
**响应示例**:
```json
{
"is_running": true,
"active_collectors_count": 3,
"interval": 5,
"collectors": {
"node-01": "running",
"node-02": "running"
},
"errors": {
"node-03": "SSH Timeout"
}
}
```
---
### 2.3 停止集群采集
**接口地址**: `POST /api/v1/metrics/collectors/stop-by-cluster/{cluster_uuid}`
**功能描述**: 停止指定集群下所有节点的后台采集线程。
**请求示例**:
`POST /api/v1/metrics/collectors/stop-by-cluster/550e8400-e29b-41d4-a716-446655440000`
**响应示例**:
```json
{
"ok": true,
"message": "Metrics collection stopping for cluster 550e8400-e29b-41d4-a716-446655440000"
}
```
## 3. 前端集成逻辑建议
### 3.1 页面加载时同步状态
当用户进入“集群监控”或“节点列表”页面时,应先调用 `GET /api/v1/metrics/collectors/status` 接口。
- 如果 `is_running``false`,界面可以显示“启动监控”按钮。
- 如果为 `true`,界面显示“监控中”状态,并可以根据 `interval` 开启前端定时刷新(调用原有的节点数据接口获取最新值)。
### 3.2 启动监控
点击“启动监控”按钮后,调用 `start-by-cluster` 接口。成功后,前端应每隔一段时间(建议大于等于 `interval`)重新获取节点列表数据,以展示最新的 CPU 和内存使用率。
### 3.3 错误处理
如果在状态接口中发现 `errors` 字段有内容,前端应在对应的节点行或监控卡片上显示错误图标及详情(如 SSH 连接失败等)。
## 4. 注意事项
1. **权限控制**: 只有拥有该集群访问权限的用户才能操作其采集器。
2. **性能影响**: 采集器运行在后台线程,过多频繁的 SSH 轮询可能对目标节点产生轻微负载,建议 `interval` 不要小于 `2` 秒。
3. **数据一致性**: 采集器更新的是 `nodes` 表。前端展示数据时,请调用 `GET /api/v1/nodes` 相关接口获取最新字段。
---
**版本**: v1.0
**最后更新**: 2026-01-07

@ -1,9 +1,11 @@
import httpx
import asyncio
import json
import os
import pytest
async def test_register():
url = "http://localhost:8000/api/v1/user/register"
async def _run_register_checks(base_url: str):
url = f"{base_url.rstrip('/')}/api/v1/user/register"
# 1. 测试字段缺失 (422)
print("\n1. Testing missing field...")
@ -45,5 +47,12 @@ async def test_register():
print(f"Status: {r.status_code}")
print(f"Response: {r.text}")
def test_register_fix_e2e():
base_url = os.getenv("E2E_BASE_URL", "").strip()
if not base_url:
pytest.skip("需要设置 E2E_BASE_URL 并启动后端服务")
asyncio.run(_run_register_checks(base_url))
if __name__ == "__main__":
asyncio.run(test_register())
url = os.getenv("E2E_BASE_URL", "http://localhost:8000").strip()
asyncio.run(_run_register_checks(url))

@ -0,0 +1,104 @@
# 邹佳轩第 12 周学习总结
第 12 周2025-12-08 至 2025-12-14
## 本周完成情况概览
### 已完成内容
1. **PostgreSQL 环境搭建与网络配置12/8**
- 在主机 A/B 完成 PostgreSQL 安装(版本统一为 15
- 修改 `postgresql.conf` 配置 `listen_addresses='*'` 与最大连接数
- 配置 `pg_hba.conf` 开放网段访问权限,并配置防火墙放行 5432 端口
- 涉及文件:`postgresql.conf`、`pg_hba.conf`
2. **跨主机远程访问与权限控制12/9**
- 创建远程访问专用角色 `remote_user` 并设置加密密码
- 实施最小权限策略,仅授予指定模式/表的只读或读写权限
- 验证从主机 B 通过 `psql` 客户端成功连接主机 A
- 交付物:用户权限配置脚本、连接测试截图
3. **FDW外部数据包装器数据共享12/10**
- 在主机 B 启用 `postgres_fdw` 扩展
- 创建外部服务器Server与用户映射User Mapping
- 成功导入主机 A 的 `public` 模式表至主机 B 的 `foreign_public`
- 验证:在主机 B 可直接查询主机 A 的数据,性能符合预期
- 交付物FDW 配置 SQL 脚本
4. **逻辑复制增量同步12/11**
- 在主机 A发布端创建 Publication`pub_demo`
- 在主机 B订阅端创建 Subscription`sub_demo`
- 验证数据同步:在主机 A 插入数据,主机 B 毫秒级自动同步
- 交付物:逻辑复制配置文档与验证记录
5. **文档与风险控制方案12/12**
- 整理完整的《PostgreSQL 跨主机协作配置指南》
- 制定安全策略IP 白名单、密码轮换)与回滚预案(撤销 FDW/订阅)
- 涉及文档:配置变更清单、操作手册、回滚脚本
### 部分完成/待完善内容
1. 逻辑复制在大批量数据写入时的延迟监控与冲突处理机制尚需优化
2. FDW 跨大表查询的性能调优(如算子下推)还需进一步测试
3. 目前仅实现了基础的主从同步高可用故障切换Failover暂未涉及
### 各领域掌握程度评估
#### 数据库运维与管理
- 掌握状态:熟悉 PostgreSQL 配置文件与网络访问控制
- 具体表现:独立完成跨主机网络打通与权限配置
- 能力描述:具备构建安全、可访问的数据库服务环境的能力
#### 分布式数据协作技术
- 掌握状态:掌握 FDW 与逻辑复制的核心原理与实践
- 具体表现:实现跨库查询与增量数据同步
- 能力描述:能够根据业务需求选择合适的数据共享方案(联邦查询 vs 复制)
#### 系统安全与风险控制
- 掌握状态:具备基础的安全意识与预案制定能力
- 具体表现:实施最小权限原则,制定回滚与应急方案
- 能力描述:能在保障功能的前提下,有效控制系统风险
## 问题分析与反思
### 主要收益
1. 打通了跨主机数据库协作流程,为后续微服务拆分或读写分离奠定基础
2. 深入理解了 PostgreSQL 的扩展机制FDW与复制架构
3. 提升了多环境下的网络排查与配置能力
### 存在不足与改进方向
1. 对网络波动导致的复制中断缺乏自动恢复测试
2. FDW 在复杂 Join 场景下的性能表现未做深入评估
3. 监控指标(如复制延迟、连接数)尚未集成到统一监控平台
## 下周重点与计划
1. 对逻辑复制进行压力测试,模拟网络中断并验证自动恢复能力
2. 探索 PostgreSQL 的高可用方案(如 Patroni 或 Repmgr
3. 将数据库监控指标Exporter接入 Prometheus + Grafana
4. 配合后端团队,将应用层连接切换至新搭建的数据库环境进行联调
## 经验总结与启示
1. 网络配置(防火墙/监听地址)是跨主机协作的第一道坎,需细致排查
2. 权限控制应从一开始就严格遵循最小化原则,避免后期收缩困难
3. 逻辑复制相比物理复制更灵活,适合特定表的数据分发场景
## 总体评价与展望
本周按计划完成了 PostgreSQL 的跨主机环境搭建与数据协作配置,掌握了 FDW 与逻辑复制两项关键技术,并输出了完整的配置文档与安全方案。为团队后续的分布式开发与测试环境提供了有力支撑。下周将重点关注稳定性与监控,确保数据库服务的高可用。
---
**总结人**:邹佳轩
**总结时间**:第 12 周末

@ -0,0 +1,43 @@
# 邹佳轩第 13 周学习计划
第 13 周2025-12-15 至 2025-12-21
## 本周学习目标
1. **服务容器化封装**:掌握 Docker 基础,将后端、数据库等核心组件封装为容器镜像。
2. **编排环境搭建**:使用 Docker Compose 实现多服务的一键启动与网络互通。
3. **开发环境标准化**:解决团队成员本地环境不一致的问题,输出标准化配置指南。
## 详细学习任务
### 1. Docker 基础与镜像构建
- [ ] 学习 Docker 核心概念Image, Container, Registry
- [ ] 编写 `backend` 服务的 `Dockerfile`,优化构建层级以减少镜像体积。
- [ ] 编写 `database` (PostgreSQL) 与 `cache` (Redis) 的自定义镜像配置。
### 2. Docker Compose 服务编排
- [ ] 编写 `docker-compose.yml` 文件,定义服务依赖(`depends_on`)。
- [ ] 配置容器网络Bridge Network确保后端能通过服务名访问数据库。
- [ ] 配置数据卷Volumes实现数据库与日志文件的持久化存储。
- [ ] 添加健康检查Healthcheck确保依赖服务完全启动后再拉起应用。
### 3. 环境验证与文档输出
- [ ] 在 Windows 与 Linux 环境下分别测试一键启动脚本。
- [ ] 验证容器内 DNS 解析与端口映射是否正常。
- [ ] 编写《本地容器化开发环境搭建指南》,指导团队成员迁移环境。
## 预估产出物
1. 后端与数据库服务的 `Dockerfile`
2. 完整的 `docker-compose.yml` 编排文件。
3. 环境启动脚本与搭建文档。
## 重点难点分析
- **难点**:容器网络互通与文件权限映射(尤其是在 Windows 主机上)。
- **对策**:深入阅读 Docker 网络文档,使用 WSL2 环境进行调试。
---
**计划人**:邹佳轩
**制定时间**:第 13 周初

@ -0,0 +1,70 @@
# 邹佳轩第 13 周学习总结
第 13 周2025-12-15 至 2025-12-21
## 本周完成情况概览
### 已完成内容
1. **服务容器化封装12/15**
- 编写后端服务、数据库与缓存组件的 `Dockerfile`
- 优化镜像构建分层,利用多阶段构建减少镜像体积(从 800MB 优化至 200MB+
- 涉及文件:`backend/Dockerfile`、`database/Dockerfile`
2. **Docker Compose 编排环境12/16**
- 编写 `docker-compose.yml`,定义服务依赖关系与网络拓扑
- 实现 `backend`、`postgres`、`redis` 与 `flume` 的一键启停
- 配置服务健康检查Healthcheck确保依赖服务就绪后再启动应用
- 交付物:完整的 `docker-compose.yml` 与环境启动脚本
3. **容器数据卷与网络管理12/17**
- 配置持久化数据卷Volume保障数据库与日志数据不随容器销毁而丢失
- 划分内部网络(`backend-net`)与外部网络,隔离数据层与访问层
- 验证容器间 DNS 解析与服务互通性
4. **开发环境标准化12/18**
- 统一团队开发环境配置,通过 `.env` 文件管理环境变量
- 解决 Windows/Linux 跨平台文件路径与权限差异问题
- 输出《本地容器化开发环境搭建指南》
### 部分完成/待完善内容
1. K8s 部署清单Manifests仅完成初步草案尚未在 Minikube/Kind 环境完整验证
2. 容器日志尚未统一收集到 Flume目前仍依赖 `docker logs` 查看
### 各领域掌握程度评估
#### 容器技术
- 掌握状态:熟练掌握 Docker 构建与 Compose 编排
- 具体表现:独立完成多服务环境的封装与网络配置
- 能力描述:具备将传统应用迁移至容器化架构的能力
#### 开发运维DevOps
- 掌握状态:初步建立“配置即代码”的意识
- 具体表现:通过 Compose 文件固化运行环境,消除环境差异
- 能力描述:能够为团队提供标准化的开发基础设施
## 问题分析与反思
### 主要收益
1. 彻底解决了“在我机器上能跑”的环境一致性问题
2. 新成员接入成本大幅降低,一条命令即可拉起完整后端环境
3. 通过容器化隔离,避免了本地依赖冲突
### 存在不足与改进方向
1. 镜像构建速度较慢,需引入构建缓存或国内镜像源优化
2. 容器资源限制CPU/Memory未做精细化配置可能导致 OOM
3. 缺乏容器内部的监控手段
## 下周重点与计划
1. 引入 Prometheus + Grafana搭建容器与数据库监控体系
2. 研究 PostgreSQL 的高可用架构(如 Patroni并在容器环境中模拟故障切换
3. 优化镜像构建流水线,尝试接入 CI/CD 流程

@ -0,0 +1,42 @@
# 邹佳轩第 14 周学习计划
第 14 周2025-12-22 至 2025-12-28
## 本周学习目标
1. **全栈监控体系搭建**:引入 Prometheus + Grafana实现对容器、主机及中间件的全面监控。
2. **可视化仪表盘配置**:定制数据库性能与系统资源大屏,实现核心指标的可视化。
3. **数据库高可用探索**:研究并验证 PostgreSQL 的主从复制与故障自动切换方案。
## 详细学习任务
### 1. 监控基础设施部署
- [ ] 部署 Prometheus Server 与 Grafana 容器,并挂载持久化存储。
- [ ] 配置 `node_exporter` 采集主机硬件指标CPU/Mem/Disk
- [ ] 配置 `cadvisor` 采集 Docker 容器的实时资源使用情况。
### 2. 应用与中间件监控
- [ ] 接入 `postgres_exporter`,采集数据库连接数、缓存命中率、死锁等关键指标。
- [ ] 接入 `redis_exporter`,监控缓存服务的吞吐量与延迟。
- [ ] 在 Grafana 中导入并优化社区标准的 Dashboard 模板。
### 3. 高可用架构演练
- [ ] 调研 PostgreSQL 高可用方案Patroni / Repmgr
- [ ] 搭建一主一从Primary-Standby的数据库集群。
- [ ] 模拟主节点宕机场景验证从节点的自动提升Failover流程。
## 预估产出物
1. 监控服务全套 Compose 配置(含 Exporters
2. Grafana 监控大屏 JSON 导出文件。
3. 《PostgreSQL 高可用方案选型与测试报告》。
## 重点难点分析
- **难点**:在容器网络环境下实现 VIP虚拟 IP漂移或客户端自动重连。
- **对策**重点测试驱动层JDBC/Psycopg2的连接参数配置确保应用能感知主从切换。
---
**计划人**:邹佳轩
**制定时间**:第 14 周初

@ -0,0 +1,72 @@
# 邹佳轩第 14 周学习总结
第 14 周2025-12-22 至 2025-12-28
## 本周完成情况概览
### 已完成内容
1. **监控体系搭建12/22**
- 部署 Prometheus 与 Grafana 容器,配置持久化存储
- 集成 `node_exporter` 采集主机指标,`cadvisor` 采集容器资源指标
- 接入 `postgres_exporter``redis_exporter`,实现中间件深度监控
- 交付物:监控服务 Compose 配置、Grafana 数据源配置
2. **可视化仪表盘配置12/23**
- 导入并定制 PostgreSQL 性能监控大屏QPS、连接数、缓存命中率、死锁
- 搭建系统资源概览面板,实时展示 CPU、内存、磁盘 I/O 水位
- 配置基础告警规则(如 CPU > 80%),验证告警触发与恢复
- 涉及文件Grafana Dashboards JSON 导出文件
3. **数据库高可用探索12/24**
- 调研 PostgreSQL 高可用方案Patroni vs Repmgr vs Pgpool-II
- 在测试环境搭建基于 Repmgr 的主从复制集群
- 模拟主节点宕机验证从节点自动提升Failover流程
- 输出《PostgreSQL 高可用方案选型与测试报告》
4. **日志与监控联动12/25**
- 配合 Flume 团队,将容器标准输出日志重定向至日志收集端
- 尝试在 Grafana 中集成 Loki轻量级日志系统实现指标与日志的同屏关联
### 部分完成/待完善内容
1. 高可用方案的 VIP虚拟 IP漂移在容器网络中实现较复杂目前仍依赖客户端重连
2. 告警通知渠道目前仅支持邮件,尚未接入钉钉/企业微信 Webhook
3. Flume 自身的监控指标尚未完全接入 Prometheus
### 各领域掌握程度评估
#### 可观测性Observability
- 掌握状态:掌握监控数据的采集、存储与可视化链路
- 具体表现:独立搭建全栈监控平台,覆盖基础设施与中间件
- 能力描述:具备构建系统级“仪表盘”的能力,能通过指标快速发现隐患
#### 数据库高可用
- 掌握状态:理解主从复制与故障转移的核心原理
- 具体表现:成功搭建并验证主从切换流程
- 能力描述:能够设计具备一定容灾能力的数据库架构
## 问题分析与反思
### 主要收益
1. 系统运行状态透明化,不再“盲人摸象”
2. 通过压力测试配合监控观察,发现了数据库连接池配置过小的瓶颈
3. 高可用演练暴露了应用层重连机制的缺陷,倒逼代码优化
### 存在不足与改进方向
1. 监控数据保留策略未配置,长期运行可能占用过多磁盘
2. 告警规则存在误报Flapping需优化阈值与持续时间窗口
3. 高可用架构增加了运维复杂度,需编写自动化维护脚本
## 下周重点与计划
1. 深入研究 AI Agent 技术栈,部署本地大语言模型(如 Llama/Qwen
2. 探索 LangChain 框架尝试构建基于文档的问答助手RAG
3. 配合后端开发,设计 AI 诊断接口,实现“日志 -> 诊断建议”的初步闭环

@ -0,0 +1,42 @@
# 邹佳轩第 15 周学习计划
第 15 周2025-12-29 至 2026-01-04
## 本周学习目标
1. **本地 LLM 环境部署**:搭建私有化大模型推理环境,摆脱对公网 API 的依赖。
2. **AI 诊断接口开发**:基于 FastAPI 开发故障诊断服务,实现 Prompt 工程化。
3. **RAG 技术探索**:引入检索增强生成技术,利用项目文档提升 AI 诊断的准确性。
## 详细学习任务
### 1. 模型选型与部署
- [ ] 调研开源大模型Qwen2.5, Llama3选择适合本地显存<16GB
- [ ] 使用 Ollama 或 vLLM 部署推理服务,并开启兼容 OpenAI 的 API 接口。
- [ ] 测试不同量化精度4bit vs 8bit下的推理速度与生成质量。
### 2. AI 业务逻辑开发
- [ ] 设计针对 Hadoop 故障诊断的 System Prompt规范输出格式。
- [ ] 开发后端接口,实现“接收日志 -> 组装 Prompt -> 调用 LLM -> 流式返回”的完整链路。
- [ ] 优化前端交互,支持 SSEServer-Sent Events打字机效果。
### 3. 知识库增强 (RAG)
- [ ] 收集 Hadoop 官方文档与历史故障案例,进行文本清洗与分块。
- [ ] 使用 Embedding 模型将文本向量化,并存入 ChromaDB 向量数据库。
- [ ] 实现“检索 + 生成”流程,将相关知识作为上下文注入 Prompt。
## 预估产出物
1. 本地 LLM 启动脚本与性能测试报告。
2. 集成了 AI 诊断功能的后端代码模块。
3. 典型故障诊断案例演示视频。
## 重点难点分析
- **难点**Prompt 的调优Prompt Engineering如何让模型准确输出结构化建议而非闲聊。
- **对策**:建立 Prompt 版本库,通过大量真实日志样本进行迭代测试。
---
**计划人**:邹佳轩
**制定时间**:第 15 周初

@ -0,0 +1,80 @@
# 邹佳轩第 15 周学习总结
第 15 周2025-12-29 至 2026-01-04
## 本周完成情况概览
### 已完成内容
1. **本地 LLM 环境部署12/29**
- 使用 Ollama 部署 Qwen2.5-Coder 与 Llama3 模型
- 搭建 vLLM 推理服务,提供兼容 OpenAI 格式的 API 接口
- 对比不同量化版本4bit/8bit在本地显存下的推理速度与效果
- 交付物:本地 LLM 启动脚本与性能测试报告
2. **AI Agent 接口开发12/30**
- 基于 FastAPI 封装 AI 诊断服务,对接 vLLM 接口
- 设计 Prompt 模板,包含“角色设定+上下文(日志)+任务指令+输出格式”
- 实现流式输出SSE提升前端用户体验
- 涉及文件:`backend/app/routers/ai.py`、`backend/app/services/llm.py`
3. **故障诊断功能初步跑通01/01**
- 联调“日志采集 -> 存入数据库 -> 读取日志 -> 发送给 AI -> 返回诊断”全链路
- 针对 Hadoop 常见报错(如 DataNode 丢失、SafeMode优化 Prompt
- 验证 AI 给出的修复建议准确性,并进行人工微调
- 交付物:故障诊断演示视频、典型案例 Prompt 库
4. **RAG检索增强生成探索01/02**
- 尝试将 Hadoop 官方文档与过往故障知识库向量化(使用 ChromaDB
- 在诊断流程中引入知识库检索,减少模型幻觉
- 验证发现 RAG 能显著提升对特定版本配置参数建议的准确度
### 部分完成/待完善内容
1. Agent 目前仅能给出建议,尚未实现“自动执行修复命令”的功能(需 MCP 支持)
2. 多轮对话上下文管理尚简陋,长对话可能丢失早期信息
3. 推理延迟在并发请求下较高,需设计请求队列或限流机制
### 各领域掌握程度评估
#### 大模型应用开发LLM Ops
- 掌握状态:熟悉 Prompt 工程与本地模型部署接口
- 具体表现:成功将通用大模型转化为特定领域的故障诊断助手
- 能力描述:具备开发基于 LLM 的垂直应用的能力
#### AI 后端集成
- 掌握状态:掌握 SSE 流式传输与异步推理调用
- 具体表现:实现了低延迟的 AI 交互接口
- 能力描述:能够解决 AI 模型高延迟与 Web 实时交互之间的矛盾
## 问题分析与反思
### 主要收益
1. 项目核心亮点“AI 故障诊断”终于落地,形成了差异化竞争力
2. 深刻体会了 Prompt Quality 对输出结果的决定性影响
3. 掌握了本地部署大模型的低成本方案,摆脱了对昂贵商业 API 的依赖
### 存在不足与改进方向
1. 模型对长日志(超过 Context Window的处理仍需截断可能丢失关键信息
2. 缺乏对 AI 输出结果的自动评估机制Eval依赖人工主观判断
3. 知识库构建尚未自动化,文档更新滞后
## 下周重点与计划
1. 进行全系统集成测试,模拟从 Hadoop 故障发生到 AI 给出诊断的完整闭环
2. 配合前端优化 AI 对话界面,支持 Markdown 渲染与代码高亮
3. 整理项目文档,准备最终验收答辩材料
4. 编写系统部署手册,确保在全新环境下可一键复现
## 总体评价与展望
本周实现了 AI 技术的赋能,将项目的技术含量提升了一个台阶。虽然目前的 Agent 还比较初级,但已经展现出了强大的辅助运维潜力。下周将进入最后的冲刺阶段,聚焦于系统的整体打磨与交付。
---
**总结人**:邹佳轩
**总结时间**:第 15 周末

@ -0,0 +1,42 @@
# 邹佳轩第 16 周学习计划
第 16 周2026-01-05 至 2026-01-11
## 本周学习目标
1. **全系统集成测试**:打通 Hadoop、Flume、Backend、AI 与 Frontend 的全链路,验证系统整体功能。
2. **性能优化与 Bug 修复**:解决高并发下的性能瓶颈,修复测试中发现的关键缺陷。
3. **项目交付与验收**:整理最终交付文档,录制演示视频,准备答辩材料。
## 详细学习任务
### 1. 端到端集成测试 (E2E)
- [ ] 设计完整的测试用例:从 Hadoop 节点注入故障开始,到前端显示 AI 诊断结果结束。
- [ ] 组织全员进行压力测试,观察数据库连接池与 Docker 容器资源的稳定性。
- [ ] 验证系统在异常情况(如网络中断、服务宕机)下的恢复能力。
### 2. 系统优化
- [ ] 分析数据库慢查询日志,对高频查询字段添加索引。
- [ ] 调整 Docker 容器的资源限制CPU/Memory防止 OOM。
- [ ] 优化前端静态资源加载策略Gzip 压缩、浏览器缓存)。
### 3. 文档整理与交付
- [ ] 编写《系统部署与运维手册》,确保第三方可在新环境一键部署。
- [ ] 整理 API 文档、数据库设计文档与测试报告。
- [ ] 制作项目演示 PPT 与功能演示视频,准备最终答辩。
## 预估产出物
1. 集成测试报告与 Bug 修复清单。
2. 完整的项目交付文档包部署手册、API 文档等)。
3. 项目最终源码包Release Version
## 重点难点分析
- **难点**多组件联动下的故障定位Distributed Tracing
- **对策**:利用之前搭建的 Grafana 监控大屏,结合日志时间戳进行全链路排查。
---
**计划人**:邹佳轩
**制定时间**:第 16 周初

@ -0,0 +1,61 @@
# 邹佳轩第 16 周学习总结
第 16 周2026-01-05 至 2026-01-11
## 本周完成情况概览
### 已完成内容
1. **全系统集成测试01/05**
- 组织全员进行端到端E2E测试Hadoop 故障注入 -> Flume 采集 -> Backend 存储 -> AI 诊断 -> 前端展示
- 修复了 Docker 网络互通、数据库连接超时、AI 响应格式解析错误等 5 个关键 Bug
- 验证了系统在 50+ 并发用户下的稳定性,数据库与后端服务未见异常
- 交付物集成测试报告、Bug 修复清单
2. **系统性能优化01/06**
- 优化 PostgreSQL 索引,将日志查询耗时从 500ms 降低至 50ms
- 调整 Docker 容器资源配额,防止 Java 进程 OOM
- 为前端静态资源配置 Nginx 缓存与 Gzip 压缩,首屏加载速度提升 40%
### 部分完成/待完善内容
1. 部分边缘测试用例(如网络极端抖动)覆盖不足
2. 代码注释率虽然达标,但部分复杂逻辑的文档说明仍显单薄
### 各领域掌握程度评估
#### 全栈系统架构
- 掌握状态:具备从底层设施到上层应用的全链路视野
- 具体表现能够定位跨组件Frontend-Backend-DB-AI-Infra的复杂问题
- 能力描述:拥有独立设计并交付中型分布式系统的能力
#### 项目交付与质量管理
- 掌握状态:熟悉软件交付生命周期的最后“一公里”
- 具体表现:产出高质量的文档与经过验证的软件包
- 能力描述:能够按照工程标准完成软件交付
## 问题分析与反思
### 主要收益
1. 完成了从 0 到 1 的全过程技术栈覆盖面广BigData + Web + AI + Ops
2. 容器化与 AI 的结合实践极具前瞻性,积累了宝贵经验
3. 团队协作默契度在最后冲刺阶段达到顶峰
### 存在不足与改进方向
1. 前期需求分析不够细致,导致后期部分功能返工(如日志格式统一)
2. 测试自动化程度不高,回归测试依赖人工,效率较低
3. 对 Hadoop 底层原理的理解仍停留在运维层面,源码级掌握不足
## 下周重点与计划
1. **项目结项**:正式提交所有代码与文档
2. **成果展示**:进行课程/项目答辩
3. **后续维护**:如有需要,修复验收过程中发现的非阻断性 Bug

@ -0,0 +1,64 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

@ -0,0 +1,94 @@
# AI 暗黑模式 (Dark Mode) 迁移指南
本文档为 AI 助手设计,旨在通过结构化的步骤将 `frontend-vue` 项目从硬编码配色迁移至支持响应式切换的暗黑模式架构。
---
## 1. 核心任务目标
将项目中分散在各个 `.vue` 文件 `<style>` 块中的硬编码颜色Hex/RGB统一提取为 CSS 变量,并基于 `.dark` 类名实现暗黑主题覆盖。
---
## 2. 语义化变量映射表 (Context for AI)
在迁移时,请遵循以下映射逻辑:
| 语义类别 | 浅色模式 (当前值) | 暗黑模式 (目标值) | CSS 变量名 |
| :--- | :--- | :--- | :--- |
| 品牌主色 | `#0ea5e9` | `#0ea5e9` | `--el-color-primary` |
| 页面背景 | `#f0f9ff` | `#0f172a` | `--app-bg` |
| 内容区背景 | `#f8fafc` | `#1e293b` | `--app-content-bg` |
| 卡片/容器背景 | `#ffffff` | `#1e293b` | `--app-card-bg` |
| 顶部导航背景 | `#ffffff` | `#0f172a` | `--app-header-bg` |
| 主要文字 | `#1f2937` | `#f1f5f9` | `--app-text-primary` |
| 次要文字 | `#6b7280` | `#94a3b8` | `--app-text-secondary` |
| 边框颜色 | `#e2e8f0` | `#334155` | `--app-border-color` |
---
## 3. 实施路径 (Implementation Steps)
### 第一步:全局变量定义
在 [App.vue](file:///home/devbox/project/frontend-vue/src/app/App.vue) 或新建 `src/app/styles/vars.css` 中定义基础变量:
```css
:root {
--app-bg: #f0f9ff;
--app-content-bg: #f8fafc;
--app-card-bg: #ffffff;
--app-header-bg: #ffffff;
--app-text-primary: #1f2937;
--app-text-secondary: #6b7280;
--app-border-color: #e2e8f0;
}
html.dark {
--app-bg: #0f172a;
--app-content-bg: #1e293b;
--app-card-bg: #1e293b;
--app-header-bg: #0f172a;
--app-text-primary: #f1f5f9;
--app-text-secondary: #94a3b8;
--app-border-color: #334155;
}
```
### 第二步Element Plus 适配
在 [main.ts](file:///home/devbox/project/frontend-vue/src/app/main.ts) 中引入官方暗黑样式:
```typescript
import 'element-plus/theme-chalk/dark/css-vars.css'
```
### 第三步:状态切换逻辑
在 [ui.ts](file:///home/devbox/project/frontend-vue/src/app/stores/ui.ts) 中集成 `@vueuse/core`
```typescript
import { useDark, useToggle } from '@vueuse/core'
export const useUIStore = defineStore('ui', {
state: () => ({
isDark: useDark(),
// ... 其他状态
}),
actions: {
toggleTheme() {
const toggle = useToggle(this.isDark)
toggle()
}
}
})
```
### 第四步:清理硬编码颜色 (重点)
遍历以下关键文件,将 hardcoded color 替换为变量:
- **[App.vue](file:///home/devbox/project/frontend-vue/src/app/App.vue)**: 替换 `body``background-color`
- **[Diagnosis.vue](file:///home/devbox/project/frontend-vue/src/app/views/Diagnosis.vue)**: 替换所有 `#1f2937`、`#6b7280` 和背景色。
- **[Sidebar.vue](file:///home/devbox/project/frontend-vue/src/app/components/Sidebar.vue)**: 确保侧边栏在暗黑模式下不再使用固定的 `#001529`
---
## 4. AI 迁移注意事项 (Constraint)
1. **不要遗漏 ECharts**: 监控图表的 `theme` 必须随 `uiStore.isDark` 动态切换。
2. **渐变色处理**: [Login.vue](file:///home/devbox/project/frontend-vue/src/app/views/Login.vue) 中的线性渐变在暗黑模式下需要调整为更深邃的色调。
3. **保持原子性**: 每次修改一个组件的颜色,并确保该组件在双主题下均渲染正常。
4. **优先使用 Element Plus 变量**: 如果能用 `--el-fill-color` 等官方变量,则优先使用。

@ -0,0 +1,62 @@
# UI E2E本地与 CI 稳定运行指南)
本项目使用 Playwright 进行端到端测试。
## 指令总览
`frontend-vue/` 目录执行:
```bash
pnpm run e2e
```
```bash
pnpm run e2e:ui
```
## CI 如何运行
CI 会自动安装浏览器与系统依赖后运行 UI E2E并在失败时上传报告与 trace。
## 本地运行(推荐顺序)
### 方案 A原生运行Linux
首次运行需要安装 Playwright 的 Chromium 及系统依赖:
```bash
pnpm run e2e:ui:install
```
之后直接运行:
```bash
pnpm run e2e:ui
```
### 方案 BDocker 运行(最稳定)
本地没有系统依赖、或者安装依赖很慢时,推荐使用 DockerPlaywright 官方镜像已内置依赖):
```bash
pnpm run e2e:ui:docker
```
可通过环境变量指定镜像版本:
```bash
PLAYWRIGHT_IMAGE=mcr.microsoft.com/playwright:v1.57.0-jammy pnpm run e2e:ui:docker
```
## 产物与排查
UI E2E 失败后会生成:
- `frontend-vue/playwright-report/`HTML 报告)
- `frontend-vue/test-results/`trace / video / screenshot
本地查看报告:
```bash
pnpm exec playwright show-report playwright-report
```

@ -0,0 +1,154 @@
# 🚀 前端项目开发与架构指南 (零基础全通关版)
> **写给小组伙伴们**无论你是产品经理、UI 设计师、测试工程师还是运维同事,这份文档都是为你量身定制的。我们的目标是:看完这份文档,你能像开发者一样理解这个项目的每一个转轴是如何转动的。
---
## 目录
1. [🌍 宏观视角:网页是怎么跑起来的?](#1-宏观视角网页是怎么跑起来的)
2. [📦 我们的工具箱:技术名词大白话](#2-我们的工具箱技术名词大白话)
3. [🗺️ 职责地图:屏幕上的内容在哪里改?](#3-职责地图屏幕上的内容在哪里改)
4. [🔄 业务全流程:从点击到结果发生了什么?](#4-业务全流程从点击到结果发生了什么)
5. [<EFBFBD> 核心功能细节深挖 (必读)](#5-核心功能细节深挖-必读)
6. [<EFBFBD>🛠 非开发者调试秘籍:如何优雅地提 Bug](#6-非开发者调试秘籍如何优雅地提-bug)
7. [🎨 协作指南:不同角色如何参与?](#7-协作指南不同角色如何参与)
---
## 1. 🌍 宏观视角:网页是怎么跑起来的?
想象你在网上订餐:
1. **你(浏览器)**:输入网址,发出“我想看网页”的请求。
2. **快递员(网络/Vite**:把我们写好的代码打包成一个个小包裹,送到你的浏览器里。
3. **装修队(前端代码)**:浏览器收到包裹后,按照代码的指示,把文字、图片、按钮摆好。
4. **服务员API 接口)**你想看集群数据时前端会问后端要数据后端把数据装在盒子里JSON 格式)传回来,前端再把它拆开摆在表格里。
---
## 2. 📦 我们的工具箱:技术名词大白话
| 技术名词 | 它的角色 | 形象理解 |
| :--------------- | :------- | :----------------------------------------------------------------------------------- | ------------------------------------------------ |
| **Vue 3** | 核心框架 | **乐高底板**:所有的组件(方块)都插在它上面,它负责感应方块的变化并自动更新。 |
| **TypeScript** | 语言规范 | **施工监理**:在盖房子前检查每个零件的尺寸,尺寸不对(类型错误)直接停工,避免塌方。 |
| **Element Plus** | 组件库 | **精装房样板间**:提供现成的马桶、洗手盆(按钮、输入框),我们直接搬来用。 |
| **Pinia** | 状态管理 | **全家共享记事本** | 记录用户是谁、有没有登录,所有页面都能随时翻看。 |
| **Vite** | 构建工具 | **极速快递流水线** | 负责把我们的源代码瞬间变成浏览器能跑的程序。 |
---
## 3. 🗺️ 职责地图:屏幕上的内容在哪里改?
### A. 全局大框架 (外壳)
- **侧边栏 (导航菜单)**: [Sidebar.vue](file:///home/devbox/project/frontend-vue/src/app/components/Sidebar.vue) —— 负责切换页面。
- **顶部条 (面包屑/用户头像)**: [HeaderNav.vue](file:///home/devbox/project/frontend-vue/src/app/components/HeaderNav.vue) —— 负责显示你当前在哪里。
- **背景与整体布局**: [App.vue](file:///home/devbox/project/frontend-vue/src/app/App.vue) —— 负责整体的“骨架”。
### B. 具体的业务内容 (内页)
- **登录/注册**: [Login.vue](file:///home/devbox/project/frontend-vue/src/app/views/Login.vue) —— 处理“我是谁”的问题。
- **智能诊断区**: [Diagnosis.vue](file:///home/devbox/project/frontend-vue/src/app/views/Diagnosis.vue) —— 核心功能,左边选节点,右边聊天。
- **数据大屏**: [Dashboard.vue](file:///home/devbox/project/frontend-vue/src/app/views/Dashboard.vue) —— 满屏图表的地方。
### C. 视觉皮肤 (样式)
- **配色与间距**: [theme.scss](file:///home/devbox/project/frontend-vue/src/app/styles/theme.scss) —— 如果觉得蓝色太浅了,改这里。
---
## 4. 🔄 业务全流程:从点击到结果发生了什么?
以**“用户在 AI 诊断助手里发了一句话”**为例:
1. **触发**: 用户在 [DiagnosisChatPanel.vue](file:///home/devbox/project/frontend-vue/src/app/components/DiagnosisChatPanel.vue) 输入框打字并点发送。
2. **记录**: 前端先在自己的“小本子”里记下这句话,并立刻显示在屏幕上(让你觉得反应很快)。
3. **求助**: 前端通过 [diagnosis.service.ts](file:///home/devbox/project/frontend-vue/src/app/api/diagnosis.service.ts) 向后端发送请求。
4. **等待**: 后端 AI 开始思考,通过 **SSE (流式传输)** 把字一个一个吐回来。
5. **渲染**: 前端收到一个字,就更新一次屏幕,形成“打字机”效果。
---
## 5. 💎 核心功能细节深挖 (必读)
### 5.1 智能诊断助手的“大脑开关”
在 [DiagnosisChatPanel.vue](file:///home/devbox/project/frontend-vue/src/app/components/DiagnosisChatPanel.vue) 中,有几个关键细节:
- **模型切换**: 用户可以选择 DeepSeek-V3 或 R1。R1 模型会额外多出一个“推理过程”展示。
- **搜索与操作开关**: 这是给 AI 插件的指令。如果勾选了“搜索”AI 就会去联网查资料如果勾选了“操作”AI 就能直接执行集群修复命令。
- **深度诊断**: 这不是简单的聊天,它会触发后端一套复杂的“健康检查”工作流。
### 5.2 身份识别与“自动续期”
我们在 [api.ts](file:///home/devbox/project/frontend-vue/src/app/lib/api.ts) 里做了一件非常聪明的事:
- **身份证 (Token)**: 你登录后,后端会给你一张通行证。
- **悄悄换新**: 通行证通常只有几小时有效期。为了不让用户在操作时突然被踢出去,我们在通行证快过期时,会背着用户悄悄去后端换一张新的。这个过程用户完全感知不到,这就是“无感刷新”。
### 5.3 响应式布局的“伸缩魔术”
在 [Diagnosis.vue](file:///home/devbox/project/frontend-vue/src/app/views/Diagnosis.vue) 中:
- **可拖拽分栏**: 页面左侧选节点,右侧聊天,中间的分割线是可以左右拖动的。这是由 [SplitPane.vue](file:///home/devbox/project/frontend-vue/src/app/components/SplitPane.vue) 组件控制的。
- **手机模式**: 当屏幕宽度变窄时,左右分栏会变成上下分栏,侧边栏会自动藏起来,变成一个可以点击弹出的“汉堡菜单”。
### 5.4 权限的“分级可见”
我们在 [router/index.ts](file:///home/devbox/project/frontend-vue/src/app/router/index.ts) 里规定了谁能看什么:
- **Admin (管理员)**: 能看到左侧菜单底部的“用户管理”和“操作日志”。
- **Operator (操作员)**: 只能看到诊断和集群列表。
- **如果强行输入网址**: 比如一个普通用户强行在地址栏输入 `/user-management`,系统会自动把他弹回首页,确保安全。
---
## 6. 🛠️ 非开发者调试秘籍:如何优雅地提 Bug
当页面出问题时,你可以打开浏览器的“开发者工具”(按 F12
1. **网络 (Network) 选项卡**:
- 看到**红色的请求**?那是接口挂了,找后端同学。
- 请求耗时太长(几千毫秒)?那是服务器太慢了。
2. **控制台 (Console) 选项卡**:
- 满屏红色的英文报错?那是前端代码有 Bug截图发给开发。
3. **应用 (Application) -> Local Storage**:
- 这里存着你的身份信息。如果登录死活不进去试着右键点击“Clear”清空这里再刷新。
---
## 7. 🎨 协作指南:不同角色如何参与?
### 🙋 产品经理 (PM)
- **关注点**: 页面文案是否正确?逻辑是否顺畅?
- **如何改**: 所有的文案都在 [views/](file:///home/devbox/project/frontend-vue/src/app/views/) 的 `.vue` 文件里的 `<template>` 标签内。
### 🎨 设计师 (UI/UX)
- **关注点**: 颜色、圆角、阴影是否对齐设计稿?
- **如何改**: 检查 [theme.scss](file:///home/devbox/project/frontend-vue/src/app/styles/theme.scss) 中的变量。我们使用了 CSS 变量,改一处,全站变。
### 🛡️ 测试工程师 (QA)
- **关注点**: 异常流程是否处理?(比如断网了会显示什么?)
- **工具**: 我们的自动化测试脚本在 [e2e/](file:///home/devbox/project/frontend-vue/e2e/)。你可以看这里的脚本来理解业务路径。
---
## 8. 常见问题 (FAQ)
**Q: 为什么我改了代码界面没变化?**
A: 检查你是否保存了文件,或者 Vite 的热更新是否因为报错停掉了(看命令行窗口)。
**Q: 为什么 AI 说话断断续续的?**
A: 这是正常的!这就是“流式传输”,为了让你尽快看到内容。
**Q: 为什么有些页面我进去是空白?**
A: 很有可能是你的角色Role权限不够或者后端接口在这个环境没部署。
---
> **结语**:前端不是黑盒,它是由逻辑、样式和数据搭建起来的乐高城堡。希望这份指南能帮你成为城堡的共同建设者!

@ -0,0 +1,56 @@
# 测试命令清单frontend-vue
以下命令均在 `frontend-vue/` 目录下执行(已使用 pnpm scripts 封装,见 [package.json](file:///home/devbox/project/frontend-vue/package.json))。
## 单元测试Vitest
```bash
pnpm test
```
监听模式:
```bash
pnpm run test:watch
```
## 质量门禁(建议提 PR 前本地跑一遍)
代码规范检查:
```bash
pnpm run lint
```
TypeScript 类型检查:
```bash
pnpm run typecheck
```
生产构建验证:
```bash
pnpm run build
```
一键跑完lint + typecheck + test + build
```bash
pnpm run lint && pnpm run typecheck && pnpm test && pnpm run build
```
## 端到端测试Playwright
API 级 smoke不依赖浏览器依赖
```bash
pnpm run e2e
```
UI 级端到端(需要系统安装 Playwright 浏览器依赖CI 会自动安装):
```bash
pnpm run e2e:ui
```

@ -0,0 +1,123 @@
# 视觉体验与个性化提升指南
本文档旨在指导开发者如何通过技术手段提升管理系统的视觉表现力、交互流畅度以及个性化体验。
---
## 1. 暗黑模式 (Dark Mode)
由于系统涉及大量监控指标和日志,支持暗黑模式可以显著降低用户视觉疲劳。
### 实现步骤
1. **安装依赖**:
```bash
pnpm add @vueuse/core
```
2. **逻辑集成**:
`src/app/stores/ui.ts` 中添加切换逻辑:
```typescript
import { useDark, useToggle } from "@vueuse/core";
const isDark = useDark();
const toggleDark = useToggle(isDark);
```
3. **Element Plus 适配**:
`main.ts` 中引入暗黑模式样式:
```typescript
import "element-plus/theme-chalk/dark/css-vars.css";
```
### AI 实施建议
- 检查 `html` 标签是否包含 `dark` 类名。
- 确保 ECharts 图表的主题随系统自动切换(使用 `echarts.registerTheme` 或动态切换 `init` 时的主题参数)。
---
## 2. 微交互与动画 (Micro-interactions)
微小的动画反馈能让应用显得更“轻量”且“智能”。
### 页面过渡动画
`App.vue` 中为路由出口包裹 `transition`
```html
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
```
### 加载体验 (Skeleton Screens)
在 [Dashboard.vue](file:///home/devbox/project/frontend-vue/src/app/views/Dashboard.vue) 中,使用 `el-skeleton` 代替传统的 Loading
```html
<el-skeleton :loading="loading" animated>
<template #template>
<el-skeleton-item variant="rect" style="height: 240px" />
</template>
<template #default>
<cpu-chart />
</template>
</el-skeleton>
```
---
## 3. 响应式布局精细化
利用已有的 [useResponsive.ts](file:///home/devbox/project/frontend-vue/src/app/composables/useResponsive.ts) 进行深度适配。
### 策略
- **侧边栏自动收起**: 当屏幕宽度 `< 992px` 时,通过 `uiStore` 自动将侧边栏设为 `collapsed` 状态。
- **数据卡片堆叠**: 在移动端将 `el-row/el-col``span` 设置为 24确保图表和列表全屏展示。
---
## 4. AI 诊断交互增强 (UX for AI)
[Diagnosis.vue](file:///home/devbox/project/frontend-vue/src/app/views/Diagnosis.vue) 是项目的核心竞争力,需重点优化。
### 关键点
- **流式打字机**: 使用 `requestAnimationFrame` 优化字符渲染速度,避免长文本导致的页面闪烁。
- **自动锚点**: 当 AI 正在回复时,对话框应保持自动滚动到底部,除非用户手动向上滚动。
- **代码块增强**: 集成 `prismjs``highlight.js`,并为代码块添加“一键复制”按钮。
---
## 5. 主题定制 (Theme Customization)
通过 CSS 变量实现“一键换肤”。
### 实施方案
你可以直接在 [App.vue](file:///home/devbox/project/frontend-vue/src/app/App.vue) 的 `<style>` 块中或新建 `src/app/styles/variables.css` 定义核心品牌色:
```css
:root {
--el-color-primary: #0ea5e9; /* 项目当前使用的蓝色 */
--app-bg-color: #f0f9ff;
}
```
[data-theme='green'] {
--el-color-primary: #67c23a;
}
```
---
## AI 实施指令 (For AI Assistants)
如果你是一个 AI 助手,在执行此类任务时请遵循以下原则:
1. **优先使用 CSS 变量**: 不要硬编码颜色值。
2. **保持一致性**: 确保所有新添加的动画曲线Timing Function统一使用 `cubic-bezier(0.4, 0, 0.2, 1)`
3. **性能优先**: 避免在每一帧都进行复杂的 DOM 计算,优先使用 `transform``opacity` 进行动画。
4. **组件解耦**: 所有的动画和主题逻辑应尽量通过 `Composable``Store` 注入,保持视图组件的简洁。
```

@ -0,0 +1,78 @@
import { test, expect } from "@playwright/test";
function seedAuth(storage: { role?: "admin" | "operator" | "observer" } = {}) {
const role = storage.role ?? "admin";
return (window: Window) => {
window.localStorage.setItem(
"cm_user",
JSON.stringify({ id: 1, username: "e2e", role })
);
window.localStorage.setItem("cm_token", "e2e-token");
};
}
test("未登录访问 diagnosis 会跳转到 login", async ({ page }) => {
await page.goto("/#/diagnosis");
await expect(page).toHaveURL(/#\/login/);
await expect(page.getByText("登录", { exact: false })).toBeVisible();
});
test("已登录可打开 diagnosis 并完成一次聊天流式渲染", async ({ page }) => {
await page.addInitScript(seedAuth({ role: "admin" }));
await page.route("**/api/v1/ai/history**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
await page.route("**/api/v1/clusters**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
clusters: [{ id: "c1", uuid: "c1", name: "cluster-1" }],
}),
});
});
await page.route("**/api/v1/nodes**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
nodes: [{ name: "node-1", status: "running" }],
}),
});
});
await page.route("**/api/v1/diagnosis/logs**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ logs: [] }),
});
});
await page.route("**/api/v1/ai/chat**", async (route) => {
const body =
'data: {"content":"OK-1"}\n' + 'data: {"content":"OK-2"}\n' + "data: [DONE]\n";
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream" },
body,
});
});
await page.goto("/#/diagnosis");
await expect(page.getByText("诊断助手")).toBeVisible();
await page.getByPlaceholder("支持Markdown输入... Enter 发送").fill("hello");
await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText("OK-1")).toBeVisible();
await expect(page.getByText("OK-2")).toBeVisible();
});

@ -0,0 +1,9 @@
import { test, expect } from "@playwright/test";
test("dev server 返回 index.html", async ({ request }) => {
const res = await request.get("/");
expect(res.status()).toBe(200);
const html = await res.text();
expect(html).toContain('id="app"');
});

@ -5,25 +5,79 @@
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts",
"lint": "eslint \"src/**/*.{ts,vue}\"",
"lint:fix": "pnpm run lint --fix",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test --project=api",
"e2e:ui": "playwright test --project=ui-chromium",
"e2e:ui:install": "playwright install --with-deps chromium",
"e2e:ui:docker": "bash scripts/e2e-ui-docker.sh",
"build": "vite build --config vite.config.ts",
"preview": "vite preview --config vite.config.ts"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vueuse/core": "^14.1.0",
"axios": "^1.7.7",
"echarts": "^5.5.0",
"element-plus": "^2.13.0",
"marked": "^17.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.38",
"vue-i18n": "9",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/marked": "^5.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-vue": "^5.1.2",
"@vue/test-utils": "^2.4.6",
"amfe-flexible": "^2.2.1",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.32.0",
"happy-dom": "^16.3.1",
"postcss-pxtorem": "^6.1.0",
"sass": "^1.97.2",
"typescript": "^5.6.2",
"vite": "^5.4.10"
"unplugin-vue-components": "^30.0.0",
"vite": "^5.4.10",
"vitest": "^2.1.9",
"vue-eslint-parser": "^9.4.3"
},
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parser": "vue-eslint-parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"parser": "@typescript-eslint/parser"
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-essential"
],
"plugins": [
"vue",
"@typescript-eslint"
],
"ignorePatterns": [
"dist",
"node_modules"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"vue/multi-word-component-names": "off"
}
}
}

@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
outputDir: "test-results",
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: { baseURL: "http://127.0.0.1:5173" },
webServer: {
command: "pnpm dev --host 127.0.0.1 --port 5173",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "api",
testMatch: /.*\.api\.spec\.ts/,
},
{
name: "ui-chromium",
testMatch: /.*\.ui\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
},
},
],
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found"
exit 1
fi
IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.57.0-jammy}"
docker run --rm -t \
-v "${ROOT_DIR}:/work" \
-w /work \
"${IMAGE}" \
bash -lc 'corepack enable && pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile && pnpm run e2e:ui'

@ -4,16 +4,20 @@
<el-container v-if="!hideSidebar" class="main-container">
<!-- 移动端遮罩层 -->
<transition name="fade">
<div
v-if="isMobile && !ui.sidebarHidden"
class="mobile-mask"
<div
v-if="isMobile && !ui.sidebarHidden"
class="mobile-mask"
@click="ui.hideSidebar()"
></div>
</transition>
<el-aside
:width="ui.sidebarHidden ? '64px' : '220px'"
<el-aside
width="auto"
class="aside-menu"
:class="{ 'is-mobile-hidden': isMobile && ui.sidebarHidden, 'is-mobile-show': isMobile && !ui.sidebarHidden }"
:class="{
'is-collapsed': ui.sidebarHidden,
'is-mobile-hidden': isMobile && ui.sidebarHidden,
'is-mobile-show': isMobile && !ui.sidebarHidden,
}"
>
<Sidebar />
</el-aside>
@ -37,7 +41,7 @@
</template>
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import { ElConfigProvider } from "element-plus";
import HeaderNav from "./components/HeaderNav.vue";
import Sidebar from "./components/Sidebar.vue";
import LockScreen from "./components/LockScreen.vue";
@ -49,11 +53,14 @@ const ui = useUIStore();
const route = useRoute();
//
watch(() => route.path, () => {
if (isMobile.value && !ui.sidebarHidden) {
ui.hideSidebar();
watch(
() => route.path,
() => {
if (isMobile.value && !ui.sidebarHidden) {
ui.hideSidebar();
}
}
});
);
const hideSidebar = computed(
() => !!(route.meta && (route.meta as any).hideSidebar)
@ -68,25 +75,29 @@ const updateWidth = () => {
};
onMounted(() => {
window.addEventListener('resize', updateWidth);
window.addEventListener("resize", updateWidth);
updateWidth();
});
onUnmounted(() => {
window.removeEventListener('resize', updateWidth);
window.removeEventListener("resize", updateWidth);
});
</script>
<style>
:root {
--el-color-primary: #0ea5e9;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
font-family: var(--el-font-family);
background-color: #f0f9ff;
background-color: var(--app-bg);
color: var(--app-text-primary);
transition: background-color 0.3s, color 0.3s;
}
#app {
height: 100%;
}
.layout-wrapper {
@ -100,10 +111,21 @@ body {
}
.aside-menu {
background-color: #001529;
transition: width 0.3s, transform 0.3s, left 0.3s;
width: 220px;
background-color: var(--app-header-bg);
border-right: 1px solid var(--app-border-color);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
z-index: 1001;
will-change: width, transform;
display: flex;
flex-direction: column;
}
.aside-menu.is-collapsed {
width: 64px;
}
@media (max-width: 768px) {
@ -117,7 +139,7 @@ body {
}
.aside-menu.is-mobile-show {
transform: translateX(0);
box-shadow: 2px 0 8px rgba(0,0,0,0.15);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
}
.mobile-mask {
@ -155,13 +177,13 @@ body {
}
.header-nav {
background-color: #fff;
border-bottom: 1px solid #e2e8f0;
background-color: var(--app-header-bg);
border-bottom: 1px solid var(--app-border-color);
padding: 0;
}
.main-content {
background-color: #f8fafc;
background-color: var(--app-content-bg);
padding: 20px;
}
@ -169,7 +191,7 @@ body {
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
background-color: var(--app-bg);
position: relative;
overflow: hidden;
}
@ -181,7 +203,11 @@ body {
right: -5%;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(14, 165, 233, 0.05) 0%, transparent 70%);
background: radial-gradient(
circle,
rgba(14, 165, 233, 0.05) 0%,
transparent 70%
);
border-radius: 50%;
z-index: 0;
}
@ -193,7 +219,11 @@ body {
left: -5%;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(14, 165, 233, 0.08) 0%, transparent 70%);
background: radial-gradient(
circle,
rgba(14, 165, 233, 0.08) 0%,
transparent 70%
);
border-radius: 50%;
z-index: 0;
}

@ -19,5 +19,9 @@ export const AuthService = {
/** 后端健康检查 */
async health(): Promise<any> {
return api.get('/v1/health')
},
async refresh(payload: any): Promise<any> {
return api.post('/v1/auth/refresh', payload)
}
}

@ -19,12 +19,12 @@ export const ClusterService = {
},
/** 启动集群 */
async start(id: string): Promise<any> {
return api.post(`/v1/ops/clusters/${encodeURIComponent(id)}/start`)
async start(id: string): Promise<{ status: string; logs: string[] }> {
return api.post(`/v1/ops/clusters/${encodeURIComponent(id)}/start`, {}, { timeout: 60000 })
},
/** 停止集群 */
async stop(id: string): Promise<any> {
return api.post(`/v1/ops/clusters/${encodeURIComponent(id)}/stop`)
async stop(id: string): Promise<{ status: string; logs: string[] }> {
return api.post(`/v1/ops/clusters/${encodeURIComponent(id)}/stop`, {}, { timeout: 60000 })
}
}

@ -34,5 +34,23 @@ export const LogService = {
/** 删除执行日志 */
async removeExecLog(id: number): Promise<any> {
return api.delete(`/v1/exec-logs/${id}`)
},
/** 启动 Hadoop 集群日志采集 */
async startHadoopCollection(clusterUuid: string, interval: number = 3): Promise<any> {
return api.post(`/v1/hadoop/collectors/start-by-cluster/${encodeURIComponent(clusterUuid)}/`, null, {
params: { interval },
timeout: 60000
})
},
/** 停止所有日志采集 */
async stopAllCollections(): Promise<any> {
return api.post('/v1/hadoop/collectors/stop/all/')
},
/** 查看采集线程状态 */
async getCollectorStatus(): Promise<any> {
return api.get('/v1/hadoop/collectors/status/')
}
}

@ -17,5 +17,35 @@ export const MetricService = {
used: Number(res?.used ?? 0),
free: Number(res?.free ?? 0)
}
},
/** 立即采样 CPU/内存 指标 (旧接口,保留兼容) */
async sampleMetrics(clusterUuid: string): Promise<any> {
return api.post(`/v1/metrics/${clusterUuid}/`, null, {
timeout: 180000
})
},
/** 启动集群后台采集器 */
async startCollector(clusterUuid: string, interval: number = 5): Promise<any> {
return api.post(`/v1/metrics/collectors/start-by-cluster/${clusterUuid}`, null, {
params: { interval }
})
},
/** 获取采集器状态 */
async getCollectorStatus(clusterUuid?: string): Promise<{
is_running: boolean,
active_collectors_count: number,
interval: number,
collectors: Record<string, string>,
errors: Record<string, string>
}> {
return api.get(`/v1/metrics/collectors/status`, { params: { cluster: clusterUuid } })
},
/** 停止集群后台采集器 */
async stopCollector(clusterUuid: string): Promise<any> {
return api.post(`/v1/metrics/collectors/stop-by-cluster/${clusterUuid}`)
}
}

@ -0,0 +1,151 @@
<template>
<el-card
class="selection-card-vertical"
shadow="never"
:style="isMobile ? { height: percent + '%' } : { width: percent + '%' }"
>
<template #header>
<div class="card-header">
<span class="card-title">集群与节点</span>
</div>
</template>
<el-scrollbar>
<div class="cluster-groups-vertical">
<div v-for="g in filteredGroups" :key="g.id" class="cluster-group-v">
<div class="group-header" @click="toggleGroup(g)">
<el-icon class="icon-mr">
<ArrowDown v-if="g.open" />
<ArrowRight v-else />
</el-icon>
<span class="group-name">{{ g.name }}</span>
</div>
<div v-if="g.open" class="node-list-v">
<div
v-for="n in nodesForGroup(g)"
:key="n.name"
class="node-item-v"
:class="{ 'is-active': selectedNode === n.name }"
@click="selectNode(n.name)"
>
<span class="status-dot icon-mr" :class="statusDot(n)"></span>
<span class="node-name">{{ n.name }}</span>
</div>
</div>
</div>
</div>
</el-scrollbar>
</el-card>
</template>
<script setup lang="ts">
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
type NodeItem = { name: string; status: string };
type Group = { id: string; uuid: string; name: string; open: boolean; nodes: NodeItem[]; count?: number };
defineProps<{
isMobile: boolean;
percent: number;
filteredGroups: Group[];
selectedNode: string;
nodesForGroup: (g: Group) => NodeItem[];
toggleGroup: (g: Group) => void | Promise<void>;
selectNode: (n: string) => void;
statusDot: (n: NodeItem) => string;
}>();
</script>
<style scoped>
.selection-card-vertical {
flex-shrink: 0;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
}
.cluster-groups-vertical {
padding: 8px;
}
.cluster-group-v {
margin-bottom: 8px;
}
.group-header {
padding: 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
font-size: 13px;
font-weight: 500;
color: var(--app-text-primary);
transition: background 0.2s;
}
.group-header:hover {
background: var(--el-color-primary-light-9);
}
.node-list-v {
margin-top: 4px;
padding-left: 12px;
}
.node-item-v {
padding: 6px 10px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
transition: all 0.2s;
}
.node-item-v:hover {
background: var(--app-content-bg);
}
.node-item-v.is-active {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 600;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.status-dot-running {
background-color: var(--el-color-success);
}
.status-dot-warning {
background-color: var(--el-color-warning);
}
.status-dot-error {
background-color: var(--el-color-danger);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-weight: 600;
font-size: 14px;
}
.icon-mr {
margin-right: 6px;
}
</style>

@ -4,41 +4,96 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { MetricService } from '../api/metric.service'
import { useAuthStore } from '../stores/auth'
import { useUIStore } from '../stores/ui'
import { loadEcharts } from '../lib/echarts'
import type { EChartsType } from 'echarts/core'
const props = defineProps<{ cluster: string }>()
const auth = useAuthStore()
const ui = useUIStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
let chart: EChartsType | null = null
let ro: ResizeObserver | null = null
function render(times: string[], values: number[]) {
chart?.setOption({ xAxis: { type: 'category', boundaryGap: false, data: times }, yAxis: { type: 'value', min:0, max:100 }, series: [{ type: 'line', smooth: true, areaStyle: {}, data: values }] })
let destroyed = false
function render(used: number, idle: number) {
if (!chart) return
const isDark = ui.isDark
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
formatter: '{b}: {d}%',
backgroundColor: isDark ? '#333' : '#fff',
borderColor: isDark ? '#555' : '#eee',
textStyle: { color: isDark ? '#fff' : '#333' }
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { color: isDark ? '#bbb' : '#333' }
},
series: [
{
name: 'CPU 使用率',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: isDark ? '#1d1e1f' : '#fff',
borderWidth: 2
},
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 16, fontWeight: 'bold' } },
labelLine: { show: false },
data: [
{ value: used, name: '已使用', itemStyle: { color: '#F56C6C' } },
{ value: idle, name: '可用', itemStyle: { color: isDark ? '#333' : '#E5E9F2' } }
]
}
]
})
}
async function load() {
if (!chart) return
if (!chart || props.cluster === '未选择') return
try {
const { times, values } = await MetricService.getCpuTrend(props.cluster)
if (times.length > 0 && values.length > 0) {
render(times, values)
} else {
render(['00:00','04:00','08:00','12:00','16:00','20:00','24:00'], [20,35,45,60,55,40,30])
}
const { values } = await MetricService.getCpuTrend(props.cluster)
// 使 0
const currentUsed = values.length > 0 ? values[values.length - 1] : 0
const currentIdle = Math.max(0, 100 - currentUsed)
render(Number(currentUsed.toFixed(1)), Number(currentIdle.toFixed(1)))
} catch {
render(['00:00','04:00','08:00','12:00','16:00','20:00','24:00'], [20,35,45,60,55,40,30])
render(25.5, 74.5) //
}
}
async function initChart() {
const el = root.value
if (!el) return
const echarts = await loadEcharts()
if (destroyed || root.value !== el) return
if (chart) chart.dispose()
chart = echarts.init(el)
await load()
}
onMounted(() => {
if (!root.value) return
chart = echarts.init(root.value)
load()
void initChart()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
ro = new ResizeObserver(() => { chart && chart.resize() })
ro.observe(root.value)
if (root.value) ro.observe(root.value)
nextTick(() => { chart && chart.resize() })
setTimeout(() => { chart && chart.resize() }, 300)
})
watch(() => ui.isDark, () => {
void initChart()
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
onBeforeUnmount(() => { destroyed = true; ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
</script>

@ -0,0 +1,227 @@
<template>
<el-card class="chat-card-full" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">诊断助手</span>
<div class="agent-selectors">
<el-select
:model-value="model"
size="small"
style="width: 160px"
@update:model-value="(v) => emit('update:model', String(v || ''))"
>
<el-option label="DeepSeek-V3" value="deepseek-ai/DeepSeek-V3" />
<el-option label="DeepSeek-R1" value="Pro/deepseek-ai/DeepSeek-R1" />
</el-select>
</div>
</div>
</template>
<div class="chat-container">
<div class="chat-history" :ref="setChatHistoryEl">
<div
v-for="(m, i) in visibleMessages"
:key="'msg-' + i"
class="chat-item"
:class="m.role === 'user' ? 'chat-item-user' : 'chat-item-assistant'"
>
<div class="chat-role">{{ roleLabel(m.role) }}</div>
<div class="chat-content">
<div v-if="m.reasoning" class="reasoning-container">
<el-collapse>
<el-collapse-item title="推理过程">
<pre class="reasoning-text">{{ m.reasoning }}</pre>
</el-collapse-item>
</el-collapse>
</div>
<div class="message-text">{{ m.content }}</div>
</div>
</div>
</div>
<transition name="el-fade-in">
<el-button
v-if="showScrollBottom"
class="scroll-bottom-btn"
type="primary"
circle
@click="emit('scrollBottom')"
>
<el-icon><ArrowDown /></el-icon>
</el-button>
</transition>
<div class="chat-input-area">
<el-input
:model-value="inputMsg"
type="textarea"
:rows="3"
placeholder="支持Markdown输入... Enter 发送"
:disabled="sending"
@keydown.enter.exact.prevent="emit('send')"
@update:model-value="(v) => emit('update:inputMsg', String(v || ''))"
/>
<div class="input-actions">
<div class="option-checks">
<el-checkbox
:model-value="useWebSearch"
label="搜索"
size="small"
@update:model-value="(v) => emit('update:useWebSearch', !!v)"
/>
<el-checkbox
:model-value="useClusterOps"
label="操作"
size="small"
@update:model-value="(v) => emit('update:useClusterOps', !!v)"
/>
</div>
<div class="button-group">
<el-button v-if="!sending" type="primary" :disabled="!inputMsg.trim()" @click="emit('send')">
发送
</el-button>
<el-button v-else type="danger" plain @click="emit('stop')"></el-button>
<el-button type="warning" plain :disabled="sending" @click="emit('diagnose')"></el-button>
</div>
</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ArrowDown } from "@element-plus/icons-vue";
type Message = { role: "user" | "assistant" | "system"; content: string; reasoning?: string };
defineProps<{
model: string;
visibleMessages: Message[];
showScrollBottom: boolean;
inputMsg: string;
sending: boolean;
useWebSearch: boolean;
useClusterOps: boolean;
roleLabel: (r: string) => string;
setChatHistoryEl: (el: Element | null) => void;
}>();
const emit = defineEmits<{
(e: "update:model", v: string): void;
(e: "update:inputMsg", v: string): void;
(e: "update:useWebSearch", v: boolean): void;
(e: "update:useClusterOps", v: boolean): void;
(e: "scrollBottom"): void;
(e: "send"): void;
(e: "stop"): void;
(e: "diagnose"): void;
}>();
</script>
<style scoped>
.chat-card-full {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 0;
border: none;
border-left: 1px solid var(--app-border-color);
}
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-history {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
margin-bottom: 16px;
word-break: break-word;
}
.scroll-bottom-btn {
position: absolute;
right: 20px;
bottom: 160px;
z-index: 100;
}
.chat-item {
margin-bottom: 20px;
}
.chat-role {
font-size: 12px;
color: var(--app-text-secondary);
margin-bottom: 4px;
}
.chat-item-user .chat-role {
text-align: right;
}
.chat-content {
padding: 12px;
border-radius: 8px;
background: var(--app-card-bg);
border: 1px solid var(--app-border-color);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.message-text {
white-space: pre-wrap;
}
.chat-item-user .chat-content {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-8);
}
.chat-input-area {
flex-shrink: 0;
border-top: 1px solid var(--app-border-color);
padding-top: 16px;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.button-group {
display: flex;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-weight: 600;
font-size: 14px;
}
</style>

@ -24,9 +24,8 @@
{{ row.end || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click.stop="$emit('edit', row)">编辑</el-button>
<el-popconfirm title="确定要删除这条记录吗?" @confirm="$emit('delete', row.id)">
<template #reference>
<el-button size="small" type="danger" plain @click.stop>删除</el-button>
@ -40,7 +39,7 @@
<script setup lang="ts">
type RecordItem = { id:number; clusterName:string; username:string; description:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
const props = defineProps<{ records: RecordItem[]; selectedId: number | null }>()
const emit = defineEmits(['select', 'edit', 'delete'])
const emit = defineEmits(['select', 'delete'])
function statusType(s: 'running' | 'success' | 'failed') {
const map: Record<string, string> = {
@ -57,9 +56,4 @@ function handleCurrentChange(val: RecordItem | null) {
</script>
<style scoped>
:deep(.table-header) {
background-color: #f8fafc !important;
color: #475569;
font-weight: 600;
}
</style>

@ -13,8 +13,9 @@
</el-icon>
</el-button>
<el-breadcrumb separator="/" class="u-hidden-mobile">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentRouteName">{{ currentRouteName }}</el-breadcrumb-item>
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index" :to="item.path">
{{ item.label }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
@ -25,6 +26,15 @@
</el-button>
</el-tooltip>
<el-tooltip :content="isDark ? '切换到浅色模式' : '切换到暗黑模式'" placement="bottom">
<el-button link @click="toggleThemeWithAnimation" class="theme-toggle-btn">
<el-icon :size="20">
<Moon v-if="!isDark" />
<Sunny v-else />
</el-icon>
</el-button>
</el-tooltip>
<el-tooltip :content="isFullscreen ? '退出全屏' : '全屏显示'" placement="bottom">
<el-button link @click="toggleFullscreen" class="u-hidden-mobile">
<el-icon :size="20">
@ -80,17 +90,25 @@ import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useAuthStore } from "../stores/auth";
import { useUIStore } from "../stores/ui";
import { Expand, Fold, Refresh, FullScreen, Aim, UserFilled, ArrowDown } from '@element-plus/icons-vue'
import { Expand, Fold, Refresh, FullScreen, Aim, UserFilled, ArrowDown, Moon, Sunny } from '@element-plus/icons-vue'
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const ui = useUIStore();
const { isAuthenticated } = storeToRefs(auth);
const { isDark } = storeToRefs(ui);
const authed = isAuthenticated;
const currentRouteName = computed(() => {
return route.name ? String(route.name).toUpperCase() : '';
const breadcrumbs = computed(() => {
if (route.meta && route.meta.breadcrumb) {
return route.meta.breadcrumb as Array<{ label: string, path: string }>;
}
const name = route.name ? String(route.name).toUpperCase() : '';
return [
{ label: '首页', path: '/' },
name ? { label: name, path: route.path } : null
].filter((i): i is { label: string, path: string } => i !== null);
});
const hideSidebar = computed(() => !!(route.meta && (route.meta as any).hideSidebar));
@ -104,6 +122,46 @@ function toggleSidebar() {
ui.toggleSidebar();
}
function toggleThemeWithAnimation(event: MouseEvent) {
const isAppearanceTransition = (document as any).startViewTransition &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isAppearanceTransition) {
ui.toggleTheme();
return;
}
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
const transition = (document as any).startViewTransition(async () => {
ui.toggleTheme();
});
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: 'ease-in-out',
pseudoElement: isDark.value
? '::view-transition-old(root)'
: '::view-transition-new(root)',
}
);
});
}
function refreshPage() {
window.location.reload();
}
@ -204,11 +262,45 @@ function handleCommand(command: string) {
color: #334155;
}
:deep(.dark) .username {
color: #cbd5e1;
}
html.dark .username {
color: #cbd5e1;
}
.toggle-btn {
padding: 8px;
height: auto;
}
.theme-toggle-btn {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle-btn:hover {
background-color: var(--el-fill-color-light);
transform: rotate(15deg) scale(1.1);
}
.theme-toggle-btn:active {
transform: scale(0.95);
}
.theme-toggle-btn :deep(.el-icon) {
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.theme-toggle-btn:hover :deep(.el-icon) {
color: var(--el-color-primary);
}
@media (max-width: 768px) {
.u-hidden-mobile {
display: none;

@ -0,0 +1,304 @@
<template>
<div class="log-viewer">
<div v-if="!node" class="empty-preview"></div>
<div v-else class="log-preview-container">
<div class="filter-bar">
<el-select
:model-value="level"
placeholder="级别"
size="small"
clearable
style="width: 80px"
@update:model-value="(v) => emit('update:level', String(v || ''))"
>
<el-option label="INFO" value="info" />
<el-option label="WARN" value="warning" />
<el-option label="ERROR" value="error" />
</el-select>
<el-select
:model-value="timeRange"
placeholder="时间"
size="small"
clearable
style="width: 90px"
@update:model-value="(v) => emit('update:timeRange', String(v || ''))"
>
<el-option label="1h" value="1h" />
<el-option label="6h" value="6h" />
<el-option label="24h" value="24h" />
</el-select>
</div>
<div class="log-list-wrapper">
<div class="log-list-head">
<div class="cell time">时间</div>
<div class="cell level">级别</div>
<div class="cell msg">消息</div>
</div>
<div ref="viewportEl" class="log-viewport" @scroll="onScroll">
<div class="log-spacer" :style="{ height: totalHeight + 'px' }"></div>
<div
class="log-items"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div v-for="row in visibleLogs" :key="row.id" class="log-row">
<div class="cell time">
{{ row.time.split("T")[1]?.slice(0, 8) || row.time }}
</div>
<div class="cell level">
<el-tag
:type="getLevelType(row.level)"
size="small"
effect="dark"
>
{{ row.level.charAt(0).toUpperCase() }}
</el-tag>
</div>
<div class="cell msg" :title="row.message">{{ row.message }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { LogService } from "../api/log.service";
type LogItem = {
id: number | string;
time: string;
level: string;
source: string;
message: string;
};
const props = withDefaults(
defineProps<{
node: string;
level: string;
timeRange: string;
paused?: boolean;
size?: number;
pollMs?: number;
}>(),
{ paused: false, size: 50, pollMs: 5000 }
);
const emit = defineEmits<{
(e: "update:level", v: string): void;
(e: "update:timeRange", v: string): void;
}>();
const logs = ref<LogItem[]>([]);
const viewportEl = ref<HTMLDivElement | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
const rowHeight = 34;
const overscan = 8;
let timer: any = null;
let ro: ResizeObserver | null = null;
function onScroll() {
if (!viewportEl.value) return;
scrollTop.value = viewportEl.value.scrollTop;
}
const totalHeight = computed(() => logs.value.length * rowHeight);
const startIndex = computed(() => {
const base = Math.floor(scrollTop.value / rowHeight);
return Math.max(0, base - overscan);
});
const endIndex = computed(() => {
const count = Math.ceil(viewportHeight.value / rowHeight) + overscan * 2;
return Math.min(logs.value.length, startIndex.value + count);
});
const offsetY = computed(() => startIndex.value * rowHeight);
const visibleLogs = computed(() =>
logs.value.slice(startIndex.value, endIndex.value)
);
function getLevelType(level: string) {
switch (level.toLowerCase()) {
case "error":
return "danger";
case "warning":
case "warn":
return "warning";
case "info":
return "info";
case "success":
return "success";
default:
return "info";
}
}
async function loadLogs() {
if (!props.node) {
logs.value = [];
return;
}
try {
const params: any = { node: props.node, size: props.size };
if (props.level) params.level = props.level;
if (props.timeRange) {
const now = Date.now();
const r = props.timeRange;
const span =
r === "1h"
? 60 * 60 * 1000
: r === "6h"
? 6 * 60 * 60 * 1000
: r === "24h"
? 24 * 60 * 60 * 1000
: r === "7d"
? 7 * 24 * 60 * 60 * 1000
: 0;
if (span) params.time_from = new Date(now - span).toISOString();
}
const { items } = await LogService.list(params);
logs.value = (items || []).map((d: any, i: number) => ({
id: d.log_id || d.id || i,
time: d.log_time || d.time || d.timestamp || new Date().toISOString(),
level: String(d.level || "info").toLowerCase(),
source: String(
d.title || d.source || d.node_host || d.node || d.host || ""
),
message: d.info || d.message || "",
}));
} catch {
logs.value = [];
}
}
function startPoll() {
stopPoll();
timer = setInterval(() => {
if (props.node && !props.paused) loadLogs();
}, props.pollMs);
}
function stopPoll() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
watch(
() => [props.node, props.level, props.timeRange],
() => {
loadLogs();
if (viewportEl.value) viewportEl.value.scrollTop = 0;
},
{ immediate: true }
);
onMounted(() => {
if (viewportEl.value) viewportHeight.value = viewportEl.value.clientHeight;
ro = new ResizeObserver(() => {
if (viewportEl.value) viewportHeight.value = viewportEl.value.clientHeight;
});
if (viewportEl.value) ro.observe(viewportEl.value);
startPoll();
});
onBeforeUnmount(() => {
stopPoll();
if (ro && viewportEl.value) ro.unobserve(viewportEl.value);
ro = null;
});
</script>
<style scoped>
.log-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.empty-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--app-text-secondary);
font-size: 14px;
}
.log-preview-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.log-list-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--app-border-color);
border-radius: 6px;
}
.log-list-head {
display: grid;
grid-template-columns: 90px 80px 1fr;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--app-text-secondary);
border-bottom: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
.log-viewport {
position: relative;
flex: 1;
overflow: auto;
}
.log-spacer {
width: 1px;
opacity: 0;
}
.log-items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.log-row {
height: 34px;
display: grid;
grid-template-columns: 90px 80px 1fr;
gap: 8px;
padding: 0 10px;
align-items: center;
font-size: 12px;
border-bottom: 1px solid var(--app-border-color);
}
.cell.msg {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

@ -4,19 +4,61 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { MetricService } from '../api/metric.service'
import { useAuthStore } from '../stores/auth'
import { useUIStore } from '../stores/ui'
import { loadEcharts } from '../lib/echarts'
import type { EChartsType } from 'echarts/core'
const props = defineProps<{ cluster: string }>()
const auth = useAuthStore()
const ui = useUIStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
let chart: EChartsType | null = null
let ro: ResizeObserver | null = null
let destroyed = false
function render(used: number, free: number) {
chart?.setOption({ series: [{ type: 'pie', radius: ['40%','70%'], data: [{ value: used, name: '已使用' }, { value: free, name: '可用' }] }] })
if (!chart) return
const isDark = ui.isDark
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
formatter: '{b}: {d}%',
backgroundColor: isDark ? '#333' : '#fff',
borderColor: isDark ? '#555' : '#eee',
textStyle: { color: isDark ? '#fff' : '#333' }
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { color: isDark ? '#bbb' : '#333' }
},
series: [
{
name: '内存使用',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: isDark ? '#1d1e1f' : '#fff',
borderWidth: 2
},
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 16, fontWeight: 'bold' } },
labelLine: { show: false },
data: [
{ value: used, name: '已使用', itemStyle: { color: '#409EFF' } },
{ value: free, name: '可用', itemStyle: { color: isDark ? '#333' : '#E5E9F2' } }
]
}
]
})
}
async function load() {
if (!chart) return
if (!chart || props.cluster === '未选择') return
try {
const { used, free } = await MetricService.getMemoryUsage(props.cluster)
if (used > 0 || free > 0) {
@ -28,17 +70,30 @@ async function load() {
render(8.5, 15.5)
}
}
async function initChart() {
const el = root.value
if (!el) return
const echarts = await loadEcharts()
if (destroyed || root.value !== el) return
if (chart) chart.dispose()
chart = echarts.init(el)
await load()
}
onMounted(() => {
if (!root.value) return
chart = echarts.init(root.value)
load()
void initChart()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
ro = new ResizeObserver(() => { chart && chart.resize() })
ro.observe(root.value)
if (root.value) ro.observe(root.value)
nextTick(() => { chart && chart.resize() })
setTimeout(() => { chart && chart.resize() }, 300)
})
watch(() => ui.isDark, () => {
void initChart()
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
onBeforeUnmount(() => { destroyed = true; ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
</script>

@ -0,0 +1,118 @@
<template>
<div class="resizer-bar" :class="barClass" @mousedown="start">
<div class="resizer-handle"></div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
const props = withDefaults(
defineProps<{
container: HTMLElement | null;
isMobile: boolean;
modelValue: number;
maxPercent: number;
collapseThreshold?: number;
barClass?: string;
}>(),
{ collapseThreshold: 5, barClass: "" }
);
const emit = defineEmits<{
(e: "update:modelValue", v: number): void;
(e: "dragging", v: boolean): void;
}>();
let isDragging = false;
function computePercent(e: MouseEvent) {
const containerRect = props.container?.getBoundingClientRect();
if (!containerRect) return null;
if (props.isMobile) {
return ((e.clientY - containerRect.top) / containerRect.height) * 100;
}
return ((e.clientX - containerRect.left) / containerRect.width) * 100;
}
function onMove(e: MouseEvent) {
if (!isDragging) return;
const next = computePercent(e);
if (next == null) return;
if (next < props.collapseThreshold) {
emit("update:modelValue", 0);
return;
}
if (next < props.maxPercent) {
emit("update:modelValue", next);
}
}
function stop() {
if (!isDragging) return;
isDragging = false;
emit("dragging", false);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", stop);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function start(e: MouseEvent) {
if (!props.container) return;
e.preventDefault();
isDragging = true;
emit("dragging", true);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", stop);
document.body.style.cursor = props.isMobile ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
}
onBeforeUnmount(() => stop());
</script>
<style scoped>
.resizer-bar {
width: 8px;
cursor: col-resize;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 10;
flex-shrink: 0;
}
.resizer-bar:hover {
background: var(--el-color-primary-light-8);
}
.resizer-handle {
width: 2px;
height: 40px;
background: var(--app-border-color);
border-radius: 1px;
}
.inner-resizer {
margin: 0 4px;
}
@media (max-width: 1024px) {
.resizer-bar {
width: 100%;
height: 8px;
cursor: row-resize;
}
.resizer-handle {
width: 40px;
height: 2px;
}
.inner-resizer {
margin: 4px 0;
}
}
</style>

@ -3,9 +3,7 @@
:default-active="route.path"
class="sidebar-menu"
:collapse="ui.sidebarHidden"
background-color="#001529"
text-color="rgba(255, 255, 255, 0.65)"
active-text-color="#fff"
:collapse-transition="false"
router
>
<div class="logo-container" :class="{ 'is-collapse': ui.sidebarHidden }">
@ -56,7 +54,16 @@ import { storeToRefs } from "pinia";
import { useAuthStore } from "../stores/auth";
import { useUIStore } from "../stores/ui";
import { Roles } from "../constants/roles";
import { Monitor, Search, Grid, Document, Operation, Lock, User, Clock } from '@element-plus/icons-vue'
import {
Monitor,
Search,
Grid,
Document,
Operation,
Lock,
User,
Clock,
} from "@element-plus/icons-vue";
const route = useRoute();
const auth = useAuthStore();
@ -70,8 +77,33 @@ function can(roles: string[]) {
<style scoped>
.sidebar-menu {
border-right: none;
border-right: 1px solid rgba(0, 0, 0, 0.2);
height: 100%;
width: 100% !important;
background-color: var(--app-sidebar-bg);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 10px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* 隐藏收起时的文字,防止闪烁 */
:deep(.el-menu--collapse) .el-sub-menu__title span,
:deep(.el-menu--collapse) .el-menu-item span,
:deep(.el-menu--collapse) .el-sub-menu__icon-arrow {
display: none;
}
/* 确保所有层级的菜单背景统一 */
.sidebar-menu,
:deep(.el-menu),
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
background-color: var(--app-sidebar-bg) !important;
}
:global(html.dark) .sidebar-menu {
border-right: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: none;
}
.logo-container {
@ -80,9 +112,11 @@ function can(roles: string[]) {
align-items: center;
padding: 0 20px;
gap: 12px;
background-color: #002140;
transition: all 0.3s;
background-color: var(--app-sidebar-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
white-space: nowrap;
}
.logo-container.is-collapse {
@ -91,7 +125,7 @@ function can(roles: string[]) {
}
.logo-text {
color: #fff;
color: var(--app-sidebar-text-active);
font-size: 18px;
font-weight: 600;
white-space: nowrap;
@ -99,9 +133,40 @@ function can(roles: string[]) {
:deep(.el-menu-item.is-active) {
background-color: var(--el-color-primary) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
}
:deep(.el-menu-item.is-active .el-icon) {
color: #ffffff !important;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: var(--app-sidebar-text);
}
:deep(.el-menu-item .el-icon),
:deep(.el-sub-menu__title .el-icon) {
color: var(--app-sidebar-text);
}
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: var(--app-sidebar-item-hover) !important;
color: var(--app-sidebar-text-active) !important;
}
:deep(.el-menu-item:hover .el-icon),
:deep(.el-sub-menu__title:hover .el-icon) {
color: var(--app-sidebar-text-active) !important;
}
:deep(.el-sub-menu__icon-arrow) {
color: var(--app-sidebar-text);
}
:deep(.el-menu-item:hover) {
color: #fff !important;
:deep(.el-sub-menu:hover .el-sub-menu__icon-arrow) {
color: var(--app-sidebar-text-active) !important;
}
</style>

@ -0,0 +1,36 @@
<template>
<slot name="first"></slot>
<ResizableBar
v-if="resizable"
:container="container"
:is-mobile="isMobile"
:max-percent="maxPercent"
:bar-class="barClass"
:model-value="modelValue"
@update:model-value="(v) => emit('update:modelValue', v)"
@dragging="(v) => emit('dragging', v)"
/>
<slot name="second"></slot>
</template>
<script setup lang="ts">
import ResizableBar from "./ResizableBar.vue";
withDefaults(
defineProps<{
container: HTMLElement | null;
isMobile: boolean;
modelValue: number;
maxPercent: number;
resizable?: boolean;
barClass?: string;
}>(),
{ resizable: true, barClass: "" }
);
const emit = defineEmits<{
(e: "update:modelValue", v: number): void;
(e: "dragging", v: boolean): void;
}>();
</script>

@ -1,12 +1,14 @@
import * as echarts from 'echarts'
export function initCpu(el: HTMLElement) {
import { loadEcharts } from '../lib/echarts'
export async function initCpu(el: HTMLElement) {
const echarts = await loadEcharts()
const c = echarts.init(el)
c.setOption({ xAxis: { type: 'category', boundaryGap: false, data: ['00:00','04:00','08:00','12:00','16:00','20:00','24:00'] }, yAxis: { type: 'value', min:0, max:100 }, series: [{ type: 'line', smooth: true, data: [20,35,45,60,55,40,30] }] })
return c
}
export function initMem(el: HTMLElement) {
export async function initMem(el: HTMLElement) {
const echarts = await loadEcharts()
const c = echarts.init(el)
c.setOption({ series: [{ type: 'pie', radius: ['40%','70%'], data: [{ value: 8.5, name: '已使用' }, { value: 15.5, name: '可用' }] }] })
return c
}

@ -0,0 +1,135 @@
import { computed, reactive, ref, type Ref } from "vue";
import { ClusterService } from "../api/cluster.service";
import { NodeService } from "../api/node.service";
type NodeItem = { name: string; status: string };
export type ClusterGroup = {
id: string;
uuid: string;
name: string;
open: boolean;
nodes: NodeItem[];
count?: number;
};
export function useClusterTree(options: {
kw: Ref<string>;
filters: { cluster: string; node: string };
selectedNode: Ref<string>;
setError: (e: any, def: string) => void;
}) {
const groups = reactive<ClusterGroup[]>([]);
const loadingSidebar = ref(false);
function pad3(n: number) {
return String(n).padStart(3, "0");
}
async function loadClusters() {
loadingSidebar.value = true;
try {
const list = await ClusterService.list();
const mapped: ClusterGroup[] = list
.map((x: any) => ({
id: String(x.uuid || x.id || x.host || x.name || ""),
uuid: String(x.uuid || x.id || ""),
name: String(x.host || x.name || x.uuid || ""),
open: false,
nodes: [],
count: Number(x.count) || 0,
}))
.filter((g: any) => g.id && g.name);
groups.splice(0, groups.length, ...mapped);
} catch (e: any) {
options.setError(e, "集群列表加载失败");
} finally {
loadingSidebar.value = false;
}
}
async function loadNodesFor(clusterUuid: string) {
const g = groups.find((x) => x.uuid === clusterUuid);
if (!g) return;
const clusterName = g.name;
try {
const nodes = await NodeService.listByCluster(clusterUuid);
const mappedNodes = nodes
.map((x: any) => ({
name: String(x?.name || x),
status: x?.status || "running",
}))
.filter((x: any) => x.name);
if (mappedNodes.length) g.nodes = mappedNodes;
else if ((g.count || 0) > 0)
g.nodes = Array.from({ length: g.count as number }, (_, i) => ({
name: `${clusterName}-${pad3(i + 1)}`,
status: "running",
}));
} catch (e: any) {
if ((g.count || 0) > 0 && g.nodes.length === 0)
g.nodes = Array.from({ length: g.count as number }, (_, i) => ({
name: `${clusterName}-${pad3(i + 1)}`,
status: "running",
}));
options.setError(e, "节点列表加载失败");
}
}
async function toggleGroup(g: ClusterGroup) {
g.open = !g.open;
if (g.open && g.nodes.length === 0) await loadNodesFor(g.uuid);
}
const filteredGroups = computed(() => {
const kraw = options.kw.value.trim().toLowerCase();
let base = groups.filter((g) => !options.filters.cluster || g.name === options.filters.cluster);
if (kraw) {
base = base.filter(
(g) =>
g.name.toLowerCase().includes(kraw) ||
g.nodes.some((n) => n.name.toLowerCase().includes(kraw))
);
}
if (options.filters.node) {
base = base.filter((g) => g.nodes.some((n) => n.name === options.filters.node));
}
return base;
});
function nodesForGroup(g: ClusterGroup) {
const k = options.kw.value.trim().toLowerCase();
let nodes = g.nodes;
if (k) nodes = nodes.filter((n) => n.name.toLowerCase().includes(k) || g.name.toLowerCase().includes(k));
if (options.filters.node) nodes = nodes.filter((n) => n.name === options.filters.node);
return nodes;
}
function statusDot(n: { name: string; status: string }) {
if (n.status === "running") return "status-dot-running";
if (n.status === "warning") return "status-dot-warning";
if (n.status === "error") return "status-dot-error";
return n.name.includes("003")
? "status-dot-error"
: n.name.includes("002")
? "status-dot-warning"
: "status-dot-running";
}
const selectedClusterUuid = computed(() => {
if (!options.selectedNode.value) return "";
const group = groups.find((g) => g.nodes.some((n) => n.name === options.selectedNode.value));
return group ? group.uuid : "";
});
return {
groups,
loadingSidebar,
filteredGroups,
nodesForGroup,
toggleGroup,
statusDot,
selectedClusterUuid,
loadClusters,
};
}

@ -0,0 +1,38 @@
import { ref, watch } from "vue";
export function useCollapsiblePercent(options: {
initialPercent: number;
defaultExpandedPercent: number;
minRememberPercent?: number;
}) {
const minRememberPercent = options.minRememberPercent ?? 0;
const percent = ref(options.initialPercent);
const lastPercent = ref(options.initialPercent);
const collapsed = ref(options.initialPercent <= 0);
function toggle() {
if (collapsed.value) {
percent.value =
lastPercent.value > minRememberPercent
? lastPercent.value
: options.defaultExpandedPercent;
collapsed.value = false;
} else {
lastPercent.value = percent.value;
percent.value = 0;
collapsed.value = true;
}
}
watch(percent, (v) => {
if (v > 0) {
collapsed.value = false;
lastPercent.value = v;
} else {
collapsed.value = true;
}
});
return { percent, lastPercent, collapsed, toggle };
}

@ -0,0 +1,211 @@
import { computed, nextTick, reactive, ref, type Ref } from "vue";
import type { ClusterGroup } from "./useClusterTree";
import { DiagnosisService } from "../api/diagnosis.service";
import { ElMessage } from "element-plus";
type Message = { role: "user" | "assistant" | "system"; content: string; reasoning?: string };
export function useDiagnosisChat(options: {
authToken: Ref<string | null>;
agent: Ref<string>;
model: Ref<string>;
selectedNode: Ref<string>;
selectedClusterUuid: Ref<string>;
groups: ClusterGroup[];
useWebSearch: Ref<boolean>;
useClusterOps: Ref<boolean>;
setError: (e: any, def: string) => void;
clearError: () => void;
scrollToLatest: () => void;
}) {
const messages = ref<Message[]>([
{ role: "system", content: "欢迎使用多智能体诊断面板" },
{ role: "assistant", content: "请在左侧选择节点并开始诊断" },
]);
const visibleMessages = computed(() => messages.value.filter((m) => m.role !== "system"));
const inputMsg = ref("");
const sending = ref(false);
let abortController: AbortController | null = null;
function stopGeneration() {
if (abortController) {
abortController.abort();
abortController = null;
sending.value = false;
}
}
function sessionIdOf() {
return options.selectedNode.value ? `diagnosis-${options.selectedNode.value}` : "diagnosis-global";
}
async function loadHistory() {
options.clearError();
try {
const r = await DiagnosisService.getHistory(sessionIdOf());
const list = Array.isArray(r?.messages) ? r.messages : [];
messages.value = list.map((m: any) => ({
role: m.role || "assistant",
content: String(m.content || ""),
reasoning: m.reasoning || m.reasoning_content,
}));
await nextTick();
options.scrollToLatest();
} catch (e: any) {
options.setError(e, "历史记录加载失败");
}
}
async function send() {
const msg = inputMsg.value.trim();
if (!msg) return;
sending.value = true;
options.clearError();
abortController = new AbortController();
const userMsg = { role: "user" as const, content: msg };
messages.value.push(userMsg);
const assistantMsg = reactive({ role: "assistant" as const, content: "", reasoning: "" });
messages.value.push(assistantMsg);
try {
const response = await fetch("/api/v1/ai/chat", {
method: "POST",
signal: abortController.signal,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
"Cache-Control": "no-cache",
...(options.authToken.value ? { Authorization: `Bearer ${options.authToken.value}` } : {}),
},
body: JSON.stringify({
sessionId: sessionIdOf(),
message: msg,
stream: true,
context: {
webSearch: options.useWebSearch.value,
clusterOps: options.useClusterOps.value,
agent: options.agent.value,
node: options.selectedNode.value || "",
cluster: options.selectedClusterUuid.value || "",
model: options.model.value,
},
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("无法读取响应流");
inputMsg.value = "";
let buffer = "";
let hasReceivedContent = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) continue;
const jsonStr = trimmed.slice(6);
if (jsonStr === "[DONE]") break;
try {
const data = JSON.parse(jsonStr);
const text = data.content || data.reply || data.message || "";
if (text) {
assistantMsg.content += text;
hasReceivedContent = true;
}
if (data.reasoning || data.reasoning_content) {
assistantMsg.reasoning += data.reasoning || data.reasoning_content;
hasReceivedContent = true;
}
await nextTick();
options.scrollToLatest();
} catch (e) {
console.error("解析流数据失败", e, jsonStr);
}
}
}
if (!hasReceivedContent) {
options.setError({ friendlyMessage: "后端已响应但未返回任何有效诊断内容。" }, "消息发送失败");
messages.value.pop();
}
} catch (e: any) {
if (e.name === "AbortError") return;
options.setError(e, "消息发送失败");
messages.value.pop();
} finally {
sending.value = false;
}
}
async function diagnose() {
if (!options.selectedNode.value) {
ElMessage.warning("请先在左侧选择一个节点进行诊断");
return;
}
const group = options.groups.find((g) => g.nodes.some((n) => n.name === options.selectedNode.value));
if (!group) {
ElMessage.error("无法确定节点所属集群");
return;
}
sending.value = true;
options.clearError();
try {
const res = await DiagnosisService.diagnoseRepair({
cluster: group.uuid,
model: options.model.value,
auto: true,
maxSteps: 3,
});
messages.value.push({
role: "assistant",
content: `深度诊断已完成:\n${res.summary || res.message || "诊断完成,请查看报告。"}`,
});
await nextTick();
options.scrollToLatest();
} catch (e: any) {
options.setError(e, "深度诊断请求失败");
} finally {
sending.value = false;
}
}
async function generateReport() {
inputMsg.value =
inputMsg.value ||
`请根据当前节点${options.selectedNode.value || "(未选定)"}最近关键日志生成一份状态报告(包含症状、影响范围、根因假设与建议)。`;
await send();
}
return {
messages,
visibleMessages,
inputMsg,
sending,
stopGeneration,
loadHistory,
send,
diagnose,
generateReport,
};
}

@ -0,0 +1,20 @@
import { onBeforeUnmount, onMounted, ref } from "vue";
export function useIsMobile(breakpoint = 1024) {
const isMobile = ref(window.innerWidth <= breakpoint);
function update() {
isMobile.value = window.innerWidth <= breakpoint;
}
onMounted(() => {
window.addEventListener("resize", update);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", update);
});
return { isMobile, update };
}

@ -0,0 +1,39 @@
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
export function useScrollBottomHint(options?: { threshold?: number }) {
const threshold = options?.threshold ?? 200;
const el = ref<HTMLElement | null>(null);
const showScrollBottom = ref(false);
function handleScroll() {
if (!el.value) return;
const { scrollTop, scrollHeight, clientHeight } = el.value;
showScrollBottom.value = scrollHeight - scrollTop - clientHeight > threshold;
}
function setEl(v: Element | null) {
el.value = (v as HTMLElement | null) || null;
}
function scrollToBottom(smooth = true) {
nextTick(() => {
if (!el.value) return;
el.value.scrollTo({
top: el.value.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
});
}
watch(el, (next, prev) => {
if (prev) prev.removeEventListener("scroll", handleScroll);
if (next) next.addEventListener("scroll", handleScroll);
});
onBeforeUnmount(() => {
if (el.value) el.value.removeEventListener("scroll", handleScroll);
});
return { el, setEl, showScrollBottom, scrollToBottom };
}

@ -5,3 +5,11 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_TELEMETRY_ENABLED?: string
readonly VITE_TELEMETRY_ENDPOINT?: string
readonly VITE_TELEMETRY_SAMPLE_RATE?: string
readonly VITE_AUTH_REFRESH_ENABLED?: string
readonly VITE_AUTH_REFRESH_ENDPOINT?: string
}

@ -1,5 +1,6 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { trackError, trackEvent } from './telemetry'
// 扩展 AxiosInstance 接口,使其支持解包后的数据类型
declare module 'axios' {
@ -20,6 +21,38 @@ const api: AxiosInstance = axios.create({
timeout: 10000
})
const refreshApi = axios.create({
baseURL: '/api',
timeout: 10000
})
let refreshPromise: Promise<string | null> | null = null
function isRefreshEnabled() {
const raw = String(import.meta.env.VITE_AUTH_REFRESH_ENABLED || '')
if (!raw) return false
return raw.toLowerCase() === 'true'
}
function getRefreshEndpoint() {
return String(import.meta.env.VITE_AUTH_REFRESH_ENDPOINT || '/v1/auth/refresh')
}
async function refreshAccessToken() {
const auth = useAuthStore()
const refreshToken = auth.refreshToken
if (!refreshToken) return null
const endpoint = getRefreshEndpoint()
const r = await refreshApi.post(endpoint, { refreshToken, refresh_token: refreshToken })
const token = r?.data?.token || r?.data?.accessToken || r?.data?.access_token || r?.token || r?.accessToken || r?.access_token
const nextRefresh = r?.data?.refreshToken || r?.data?.refresh_token || r?.refreshToken || r?.refresh_token || refreshToken
if (!token) return null
auth.token = token
auth.refreshToken = nextRefresh || refreshToken
auth.persist()
return token as string
}
// 请求拦截器
api.interceptors.request.use(
(config) => {
@ -44,10 +77,16 @@ api.interceptors.response.use(
if (startTime) {
const duration = new Date().getTime() - startTime.getTime()
console.log(`API [${response.config.method?.toUpperCase()}] ${response.config.url} 耗时: ${duration}ms`)
trackEvent('api_ok', {
method: response.config.method?.toUpperCase() || '',
url: String(response.config.url || '').split('?')[0],
status: response.status,
durationMs: duration
})
}
return response.data // 直接解包 data
},
(error) => {
async (error) => {
const status = error.response?.status
const isRegister = error.config?.url?.includes('/v1/user/register')
let message = '网络异常,请检查您的网络连接'
@ -65,11 +104,46 @@ api.interceptors.response.use(
message = isRegister ? `注册请求失败 (状态码: ${status || '未知'})` : '操作失败'
}
trackError('api_error', error, {
method: error.config?.method?.toUpperCase?.() || '',
url: String(error.config?.url || '').split('?')[0],
status: status || null
})
const shouldTryRefresh =
status === 401 &&
isRefreshEnabled() &&
error?.config &&
!(error.config as any)._retry &&
!String(error.config.url || '').includes('/v1/user/login') &&
!String(error.config.url || '').includes('/v1/user/register') &&
!String(error.config.url || '').includes(getRefreshEndpoint())
if (shouldTryRefresh) {
try {
(error.config as any)._retry = true
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null
})
}
const nextToken = await refreshPromise
if (nextToken) {
error.config.headers = error.config.headers || {}
error.config.headers.Authorization = `Bearer ${nextToken}`
return api.request(error.config)
}
} catch (e) {
void e
}
}
if (status === 401) {
const auth = useAuthStore()
const current = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash
auth.logout()
if (!window.location.hash.includes('login')) {
window.location.hash = '#/login'
window.location.hash = `#/login?redirect=${encodeURIComponent(current || '/diagnosis')}`
}
}

@ -0,0 +1,37 @@
import type { EChartsType } from "echarts/core";
type EchartsModule = {
init: (dom: HTMLElement, theme?: unknown, opts?: unknown) => EChartsType;
use: (plugins: unknown[]) => void;
};
let modulePromise: Promise<EchartsModule> | null = null;
export async function loadEcharts(): Promise<EchartsModule> {
if (modulePromise) return modulePromise;
modulePromise = (async () => {
const echarts = (await import("echarts/core")) as unknown as EchartsModule;
const { PieChart, LineChart } = await import("echarts/charts");
const {
TooltipComponent,
LegendComponent,
GridComponent,
DatasetComponent,
} = await import("echarts/components");
const { CanvasRenderer } = await import("echarts/renderers");
echarts.use([
PieChart,
LineChart,
TooltipComponent,
LegendComponent,
GridComponent,
DatasetComponent,
CanvasRenderer,
]);
return echarts;
})();
return modulePromise;
}

@ -0,0 +1,42 @@
export function formatError(
e: any,
defaultMsg: string,
options?: { mode?: "diagnosis" | "clusterList" }
): string {
const mode = options?.mode || "diagnosis";
if (mode === "clusterList") {
if (e?.response) {
const s = e.response.status;
const d = e.response.data;
let detail = "";
if (d?.detail) {
if (typeof d.detail === "string") detail = d.detail;
else if (Array.isArray(d.detail?.errors)) {
detail = d.detail.errors
.map((x: any) => {
let msg = x?.message || "未知错误";
if (x?.field) msg = `[${x.field}] ${msg}`;
return msg;
})
.join(", ");
}
}
return detail || `请求异常 (${s})`;
}
return e?.message || defaultMsg;
}
if (e instanceof Error && !(e as any).response) {
return e.message || "网络请求异常";
}
const r = e?.response;
const s = r?.status;
const d = r?.data;
const detail = typeof d?.detail === "string" ? d.detail : "";
const msgs: string[] = [];
if (s) msgs.push(`HTTP ${s}`);
if (detail) msgs.push(detail);
if (!msgs.length) msgs.push(r ? defaultMsg : "网络连接异常");
return msgs.join(" | ");
}

@ -0,0 +1,118 @@
type TelemetryLevel = "debug" | "info" | "warn" | "error";
type TelemetryEvent = {
name: string;
level: TelemetryLevel;
ts: number;
context?: Record<string, unknown>;
};
type TelemetryConfig = {
enabled: boolean;
endpoint: string;
sampleRate: number;
getContext?: () => Record<string, unknown>;
};
let config: TelemetryConfig = {
enabled: false,
endpoint: "",
sampleRate: 1,
};
function clamp01(n: number) {
if (Number.isNaN(n)) return 1;
return Math.max(0, Math.min(1, n));
}
function safeString(v: unknown, maxLen = 400) {
const s = typeof v === "string" ? v : JSON.stringify(v);
return s.length > maxLen ? s.slice(0, maxLen) : s;
}
function normalizeError(e: unknown) {
if (e instanceof Error) {
return {
name: e.name,
message: safeString(e.message, 800),
stack: safeString(e.stack || "", 2000),
};
}
return { message: safeString(e, 800) };
}
function shouldSend() {
if (!config.enabled) return false;
const r = clamp01(config.sampleRate);
if (r >= 1) return true;
return Math.random() < r;
}
function mergeContext(extra?: Record<string, unknown>) {
const base = config.getContext ? config.getContext() : {};
return { ...base, ...(extra || {}) };
}
function postEvent(evt: TelemetryEvent) {
if (!config.endpoint) return;
const payload = JSON.stringify(evt);
try {
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon(config.endpoint, blob);
return;
}
} catch (e) {
void e;
}
try {
fetch(config.endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: payload,
keepalive: true,
}).catch(() => {});
} catch (e) {
void e;
}
}
export function initTelemetry(next: Partial<TelemetryConfig>) {
config = {
...config,
...next,
enabled: !!next.enabled,
sampleRate: clamp01(Number(next.sampleRate ?? config.sampleRate)),
endpoint: String(next.endpoint ?? config.endpoint),
};
}
export function trackEvent(name: string, context?: Record<string, unknown>, level: TelemetryLevel = "info") {
if (!shouldSend()) return;
postEvent({ name, level, ts: Date.now(), context: mergeContext(context) });
}
export function trackError(name: string, error: unknown, context?: Record<string, unknown>) {
postEvent({
name,
level: "error",
ts: Date.now(),
context: mergeContext({ error: normalizeError(error), ...(context || {}) }),
});
}
export function installGlobalErrorHandlers() {
window.addEventListener("error", (ev) => {
trackError("window_error", ev.error || ev.message, {
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
});
});
window.addEventListener("unhandledrejection", (ev) => {
trackError("unhandledrejection", ev.reason);
});
}

@ -2,21 +2,52 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './styles/theme.scss'
import { Aim, ArrowDown, ArrowRight, CircleCheck, Edit, Expand, Fold, FullScreen, Lock, Monitor, Moon, More, Plus, Rank, Refresh, Sunny, User, UserFilled, Message } from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import { initTelemetry, installGlobalErrorHandlers, trackEvent } from './lib/telemetry'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.component('Aim', Aim)
app.component('ArrowDown', ArrowDown)
app.component('ArrowRight', ArrowRight)
app.component('CircleCheck', CircleCheck)
app.component('Edit', Edit)
app.component('Expand', Expand)
app.component('Fold', Fold)
app.component('FullScreen', FullScreen)
app.component('Lock', Lock)
app.component('Monitor', Monitor)
app.component('Moon', Moon)
app.component('More', More)
app.component('Plus', Plus)
app.component('Rank', Rank)
app.component('Refresh', Refresh)
app.component('Sunny', Sunny)
app.component('User', User)
app.component('UserFilled', UserFilled)
app.component('Message', Message)
app.use(ElementPlus)
const auth = useAuthStore()
auth.restore()
app.use(router)
initTelemetry({
enabled: String(import.meta.env.VITE_TELEMETRY_ENABLED || '').toLowerCase() === 'true',
endpoint: String(import.meta.env.VITE_TELEMETRY_ENDPOINT || ''),
sampleRate: Number(import.meta.env.VITE_TELEMETRY_SAMPLE_RATE || 1),
getContext: () => ({
route: window.location.hash || '',
userId: auth.user?.id || null,
role: auth.user?.role || null
})
})
installGlobalErrorHandlers()
trackEvent('app_boot')
app.mount('#app')

@ -1,13 +1,14 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { Roles, AllRoles } from '../constants/roles'
import { trackEvent } from '../lib/telemetry'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/diagnosis' },
{ path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { requiresAuth: false, hideSidebar: true } },
{ path: '/register', name: 'register', component: () => import('../views/Register.vue'), meta: { requiresAuth: false, hideSidebar: true } },
{ path: '/cluster-list', name: 'cluster-list', component: () => import('../views/ClusterList.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/cluster-list', name: 'cluster-list', component: () => import('../views/ClusterList.vue'), meta: { requiresAuth: true, roles: AllRoles, breadcrumb: [{ label: '首页', path: '/' }, { label: 'CLUSTER-LIST', path: '/cluster-list' }] } },
{ path: '/dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue'), meta: { requiresAuth: true, roles: AllRoles, breadcrumb: [{ label: '首页', path: '/' }, { label: 'CLUSTER-LIST', path: '/cluster-list' }, { label: 'DASHBOARD', path: '/dashboard' }] } },
{ path: '/logs', name: 'logs', component: () => import('../views/Logs.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/diagnosis', name: 'diagnosis', component: () => import('../views/Diagnosis.vue'), meta: { requiresAuth: true, roles: [Roles.admin, Roles.operator] } },
{ path: '/hadoop-exec-logs', name: 'hadoop-exec-logs', component: () => import('../views/ExecLogs.vue'), meta: { requiresAuth: true, roles: AllRoles } },
@ -22,10 +23,14 @@ const router = createRouter({ history: createWebHashHistory(), routes })
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta || to.meta.requiresAuth === false) return true
if (!auth.isAuthenticated) return { name: 'login' }
if (!auth.isAuthenticated) return { name: 'login', query: { redirect: to.fullPath } }
const roles = to.meta.roles as string[] | undefined
if (roles && !roles.includes(auth.role || '')) return { name: auth.defaultPage }
return true
})
router.afterEach((to, from) => {
trackEvent('route_change', { to: to.fullPath, from: from.fullPath })
})
export default router

@ -19,7 +19,7 @@ function normalizeRole(r: string): 'admin'|'operator'|'observer'|'' {
}
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null as User|null, token: null as string|null }),
state: () => ({ user: null as User|null, token: null as string|null, refreshToken: null as string|null }),
getters: {
isAuthenticated: (s) => !!(s.user && s.token),
role: (s) => s.user?.role || null,
@ -34,14 +34,18 @@ export const useAuthStore = defineStore('auth', {
restore() {
const rawUser = localStorage.getItem('cm_user')
const rawToken = localStorage.getItem('cm_token')
const rawRefreshToken = localStorage.getItem('cm_refresh_token')
if (rawUser && rawToken) {
this.user = JSON.parse(rawUser)
this.token = rawToken
this.refreshToken = rawRefreshToken || null
} else {
this.user = null
this.token = null
this.refreshToken = null
localStorage.removeItem('cm_user')
localStorage.removeItem('cm_token')
localStorage.removeItem('cm_refresh_token')
}
},
persist() {
@ -49,11 +53,14 @@ export const useAuthStore = defineStore('auth', {
else localStorage.removeItem('cm_user')
if (this.token) localStorage.setItem('cm_token', this.token)
else localStorage.removeItem('cm_token')
if (this.refreshToken) localStorage.setItem('cm_refresh_token', this.refreshToken)
else localStorage.removeItem('cm_refresh_token')
},
async login(username: string, password: string) {
try {
const r: any = await AuthService.login({ username, password })
const token = r?.token
const refreshToken = r?.refreshToken || r?.refresh_token || r?.tokens?.refresh || null
const userId = r?.user?.id || r?.id || 0
const backendRoles = (r?.roles || []) as string[]
const backendRoleRaw = (r?.user?.role || r?.role || r?.role_key || (backendRoles.length > 0 ? backendRoles[0] : '')) as string
@ -64,6 +71,7 @@ export const useAuthStore = defineStore('auth', {
}
this.user = { id: userId, username, role }
this.token = token
this.refreshToken = refreshToken
this.persist()
return { ok: true, role }
} catch (e: any) {
@ -80,12 +88,13 @@ export const useAuthStore = defineStore('auth', {
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' || username === 'administrator' ? 'admin' : (username === 'ops' || username === 'operator') ? 'operator' : 'observer')
this.user = { id: userId, username, role }
this.token = r?.token || null
this.refreshToken = r?.refreshToken || r?.refresh_token || r?.tokens?.refresh || null
this.persist()
return { ok: true, role }
} catch (e: any) {
return { ok: false, message: e.friendlyMessage || '注册失败' }
}
},
logout() { this.user = null; this.token = null; this.persist() }
logout() { this.user = null; this.token = null; this.refreshToken = null; this.persist() }
}
})

@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useClusterStore = defineStore('cluster', () => {
// 存储每个集群的采集开关状态,并从 localStorage 初始化
const collectionStates = ref<Record<string, boolean>>(
JSON.parse(localStorage.getItem('collection_states') || '{}')
)
const setCollectionState = (uuid: string, state: boolean) => {
collectionStates.value[uuid] = state
localStorage.setItem('collection_states', JSON.stringify(collectionStates.value))
}
const syncStates = (states: Record<string, boolean>) => {
collectionStates.value = { ...collectionStates.value, ...states }
localStorage.setItem('collection_states', JSON.stringify(collectionStates.value))
}
return {
collectionStates,
setCollectionState,
syncStates
}
})

@ -1,27 +1,46 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useDark, useToggle } from '@vueuse/core'
export const useUIStore = defineStore('ui', {
state: () => ({
sidebarHidden: false,
isLocked: localStorage.getItem('cm_locked') === 'true',
lockPassword: localStorage.getItem('cm_lock_pwd') || ''
}),
actions: {
toggleSidebar() { this.sidebarHidden = !this.sidebarHidden },
hideSidebar() { this.sidebarHidden = true },
showSidebar() { this.sidebarHidden = false },
lock(password: string) {
this.isLocked = true;
this.lockPassword = password;
localStorage.setItem('cm_locked', 'true');
localStorage.setItem('cm_lock_pwd', password);
},
unlock() {
this.isLocked = false;
this.lockPassword = '';
localStorage.removeItem('cm_locked');
localStorage.removeItem('cm_lock_pwd');
}
export const useUIStore = defineStore('ui', () => {
const sidebarHidden = ref(false)
const isLocked = ref(localStorage.getItem('cm_locked') === 'true')
const lockPassword = ref(localStorage.getItem('cm_lock_pwd') || '')
const isDark = useDark()
const toggleTheme = () => {
isDark.value = !isDark.value
}
const toggleSidebar = () => { sidebarHidden.value = !sidebarHidden.value }
const hideSidebar = () => { sidebarHidden.value = true }
const showSidebar = () => { sidebarHidden.value = false }
const lock = (password: string) => {
isLocked.value = true
lockPassword.value = password
localStorage.setItem('cm_locked', 'true')
localStorage.setItem('cm_lock_pwd', password)
}
const unlock = () => {
isLocked.value = false
lockPassword.value = ''
localStorage.removeItem('cm_locked')
localStorage.removeItem('cm_lock_pwd')
}
return {
sidebarHidden,
isLocked,
lockPassword,
isDark,
toggleTheme,
toggleSidebar,
hideSidebar,
showSidebar,
lock,
unlock
}
})

@ -0,0 +1,157 @@
/**
* Element Plus
* SCSS Element Plus
*/
@use "sass:color";
// 1. (SCSS )
$primary-color: #0ea5e9; // (Sky Blue)
$success-color: #10b981; // 绿 (Emerald)
$warning-color: #f59e0b; // (Amber)
$danger-color: #ef4444; // (Rose)
$info-color: #64748b; // (Slate)
// 2.
$bg-color: #f0f9ff; //
$content-bg-color: #f8fafc; //
$card-bg-color: #ffffff; //
$text-primary: #1e293b; // ()
$text-regular: #475569; //
$text-secondary: #64748b; //
$text-placeholder: #94a3b8; //
$border-color: #e2e8f0; //
$border-color-light: #f1f5f9;//
// 3. Element Plus
@function get-light-color($color, $level) {
@return color.mix(#ffffff, $color, $level * 10%);
}
@function get-dark-color($color, $level) {
@return color.mix(#000000, $color, $level * 10%);
}
// 4. CSS
:root {
//
--el-color-primary: #{$primary-color};
@for $i from 1 through 9 {
@if $i == 3 or $i == 5 or $i == 7 or $i == 8 or $i == 9 {
--el-color-primary-light-#{$i}: #{get-light-color($primary-color, $i)};
}
}
--el-color-primary-dark-2: #{get-dark-color($primary-color, 2)};
//
$types: (
"success": $success-color,
"warning": $warning-color,
"danger": $danger-color,
"info": $info-color,
);
@each $type, $color in $types {
--el-color-#{$type}: #{$color};
@for $i from 1 through 9 {
@if $i == 3 or $i == 5 or $i == 7 or $i == 8 or $i == 9 {
--el-color-#{$type}-light-#{$i}: #{get-light-color($color, $i)};
}
}
--el-color-#{$type}-dark-2: #{get-dark-color($color, 2)};
}
//
--el-bg-color: #{$bg-color};
--el-bg-color-page: #{$bg-color};
--el-bg-color-overlay: #{$card-bg-color};
--el-text-color-primary: #{$text-primary};
--el-text-color-regular: #{$text-regular};
--el-text-color-secondary: #{$text-secondary};
--el-text-color-placeholder: #{$text-placeholder};
--el-border-color: #{$border-color};
--el-border-color-light: #{$border-color-light};
--el-fill-color-blank: #{$card-bg-color};
// ()
--app-bg: #{$bg-color};
--app-content-bg: #{$content-bg-color};
--app-card-bg: #{$card-bg-color};
--app-header-bg: #{$card-bg-color};
--app-sidebar-bg: #1e293b; // ()
--app-sidebar-text: #94a3b8; //
--app-sidebar-text-active: #ffffff; //
--app-sidebar-item-hover: #334155; //
--app-text-primary: #{$text-primary};
--app-text-secondary: #{$text-secondary};
--app-border-color: #{$border-color};
}
// body
body {
margin: 0;
color: var(--el-text-color-primary);
background-color: var(--el-bg-color);
}
// element-plus ( View Transition )
.el-card,
.el-header,
.el-aside,
.el-main,
.el-footer,
.el-button,
.el-input__wrapper {
transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s;
}
/**
* View Transitions API
* CSS
*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
.dark::view-transition-old(root) {
z-index: 9999;
}
.dark::view-transition-new(root) {
z-index: 1;
}
// ( element-plus/theme-chalk/dark/css-vars.css )
// --app
html.dark {
--app-bg: #0f172a; // ()
--app-content-bg: #1e293b; // ()
--app-card-bg: #1e293b;
--app-header-bg: #0f172a;
--app-sidebar-bg: #020617; // ()
--app-sidebar-text: #64748b;
--app-sidebar-text-active: #38bdf8;
--app-sidebar-item-hover: #0f172a;
--app-text-primary: #f1f5f9;
--app-text-secondary: #94a3b8;
--app-border-color: #334155;
--el-table-header-bg-color: var(--app-content-bg);
--el-table-bg-color: var(--app-bg);
--el-table-tr-bg-color: var(--app-bg);
}
//
.table-header {
background-color: var(--app-content-bg) !important;
color: var(--app-text-secondary);
font-weight: 600;
}

@ -23,24 +23,24 @@
<el-form :model="registerForm" label-position="top" size="default">
<h4 class="section-subtitle">1. 集群基本信息</h4>
<el-row :gutter="20">
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="集群名称" required>
<el-input v-model="registerForm.name" placeholder="集群名称" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="集群类型">
<el-select v-model="registerForm.type" class="full-width">
<el-option label="Hadoop" value="hadoop" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="节点总数">
<el-input-number v-model="registerForm.node_count" :min="1" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="健康状态">
<el-select v-model="registerForm.health_status" class="full-width">
<el-option label="健康" value="healthy" />
@ -53,22 +53,22 @@
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="NameNode IP">
<el-input v-model="registerForm.namenode_ip" placeholder="NameNode IP" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="NameNode 密码">
<el-input v-model="registerForm.namenode_psw" type="password" show-password placeholder="NameNode 密码" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="RM IP">
<el-input v-model="registerForm.rm_ip" placeholder="ResourceManager IP" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="RM 密码">
<el-input v-model="registerForm.rm_psw" type="password" show-password placeholder="ResourceManager 密码" />
</el-form-item>
@ -83,22 +83,22 @@
<div v-for="(node, idx) in nodes" :key="idx" class="node-config-row">
<div class="node-index">节点 {{ idx + 1 }}</div>
<el-row :gutter="10">
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="主机名" required>
<el-input v-model="node.hostname" placeholder="hostname" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="IP 地址" required>
<el-input v-model="node.ip_address" placeholder="ip_address" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="SSH 用户" required>
<el-input v-model="node.ssh_user" placeholder="ssh_user" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-col :xs="24" :sm="12" :md="6">
<el-form-item label="SSH 密码" required>
<el-input v-model="node.ssh_password" type="password" show-password placeholder="ssh_password" />
</el-form-item>
@ -135,7 +135,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" :width="isMobile ? 120 : 350" fixed="right">
<el-table-column label="操作" :width="isMobile ? 120 : 420" fixed="right">
<template #default="{ row }">
<div v-if="!isMobile" class="table-actions" @click.stop>
<el-button size="small" @click="toDashboard(row)"></el-button>
@ -145,7 +145,7 @@
plain
:disabled="row.health_status === 'healthy'"
:loading="rowLoading[row.uuid]"
@click="startCluster(row.uuid)"
@click="startCluster(row)"
>启动</el-button>
<el-button
size="small"
@ -153,8 +153,17 @@
plain
:disabled="row.health_status !== 'healthy'"
:loading="rowLoading[row.uuid]"
@click="stopCluster(row.uuid)"
@click.stop="stopCluster(row)"
>停止</el-button>
<el-button
size="small"
:type="clusterStore.collectionStates[row.uuid] ? 'danger' : 'success'"
plain
:loading="rowLoading[row.uuid]"
@click.stop="clusterStore.collectionStates[row.uuid] ? stopLogs(row) : collectLogs(row)"
>
{{ clusterStore.collectionStates[row.uuid] ? '停止采集' : '采集日志' }}
</el-button>
<el-popconfirm title="确定要注销此集群吗?" @confirm="unregister(row.uuid)">
<template #reference>
<el-button size="small" type="danger" plain>注销</el-button>
@ -178,6 +187,7 @@
command="stop"
:disabled="row.health_status !== 'healthy'"
>停止</el-dropdown-item>
<el-dropdown-item command="collect">采集日志</el-dropdown-item>
<el-dropdown-item command="unregister" divided class="text-danger">注销</el-dropdown-item>
</el-dropdown-menu>
</template>
@ -187,6 +197,28 @@
</el-table-column>
</el-table>
</el-card>
<!-- 操作日志弹窗 -->
<el-dialog
v-model="showLogs"
:title="logTitle"
:width="isMobile ? '95%' : '60%'"
destroy-on-close
class="log-dialog"
>
<div class="log-terminal">
<div v-for="(line, idx) in executionLogs" :key="idx" class="log-line">
<span class="log-timestamp">[{{ new Date().toLocaleTimeString() }}]</span>
<span class="log-content">{{ line }}</span>
</div>
<div v-if="executionLogs.length === 0" class="log-empty">...</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="showLogs = false"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
@ -194,10 +226,14 @@
import { reactive, ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ClusterService } from '../api/cluster.service'
import { LogService } from '../api/log.service'
import { useClusterStore } from '../stores/cluster'
import { Plus, More } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatError } from '../lib/errors'
const router = useRouter()
const clusterStore = useClusterStore()
const clusters = ref<any[]>([])
const showRegister = ref(false)
const loading = ref(false)
@ -205,6 +241,11 @@ const registering = ref(false)
const err = ref('')
const rowLoading = ref<Record<string, boolean>>({})
//
const showLogs = ref(false)
const logTitle = ref('')
const executionLogs = ref<string[]>([])
const isMobile = ref(window.innerWidth < 768)
const updateWidth = () => {
isMobile.value = window.innerWidth < 768
@ -266,26 +307,6 @@ function getHealthTag(h: string) {
return map[h] || 'info'
}
function formatError(e: any, defaultMsg: string = '操作失败'): string {
if (e?.response) {
const s = e.response.status
const d = e.response.data
let detail = ''
if (d?.detail) {
if (typeof d.detail === 'string') detail = d.detail
else if (Array.isArray(d.detail?.errors)) {
detail = d.detail.errors.map((x: any) => {
let msg = x?.message || '未知错误'
if (x?.field) msg = `[${x.field}] ${msg}`
return msg
}).join(', ')
}
}
return detail || `请求异常 (${s})`
}
return e?.message || defaultMsg
}
async function load() {
loading.value = true
try {
@ -297,13 +318,34 @@ async function load() {
health_status: x.health_status || x.health || 'unknown',
healthText: healthTextOf(x.health_status || x.health)
}))
//
await syncCollectionStatus()
} catch (e: any) {
ElMessage.error(e.friendlyMessage || formatError(e, '加载列表失败'))
ElMessage.error(e.friendlyMessage || formatError(e, '加载列表失败', { mode: 'clusterList' }))
} finally {
loading.value = false
}
}
/** 同步采集状态 */
async function syncCollectionStatus() {
try {
const status = await LogService.getCollectorStatus()
// status UUID
// clusters
const newStates: Record<string, boolean> = {}
clusters.value.forEach(c => {
if (clusterStore.collectionStates[c.uuid] === undefined) {
newStates[c.uuid] = false
}
})
clusterStore.syncStates(newStates)
} catch (e) {
console.error('获取采集状态失败', e)
}
}
async function onRegister() {
if (!registerForm.name) {
ElMessage.warning('请填写集群名称')
@ -330,7 +372,7 @@ async function onRegister() {
cancelRegister()
await load()
} catch (e: any) {
err.value = e.friendlyMessage || formatError(e, '提交失败')
err.value = e.friendlyMessage || formatError(e, '提交失败', { mode: 'clusterList' })
} finally {
registering.value = false
}
@ -342,45 +384,96 @@ async function unregister(id: string) {
ElMessage.success('集群已注销')
await load()
} catch (e: any) {
ElMessage.error(e.friendlyMessage || formatError(e, '注销失败'))
ElMessage.error(e.friendlyMessage || formatError(e, '注销失败', { mode: 'clusterList' }))
}
}
async function startCluster(id: string) {
async function startCluster(row: any) {
const id = typeof row === 'string' ? row : row.uuid
const name = typeof row === 'string' ? id : row.name
rowLoading.value[id] = true
const msg = ElMessage({
message: '正在发送启动命令...',
type: 'info',
duration: 0
})
logTitle.value = `正在启动集群: ${name}`
executionLogs.value = []
showLogs.value = true
try {
await ClusterService.start(id)
msg.close()
ElMessage.success('启动命令已发送')
const res = await ClusterService.start(id)
executionLogs.value = res.logs || ['启动指令已成功发送,正在执行...']
ElMessage.success('启动成功')
await load()
} catch (e: any) {
msg.close()
ElMessage.error(e.friendlyMessage || formatError(e, '启动失败'))
executionLogs.value = e.response?.data?.logs || [e.message || '启动失败']
ElMessage.error(e.friendlyMessage || formatError(e, '启动失败', { mode: 'clusterList' }))
} finally {
rowLoading.value[id] = false
}
}
async function stopCluster(id: string) {
async function stopCluster(row: any) {
const id = typeof row === 'string' ? row : row.uuid
const name = typeof row === 'string' ? id : row.name
rowLoading.value[id] = true
const msg = ElMessage({
message: '正在发送停止命令...',
type: 'info',
duration: 0
})
logTitle.value = `正在停止集群: ${name}`
executionLogs.value = []
showLogs.value = true
try {
await ClusterService.stop(id)
msg.close()
ElMessage.success('停止命令已发送')
const res = await ClusterService.stop(id)
executionLogs.value = res.logs || ['停止指令已成功发送,正在执行...']
ElMessage.success('停止成功')
await load()
} catch (e: any) {
msg.close()
ElMessage.error(e.friendlyMessage || formatError(e, '关闭失败'))
executionLogs.value = e.response?.data?.logs || [e.message || '停止失败']
ElMessage.error(e.friendlyMessage || formatError(e, '关闭失败', { mode: 'clusterList' }))
} finally {
rowLoading.value[id] = false
}
}
async function collectLogs(row: any) {
const id = row.uuid
const name = row.name
rowLoading.value[id] = true
logTitle.value = `正在启动采集: ${name}`
executionLogs.value = []
showLogs.value = true
try {
const res = await LogService.startHadoopCollection(id)
executionLogs.value = res.logs || ['日志采集任务已启动...']
ElMessage.success('日志采集任务已启动')
clusterStore.setCollectionState(id, true)
} catch (e: any) {
executionLogs.value = e.response?.data?.logs || [e.message || '启动采集失败']
ElMessage.error(formatError(e, '启动日志采集失败', { mode: 'clusterList' }))
clusterStore.setCollectionState(id, false) //
} finally {
rowLoading.value[id] = false
}
}
async function stopLogs(row: any) {
const id = row.uuid
rowLoading.value[id] = true
logTitle.value = `正在停止采集: ${row.name}`
executionLogs.value = []
showLogs.value = true
try {
const res = await LogService.stopAllCollections()
executionLogs.value = res.logs || ['已发送停止全部采集指令...']
ElMessage.warning('所有采集任务已停止')
//
const newStates: Record<string, boolean> = {}
clusters.value.forEach(c => { newStates[c.uuid] = false })
clusterStore.syncStates(newStates)
} catch (e: any) {
executionLogs.value = e.response?.data?.logs || [e.message || '停止失败']
ElMessage.error(formatError(e, '停止采集失败', { mode: 'clusterList' }))
clusterStore.setCollectionState(id, true) //
} finally {
rowLoading.value[id] = false
}
@ -397,10 +490,17 @@ function handleAction(command: string, row: any) {
toDashboard(row);
break;
case 'start':
startCluster(row.uuid);
startCluster(row);
break;
case 'stop':
stopCluster(row.uuid);
stopCluster(row);
break;
case 'collect':
if (clusterStore.collectionStates[row.uuid]) {
stopLogs(row);
} else {
collectLogs(row);
}
break;
case 'unregister':
ElMessageBox.confirm('确定要注销此集群吗?', '提示', {
@ -441,12 +541,12 @@ onUnmounted(() => {
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
color: var(--app-text-primary);
margin: 0;
}
.page-subtitle {
color: #6b7280;
color: var(--app-text-secondary);
font-size: 14px;
margin: 4px 0 0 0;
}
@ -473,8 +573,8 @@ onUnmounted(() => {
.section-subtitle {
margin: 0 0 16px 0;
font-size: 15px;
color: #1e293b;
border-left: 4px solid #0ea5e9;
color: var(--app-text-primary);
border-left: 4px solid var(--el-color-primary);
padding-left: 10px;
}
@ -485,27 +585,39 @@ onUnmounted(() => {
.node-config-row {
margin-bottom: 16px;
padding: 16px;
border: 1px solid #e2e8f0;
border: 1px solid var(--app-border-color);
border-radius: 6px;
background-color: #f8fafc;
background-color: var(--app-content-bg);
transition: background-color 0.3s;
}
.node-config-row:hover {
background-color: #f1f5f9;
background-color: var(--app-bg);
}
.node-index {
font-weight: 600;
margin-bottom: 12px;
font-size: 14px;
color: #475569;
color: var(--app-text-secondary);
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--app-border-color);
}
@media (max-width: 768px) {
.form-actions {
flex-direction: column;
}
.form-actions .el-button {
width: 100%;
margin-left: 0 !important;
}
}
.form-alert {
@ -514,7 +626,7 @@ onUnmounted(() => {
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
border: 1px solid var(--app-border-color);
}
.cluster-table {
@ -524,23 +636,20 @@ onUnmounted(() => {
.table-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
}
.full-width {
width: 100%;
}
:deep(.table-header) {
background-color: #f8fafc !important;
color: #475569;
font-weight: 600;
}
.dropdown-header {
font-weight: bold;
color: #333;
color: var(--app-text-primary);
padding: 8px 16px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--app-border-color);
pointer-events: none;
}

@ -6,6 +6,10 @@
<el-tag type="info" effect="plain" class="update-time">
更新时间{{ updateTime }}
</el-tag>
<el-tag v-if="isMonitoring" type="success" effect="dark" round class="status-tag">
<el-icon class="is-loading" style="vertical-align: middle; margin-right: 4px;"><Loading /></el-icon>
后台监控中 ({{ monitorInterval }}s)
</el-tag>
</div>
<div class="cluster-info">
<el-descriptions :column="2" border size="small">
@ -23,29 +27,29 @@
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<el-statistic title="健康节点" :value="healthyCount" value-style="color: #16a34a" />
<el-statistic title="健康节点" :value="healthyCount" value-style="color: var(--el-color-success)" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<el-statistic title="警告节点" :value="warningCount" value-style="color: #f59e0b" />
<el-statistic title="警告节点" :value="warningCount" value-style="color: var(--el-color-warning)" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<el-statistic title="异常节点" :value="errorCount" value-style="color: #dc2626" />
<el-statistic title="异常节点" :value="errorCount" value-style="color: var(--el-color-danger)" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="charts-row">
<el-col :xs="24" :md="12">
<el-card shadow="hover" header="CPU 使用率趋势">
<el-card shadow="hover" header="CPU 使用率比例">
<CpuChart :cluster="meta.uuid" />
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="hover" header="内存使用率趋势">
<el-card shadow="hover" header="内存配额比例">
<MemoryChart :cluster="meta.uuid" />
</el-card>
</el-col>
@ -55,25 +59,29 @@
<template #header>
<div class="card-header">
<span>节点状态详情</span>
<el-button type="primary" link @click="loadNodes"></el-button>
<div class="header-actions">
<template v-if="meta.uuid !== '未选择'">
<el-button v-if="!isMonitoring" type="warning" link @click="collectMetrics" :loading="collecting || syncing"></el-button>
<template v-else>
<el-button type="danger" link @click="stopMonitoring" :loading="syncing">停止监控</el-button>
</template>
<el-button type="primary" link @click="loadNodes" :loading="syncing">刷新列表</el-button>
</template>
</div>
</div>
</template>
<el-table :data="nodes" style="width: 100%" stripe>
<el-table-column prop="name" label="节点名称" min-width="120">
<template #default="{ row }">
<span class="font-bold">{{ row.name }}</span>
<el-tooltip v-if="row.error" :content="row.error" placement="top">
<el-icon class="ml-1 text-red-500" style="vertical-align: middle; margin-left: 4px;"><CircleCloseFilled /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP 地址" width="140" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="cpu" label="CPU 使用率" width="120" />
<el-table-column prop="mem" label="内存使用" width="120" />
<el-table-column prop="mem" label="内存使用率" width="120" />
<el-table-column prop="updated" label="最近更新" width="160" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
@ -90,12 +98,33 @@
</el-table-column>
</el-table>
</el-card>
<!-- 采集进度弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="'正在启动采集: ' + meta.name"
width="60%"
class="log-dialog"
>
<div class="log-terminal">
<div v-if="collectLogs.length === 0" class="log-empty">...</div>
<div v-else v-for="(log, index) in collectLogs" :key="index" class="log-line">
{{ log }}
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="dialogVisible = false"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, computed } from 'vue'
import { onMounted, reactive, computed, ref, watch, onUnmounted } from 'vue'
import { NodeService } from '../api/node.service'
import { MetricService } from '../api/metric.service'
import { useAuthStore } from '../stores/auth'
import CpuChart from '../components/CpuChart.vue'
import MemoryChart from '../components/MemoryChart.vue'
@ -103,25 +132,129 @@ import { ElMessage } from 'element-plus'
const meta = reactive({ uuid: '未选择', name: '-', ip: '-' })
const auth = useAuthStore()
const nodes = reactive<Array<{ name:string; ip:string; status:'healthy'|'warning'|'error'; cpu:string; mem:string; updated:string }>>([])
const nodes = reactive<Array<{ name:string; ip:string; status:'healthy'|'warning'|'error'; cpu:string; mem:string; updated:string; error?:string }>>([])
const collecting = ref(false)
const syncing = ref(false)
const isMonitoring = ref(false)
const monitorInterval = ref(5)
const nodeErrors = ref<Record<string, string>>({})
const dialogVisible = ref(false)
const collectLogs = ref<string[]>([])
let pollTimer: any = null
const updateTime = computed(() => {
const d = new Date()
return `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}`
return `${d.getFullYear()}${d.getMonth()+1}${d.getDate()} ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`
})
const totalCount = computed(() => nodes.length)
const healthyCount = computed(() => nodes.filter(n => n.status==='healthy').length)
const warningCount = computed(() => nodes.filter(n => n.status==='warning').length)
const errorCount = computed(() => nodes.filter(n => n.status==='error').length)
const errorCount = computed(() => nodes.filter(n => n.status==='error' || n.error).length)
// ID
watch(() => meta.uuid, async (newUuid) => {
if (newUuid && newUuid !== '未选择') {
stopPolling() //
await syncMonitorStatus()
await loadNodes()
}
})
onMounted(() => {
onMounted(async () => {
const raw = sessionStorage.getItem('current_cluster')
if (raw) Object.assign(meta, JSON.parse(raw))
loadNodes()
// meta.uuid watch
if (meta.uuid !== '未选择') {
await syncMonitorStatus()
await loadNodes()
}
})
onUnmounted(() => {
stopPolling()
})
async function syncMonitorStatus() {
if (meta.uuid === '未选择') return
syncing.value = true
try {
const status = await MetricService.getCollectorStatus(meta.uuid)
console.log("[DEBUG] Collector Status Response:", status)
// 0/1 "true"/"false"
isMonitoring.value = !!status.is_running
monitorInterval.value = status.interval || 5
nodeErrors.value = status.errors || {}
if (isMonitoring.value) {
startPolling()
} else {
stopPolling()
}
} catch (e) {
console.error("同步监控状态失败", e)
} finally {
syncing.value = false
}
}
function startPolling() {
if (pollTimer) return
pollTimer = setInterval(() => {
loadNodes()
syncMonitorStatus() //
}, monitorInterval.value * 1000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function collectMetrics() {
if (meta.uuid === '未选择') {
ElMessage.warning("请先选择一个集群")
return
}
collectLogs.value = []
dialogVisible.value = true
collecting.value = true
try {
collectLogs.value.push(`[${new Date().toLocaleTimeString()}] 正在启动后台采集线程 (周期: ${monitorInterval.value}s)...`)
await MetricService.startCollector(meta.uuid, monitorInterval.value)
collectLogs.value.push(`[${new Date().toLocaleTimeString()}] 后台采集器启动成功!`)
collectLogs.value.push(`[${new Date().toLocaleTimeString()}] 系统将进入自动刷新模式。`)
isMonitoring.value = true
startPolling()
ElMessage.success("监控采集器已启动")
} catch (e: any) {
const errorMsg = e.friendlyMessage || "启动采集器失败"
collectLogs.value.push(`[${new Date().toLocaleTimeString()}] 错误: ${errorMsg}`)
ElMessage.error(errorMsg)
} finally {
collecting.value = false
}
}
async function stopMonitoring() {
try {
await MetricService.stopCollector(meta.uuid)
isMonitoring.value = false
stopPolling()
ElMessage.warning("后台监控已停止")
} catch (e: any) {
ElMessage.error("停止监控失败")
}
}
async function loadNodes(){
if (meta.uuid === '未选择') return
try{
const list = await NodeService.listByCluster(meta.uuid)
nodes.splice(0, nodes.length, ...list.map((x:any)=>({
@ -130,21 +263,15 @@ async function loadNodes(){
status:x.status,
cpu:x.cpu,
mem:x.mem,
updated:x.updated
updated:x.updated,
error: nodeErrors.value[x.name] //
})))
}catch(e:any){
ElMessage.error("获取节点列表失败")
//
if (!pollTimer) ElMessage.error("获取节点列表失败")
}
}
function statusText(s:'healthy'|'warning'|'error'){
return s==='healthy'?'运行中':s==='warning'?'警告':'异常'
}
function statusTagType(s:'healthy'|'warning'|'error'){
return s==='healthy'?'success':s==='warning'?'warning':'danger'
}
async function start(name:string){
try{
await NodeService.start(name)
@ -197,7 +324,7 @@ async function remove(name:string){
.page-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
color: var(--app-text-primary);
margin: 0;
}
@ -223,7 +350,38 @@ async function remove(name:string){
align-items: center;
}
.header-actions {
display: flex;
gap: 12px;
}
.font-bold {
font-weight: 600;
}
/* 终端样式弹窗 */
.log-terminal {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 4px;
font-family: 'Courier New', Courier, monospace;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
}
.log-empty {
color: #888;
font-style: italic;
text-align: center;
margin-top: 80px;
}
.log-line {
margin-bottom: 8px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>

File diff suppressed because it is too large Load Diff

@ -5,9 +5,7 @@
<h2 class="page-title">集群操作日志</h2>
<p class="page-subtitle">查看与管理修复执行记录支持完整后端同步</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleCreate"></el-button>
</div>
<div class="header-actions"></div>
</div>
<el-card class="table-card" shadow="never">
@ -17,196 +15,137 @@
</div>
</template>
<ExecLogsTable
:records="records"
:records="displayRecords"
:selected-id="selected"
@select="select"
@edit="handleEdit"
@delete="del"
/>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:layout="
isMobile
? 'prev, pager, next'
: 'total, sizes, prev, pager, next, jumper'
"
:pager-count="isMobile ? 5 : 7"
:small="isMobile"
:total="records.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="openCreate ? '新增记录' : '编辑记录'"
:width="isMobile ? '95%' : '600px'"
@closed="cancelForm"
>
<el-form :model="form" label-width="100px" label-position="right">
<el-form-item label="集群名称" required>
<el-input v-model="form.clusterName" placeholder="请输入集群名称" />
</el-form-item>
<el-form-item label="用户">
<el-input v-model="form.username" placeholder="请输入用户" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" placeholder="请选择状态" class="w-full">
<el-option label="Running" value="running" />
<el-option label="Success" value="success" />
<el-option label="Failed" value="failed" />
</el-select>
</el-form-item>
<el-form-item label="开始时间" required>
<el-date-picker
v-model="form.start"
type="datetime"
placeholder="请选择开始时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
</el-form-item>
<el-form-item label="结束时间">
<el-date-picker
v-model="form.end"
type="datetime"
placeholder="请选择结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save"></el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { LogService } from '../api/log.service'
import { useAuthStore } from '../stores/auth'
import ExecLogsTable from '../components/ExecLogsTable.vue'
import { ElMessage } from 'element-plus'
type RecordItem = { id:number; clusterName:string; username:string; description:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
const isMobile = ref(window.innerWidth < 768)
const updateWidth = () => { isMobile.value = window.innerWidth < 768 }
const auth = useAuthStore()
const records = reactive<RecordItem[]>([])
const selected = ref<number|null>(null)
const openCreate = ref(false)
const openEditForm = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const loading = ref(false)
const form = reactive<RecordItem>({ id:0, clusterName:'', username:'', description:'', faultId:'', cmdType:'shell', status:'running', start:'', end:'', code:null })
function select(r: RecordItem){ selected.value = r.id }
function handleCreate() {
openCreate.value = true
openEditForm.value = false
dialogVisible.value = true
}
function handleEdit(r: RecordItem) {
selected.value = r.id
openCreate.value = false
openEditForm.value = true
Object.assign(form, r)
dialogVisible.value = true
import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { LogService } from "../api/log.service";
import { useAuthStore } from "../stores/auth";
import ExecLogsTable from "../components/ExecLogsTable.vue";
import { ElMessage } from "element-plus";
type RecordItem = {
id: number;
clusterName: string;
username: string;
description: string;
faultId: string;
cmdType: string;
status: "running" | "success" | "failed";
start: string;
end: string | "";
code: number | null;
};
const isMobile = ref(window.innerWidth < 768);
const updateWidth = () => {
isMobile.value = window.innerWidth < 768;
};
const auth = useAuthStore();
const records = reactive<RecordItem[]>([]);
const selected = ref<number | null>(null);
const loading = ref(false);
//
const currentPage = ref(1);
const pageSize = ref(20);
//
const displayRecords = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return records.slice(start, end);
});
function handleSizeChange(val: number) {
pageSize.value = val;
currentPage.value = 1;
}
async function del(id:number){
try{
await LogService.removeExecLog(id)
const i = records.findIndex(x=>x.id===id)
if (i>=0) {
records.splice(i,1)
if (selected.value===id) selected.value=null
}
ElMessage.success('删除成功')
}catch(e:any){
ElMessage.error('删除失败:' + (e.friendlyMessage || e.message || '网络错误'))
}
function handleCurrentChange(val: number) {
currentPage.value = val;
}
function cancelForm(){
openCreate.value=false;
openEditForm.value=false;
Object.assign(form, { id:0, clusterName:'', username:'', description:'', faultId:'', cmdType:'shell', status:'running', start:'', end:'', code:null })
function select(r: RecordItem) {
selected.value = r.id;
}
async function save(){
if (!form.clusterName || !form.start) {
ElMessage.warning('请完整填写必填信息')
return
}
const payload = {
from_user_id: auth.user?.id || 0,
cluster_name: form.clusterName,
description: form.description,
fault_id: form.faultId,
command_type: form.cmdType,
execution_status: form.status,
start_time: form.start.replace(' ', 'T'),
end_time: form.end ? form.end.replace(' ', 'T') : null,
exit_code: form.code
}
saving.value = true
try{
if (openCreate.value) {
await LogService.createExecLog(payload)
ElMessage.success('新增成功')
} else if (openEditForm.value) {
if (selected.value === null) return
await LogService.updateExecLog(selected.value, payload)
ElMessage.success('更新成功')
async function del(id: number) {
try {
await LogService.removeExecLog(id);
const i = records.findIndex((x) => x.id === id);
if (i >= 0) {
records.splice(i, 1);
if (selected.value === id) selected.value = null;
}
await load()
dialogVisible.value = false
}catch(e:any){
ElMessage.error('保存失败:' + (e.friendlyMessage || e.message || '网络错误'))
} finally {
saving.value = false
ElMessage.success("删除成功");
} catch (e: any) {
ElMessage.error(
"删除失败:" + (e.friendlyMessage || e.message || "网络错误")
);
}
}
async function load(){
loading.value = true
try{
const items = await LogService.listExecLogs()
const normalized: RecordItem[] = items.map((d:any)=>({
async function load() {
loading.value = true;
try {
const items = await LogService.listExecLogs();
const normalized: RecordItem[] = items.map((d: any) => ({
id: d.id,
clusterName: d.cluster_name || '',
username: d.username || d.user_name || d.user?.username || '',
description: d.description || '',
faultId: d.fault_id || '',
cmdType: d.command_type || '',
status: d.execution_status || 'running',
start: (d.start_time || '').replace('T',' ').slice(0,19),
end: d.end_time ? String(d.end_time).replace('T',' ').slice(0,19) : '',
code: d.exit_code ?? null
}))
records.splice(0, records.length, ...normalized)
}catch(e:any){
ElMessage.error('加载集群操作日志失败:' + (e.friendlyMessage || e.message || '网络错误'))
records.splice(0, records.length)
} finally {
loading.value = false
clusterName: d.cluster_name || "",
username: d.username || d.user_name || d.user?.username || "",
description: d.description || "",
faultId: d.fault_id || "",
cmdType: d.command_type || "",
status: d.execution_status || "running",
start: (d.start_time || "").replace("T", " ").slice(0, 19),
end: d.end_time ? String(d.end_time).replace("T", " ").slice(0, 19) : "",
code: d.exit_code ?? null,
}));
records.splice(0, records.length, ...normalized);
} catch (e: any) {
ElMessage.error(
"加载集群操作日志失败:" + (e.friendlyMessage || e.message || "网络错误")
);
records.splice(0, records.length);
} finally {
loading.value = false;
}
}
onMounted(()=>{
load()
window.addEventListener('resize', updateWidth)
})
onMounted(() => {
load();
window.addEventListener("resize", updateWidth);
});
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
window.removeEventListener("resize", updateWidth);
});
</script>
<style scoped>
@ -254,4 +193,15 @@ onUnmounted(() => {
justify-content: flex-end;
gap: 12px;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.pagination-wrapper {
justify-content: center;
}
}
</style>

@ -1,5 +1,13 @@
<template>
<div class="login-container">
<div class="theme-toggle-wrapper">
<el-button link @click="toggleThemeWithAnimation" class="theme-toggle-btn">
<el-icon :size="24">
<Moon v-if="!isDark" />
<Sunny v-else />
</el-icon>
</el-button>
</div>
<el-card class="login-card">
<div class="login-header">
<el-icon :size="48" color="#0ea5e9"><Monitor /></el-icon>
@ -61,19 +69,64 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useAuthStore } from "../stores/auth";
import { useUIStore } from "../stores/ui";
import { AuthService } from "../api/auth.service";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { Monitor, User, Lock } from '@element-plus/icons-vue'
import { Monitor, User, Lock, Moon, Sunny } from '@element-plus/icons-vue'
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const ui = useUIStore();
const { isDark } = storeToRefs(ui);
const loginFormRef = ref<FormInstance>();
const loading = ref(false);
const health = ref<"ok" | "fail" | "checking">("checking");
function toggleThemeWithAnimation(event: MouseEvent) {
const isAppearanceTransition = (document as any).startViewTransition &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isAppearanceTransition) {
ui.toggleTheme();
return;
}
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
const transition = (document as any).startViewTransition(async () => {
ui.toggleTheme();
});
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: 'ease-in-out',
pseudoElement: isDark.value
? '::view-transition-old(root)'
: '::view-transition-new(root)',
}
);
});
}
const loginForm = reactive({
username: "",
password: ""
@ -103,7 +156,8 @@ async function onSubmit() {
const r = await auth.login(loginForm.username, loginForm.password);
if (r.ok) {
ElMessage.success("登录成功");
router.replace({ name: auth.defaultPage });
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "";
router.replace(redirect || { name: auth.defaultPage });
} else {
ElMessage.error(r.message || "登录失败!");
}
@ -122,6 +176,14 @@ async function onSubmit() {
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
}
.theme-toggle-wrapper {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.login-card {
@ -129,9 +191,8 @@ async function onSubmit() {
max-width: 400px;
border-radius: 12px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
:deep(.el-card__body) {
@ -146,13 +207,13 @@ async function onSubmit() {
.login-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
color: var(--app-text-primary);
margin: 12px 0 4px;
}
.login-subtitle {
font-size: 14px;
color: #64748b;
color: var(--app-text-secondary);
}
:deep(.el-form-item) {
@ -169,7 +230,7 @@ async function onSubmit() {
.register-link {
text-align: center;
font-size: 14px;
color: #64748b;
color: var(--app-text-secondary);
margin-top: 8px;
}

@ -11,50 +11,51 @@
<el-button type="primary" link @click="clear"></el-button>
</div>
</template>
<el-form :model="q" label-position="top" size="default">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-form-item label="日志级别">
<el-select v-model="q.level" placeholder="全部级别" clearable class="w-full">
<el-option label="DEBUG" value="debug" />
<el-option label="INFO" value="info" />
<el-option label="WARN" value="warn" />
<el-option label="ERROR" value="error" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-form-item label="来源集群">
<el-select v-model="q.cluster" placeholder="全部集群" clearable class="w-full">
<el-option v-for="c in clustersOpts" :key="c" :label="c" :value="c" />
<el-select
v-model="q.cluster"
placeholder="全部集群"
clearable
class="w-full"
>
<el-option
v-for="c in clustersOpts"
:key="c"
:label="c"
:value="c"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-form-item label="来源节点">
<el-select v-model="q.node" placeholder="全部节点" clearable class="w-full">
<el-option v-for="n in nodesOpts" :key="n" :label="n" :value="n" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-form-item label="操作类型">
<el-select v-model="q.op" placeholder="全部类型" clearable class="w-full">
<el-option v-for="o in opsOpts" :key="o" :label="o" :value="o" />
<el-select
v-model="q.node"
placeholder="全部节点"
clearable
class="w-full"
>
<el-option
v-for="n in nodesOpts"
:key="n"
:label="n"
:value="n"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-form-item label="来源">
<el-select v-model="q.source" placeholder="全部来源" clearable class="w-full">
<el-option v-for="s in sourcesOpts" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-form-item label="时间范围">
<el-select v-model="q.timeRange" placeholder="全部时间" clearable class="w-full">
<el-select
v-model="q.timeRange"
placeholder="全部时间"
clearable
class="w-full"
>
<el-option label="最近1小时" value="1h" />
<el-option label="最近6小时" value="6h" />
<el-option label="最近24小时" value="24h" />
@ -65,7 +66,9 @@
</el-row>
</el-form>
<div class="filter-summary">
<el-tag type="info" variant="plain" size="small">当前筛选{{ summary }}</el-tag>
<el-tag type="info" variant="plain" size="small"
>当前筛选{{ summary }}</el-tag
>
</div>
</el-card>
@ -77,23 +80,29 @@
style="width: 100%"
header-cell-class-name="table-header"
>
<el-table-column label="时间" width="120">
<template #default="{ row }">
{{ row.time.split('T')[1]?.slice(0, 8) || row.time }}
</template>
</el-table-column>
<el-table-column label="级别" width="100">
<el-table-column label="时间" width="180">
<template #default="{ row }">
<el-tag :type="getLevelTag(row.level)" size="small" effect="dark">
{{ row.level.toUpperCase() }}
</el-tag>
{{ row.time }}
</template>
</el-table-column>
<el-table-column prop="cluster" label="集群" width="150" show-overflow-tooltip />
<el-table-column prop="node" label="节点" width="150" show-overflow-tooltip />
<el-table-column prop="op" label="操作" width="120" />
<el-table-column prop="source" label="来源" width="150" show-overflow-tooltip />
<el-table-column prop="message" label="消息" min-width="300" show-overflow-tooltip />
<el-table-column
prop="cluster"
label="集群"
width="150"
show-overflow-tooltip
/>
<el-table-column
prop="node"
label="节点"
width="150"
show-overflow-tooltip
/>
<el-table-column
prop="info"
label="详细内容"
min-width="300"
show-overflow-tooltip
/>
</el-table>
<div class="pagination-container">
@ -101,7 +110,13 @@
v-model:current-page="page"
v-model:page-size="size"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:layout="
isMobile
? 'prev, pager, next'
: 'total, sizes, prev, pager, next, jumper'
"
:pager-count="isMobile ? 5 : 7"
:small="isMobile"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@ -112,116 +127,130 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onMounted } from 'vue'
import { LogService } from '../api/log.service'
import { useAuthStore } from '../stores/auth'
import { computed, reactive, ref, watch, onMounted, onUnmounted } from "vue";
import { LogService } from "../api/log.service";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore()
const data = ref<{ id:number; time:string; level:string; cluster:string; node:string; op:string; source:string; message:string }[]>([])
const page = ref(1)
const size = ref(20)
const total = ref(0)
const loading = ref(false)
const err = ref('')
const q = reactive({ level:'', cluster:'', node:'', op:'', source:'', timeRange:'' })
const clustersOpts = ref<string[]>([])
const nodesOpts = ref<string[]>([])
const opsOpts = ref<string[]>([])
const sourcesOpts = ref<string[]>([])
const auth = useAuthStore();
const isMobile = ref(window.innerWidth < 768);
const updateWidth = () => {
isMobile.value = window.innerWidth < 768;
};
function getLevelTag(level: string) {
const map: Record<string, string> = {
'debug': 'info',
'info': 'success',
'warn': 'warning',
'error': 'danger'
}
return map[level.toLowerCase()] || 'info'
}
const data = ref<
{
id: number | string;
time: string;
cluster: string;
node: string;
info: string;
}[]
>([]);
const page = ref(1);
const size = ref(20);
const total = ref(0);
const loading = ref(false);
const err = ref("");
const q = reactive({ cluster: "", node: "", timeRange: "" });
const clustersOpts = ref<string[]>([]);
const nodesOpts = ref<string[]>([]);
function rangeFromNow(r:string){
const now = Date.now()
const span = r==='1h'?60*60*1000:r==='6h'?6*60*60*1000:r==='24h'?24*60*60*1000:r==='7d'?7*24*60*60*1000:0
return span? new Date(now-span).toISOString() : ''
function rangeFromNow(r: string) {
const now = Date.now();
const span =
r === "1h"
? 60 * 60 * 1000
: r === "6h"
? 6 * 60 * 60 * 1000
: r === "24h"
? 24 * 60 * 60 * 1000
: r === "7d"
? 7 * 24 * 60 * 60 * 1000
: 0;
return span ? new Date(now - span).toISOString() : "";
}
async function load(){
loading.value = true
err.value = ''
try{
const params: any = { page: page.value, size: size.value }
if (q.level) params.level = q.level
if (q.cluster) params.cluster = q.cluster
if (q.node) params.node = q.node
if (q.op) params.op = q.op
if (q.source) params.source = q.source
if (q.timeRange) params.time_from = rangeFromNow(q.timeRange)
const { items, total: t } = await LogService.list(params)
const normalized = items.map((d:any)=>({
id: d.log_id || d.id,
time: d.log_time || d.timestamp || '',
level: d.level || 'info',
cluster: d.cluster_name || d.cluster || '',
node: d.node_host || d.node || d.host || '',
op: d.op || '',
source: d.title || d.source || d.service || '',
message: d.info || d.message || ''
}))
data.value = normalized
total.value = t
if (!clustersOpts.value.length) clustersOpts.value = Array.from(new Set(items.map((d:any)=>d.cluster).filter(Boolean)))
if (!nodesOpts.value.length) nodesOpts.value = Array.from(new Set(items.map((d:any)=>d.node).filter(Boolean)))
if (!opsOpts.value.length) opsOpts.value = Array.from(new Set(items.map((d:any)=>d.op).filter(Boolean)))
if (!sourcesOpts.value.length) sourcesOpts.value = Array.from(new Set(normalized.map((d:any)=>d.source).filter(Boolean)))
}catch(e:any){
err.value = e.friendlyMessage || e?.response?.data?.detail || '加载失败'
} finally{
loading.value = false
async function load() {
loading.value = true;
err.value = "";
try {
const params: any = { page: page.value, size: size.value };
if (q.cluster) params.cluster = q.cluster;
if (q.node) params.node = q.node;
if (q.timeRange) params.time_from = rangeFromNow(q.timeRange);
const { items, total: t } = await LogService.list(params);
const normalized = items.map((d: any) => ({
id: d.id,
time: d.time || "",
cluster: d.cluster || "",
node: d.node || "",
info: d.info || "",
}));
data.value = normalized;
total.value = t;
if (!clustersOpts.value.length)
clustersOpts.value = Array.from(
new Set(items.map((d: any) => d.cluster).filter(Boolean))
);
if (!nodesOpts.value.length)
nodesOpts.value = Array.from(
new Set(items.map((d: any) => d.node).filter(Boolean))
);
} catch (e: any) {
err.value = e.friendlyMessage || e?.response?.data?.detail || "加载失败";
} finally {
loading.value = false;
}
}
function clear() {
q.level=''; q.cluster=''; q.node=''; q.op=''; q.source=''; q.timeRange='';
page.value=1
function clear() {
q.cluster = "";
q.node = "";
q.timeRange = "";
page.value = 1;
}
const handleSizeChange = (val: number) => {
size.value = val
page.value = 1
load()
}
size.value = val;
page.value = 1;
load();
};
const handleCurrentChange = (val: number) => {
page.value = val
load()
}
page.value = val;
load();
};
const pageData = computed(() => {
const s = q.source.trim().toLowerCase()
let list = data.value
if (s) list = list.filter(d => String(d.source || '').toLowerCase().includes(s))
return list
})
const pageData = computed(() => data.value);
watch(() => ({...q}), () => {
page.value = 1
load()
}, { deep: true })
//
watch(
() => ({ ...q }),
() => {
page.value = 1;
load();
},
{ deep: true }
);
onMounted(()=>{ load() })
onMounted(() => {
load();
window.addEventListener("resize", updateWidth);
});
onUnmounted(() => {
window.removeEventListener("resize", updateWidth);
});
const summary = computed(() => {
const parts = [] as string[]
if (q.level) parts.push(`级别=${q.level}`)
if (q.cluster) parts.push(`集群=${q.cluster}`)
if (q.node) parts.push(`节点=${q.node}`)
if (q.op) parts.push(`类型=${q.op}`)
if (q.source) parts.push(`来源=${q.source}`)
if (q.timeRange) parts.push(`时间=${q.timeRange}`)
return parts.length? parts.join('') : '无'
})
const parts = [] as string[];
if (q.cluster) parts.push(`集群=${q.cluster}`);
if (q.node) parts.push(`节点=${q.node}`);
if (q.timeRange) parts.push(`时间=${q.timeRange}`);
return parts.length ? parts.join("") : "无";
});
</script>
<style scoped>
@ -238,13 +267,15 @@ const summary = computed(() => {
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
color: var(--app-text-primary);
margin: 0;
}
.filter-card, .table-card {
.filter-card,
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
border: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
.card-header {
@ -268,9 +299,9 @@ const summary = computed(() => {
justify-content: flex-end;
}
:deep(.table-header) {
background-color: #f8fafc !important;
color: #475569;
font-weight: 600;
@media (max-width: 768px) {
.pagination-container {
justify-content: center;
}
}
</style>

@ -7,7 +7,6 @@
</div>
<div class="header-actions">
<el-button @click="refresh" :loading="loading">刷新</el-button>
<el-button type="primary" disabled title="功能待实现">导出操作日志</el-button>
</div>
</div>
@ -108,28 +107,23 @@ onMounted(() => {
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
color: var(--app-text-primary);
margin: 0;
}
.page-subtitle {
color: #6b7280;
color: var(--app-text-secondary);
font-size: 14px;
margin: 4px 0 0 0;
}
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
border: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
.card-header {
font-weight: 600;
}
:deep(.table-header) {
background-color: #f8fafc !important;
color: #475569;
font-weight: 600;
}
</style>

@ -5,7 +5,6 @@
<h2 class="page-title">个人主页</h2>
<p class="page-subtitle">查看与管理个人基础信息</p>
</div>
<el-button disabled>编辑资料</el-button>
</div>
<el-card v-loading="loading" shadow="never" class="info-card">
@ -110,23 +109,25 @@ onMounted(async () => {
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
color: var(--app-text-primary);
margin: 0;
}
.page-subtitle {
color: #6b7280;
color: var(--app-text-secondary);
font-size: 14px;
margin: 4px 0 0 0;
}
.info-card {
border-radius: 8px;
border: 1px solid #ebeef5;
border: 1px solid var(--app-border-color);
background-color: var(--app-card-bg);
}
.card-header {
font-weight: 600;
color: var(--app-text-primary);
}
.info-item {
@ -136,17 +137,17 @@ onMounted(async () => {
}
.info-label {
color: #6b7280;
color: var(--app-text-secondary);
font-size: 14px;
}
.info-value {
font-weight: 500;
color: #1f2937;
color: var(--app-text-primary);
}
.error-msg {
color: #ef4444;
color: var(--el-color-danger);
font-size: 14px;
}
</style>

@ -1,5 +1,13 @@
<template>
<div class="register-container">
<div class="theme-toggle-wrapper">
<el-button link @click="toggleThemeWithAnimation" class="theme-toggle-btn">
<el-icon :size="24">
<Moon v-if="!isDark" />
<Sunny v-else />
</el-icon>
</el-button>
</div>
<el-card class="register-card">
<div class="register-header">
<el-icon :size="48" color="#0ea5e9"><User /></el-icon>
@ -85,15 +93,59 @@
<script setup lang="ts">
import { ref, reactive } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useAuthStore } from "../stores/auth";
import { User, Lock, Message, CircleCheck, Edit } from "@element-plus/icons-vue";
import { useUIStore } from "../stores/ui";
import { User, Lock, Message, CircleCheck, Edit, Moon, Sunny } from "@element-plus/icons-vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
const router = useRouter();
const auth = useAuthStore();
const ui = useUIStore();
const { isDark } = storeToRefs(ui);
const registerFormRef = ref<FormInstance>();
const loading = ref(false);
function toggleThemeWithAnimation(event: MouseEvent) {
const isAppearanceTransition = (document as any).startViewTransition &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isAppearanceTransition) {
ui.toggleTheme();
return;
}
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
const transition = (document as any).startViewTransition(async () => {
ui.toggleTheme();
});
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: 'ease-in-out',
pseudoElement: isDark.value
? '::view-transition-old(root)'
: '::view-transition-new(root)',
}
);
});
}
const registerForm = reactive({
username: "",
password: "",
@ -168,11 +220,20 @@ async function onSubmit() {
<style scoped>
.register-container {
flex: 1;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
align-items: center;
padding: 40px 20px;
background-color: var(--app-bg);
position: relative;
}
.theme-toggle-wrapper {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.register-card {
@ -180,9 +241,8 @@ async function onSubmit() {
max-width: 450px;
border-radius: 12px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
:deep(.el-card__body) {
@ -197,13 +257,13 @@ async function onSubmit() {
.register-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
color: var(--app-text-primary);
margin: 12px 0 4px;
}
.register-subtitle {
font-size: 14px;
color: #64748b;
color: var(--app-text-secondary);
}
:deep(.el-form-item) {
@ -212,7 +272,7 @@ async function onSubmit() {
.form-tip {
font-size: 12px;
color: #94a3b8;
color: var(--el-text-color-placeholder);
margin-top: 4px;
line-height: 1.4;
}
@ -233,6 +293,6 @@ async function onSubmit() {
text-align: center;
margin-top: 8px;
font-size: 14px;
color: #64748b;
color: var(--app-text-secondary);
}
</style>

@ -7,7 +7,7 @@
<el-card class="table-card" shadow="never">
<el-table
:data="users"
:data="displayUsers"
stripe
style="width: 100%"
header-cell-class-name="table-header"
@ -43,15 +43,20 @@
plain
v-if="row.status !== 'disabled'"
@click="ban(row.username)"
>封禁</el-button>
>封禁</el-button
>
<el-button
size="small"
type="success"
plain
v-else
@click="unban(row.username)"
>解禁</el-button>
<el-popconfirm title="确认删除此用户?" @confirm="del(row.username)">
>解禁</el-button
>
<el-popconfirm
title="确认删除此用户?"
@confirm="del(row.username)"
>
<template #reference>
<el-button size="small" type="danger" plain>删除</el-button>
</template>
@ -59,6 +64,24 @@
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:layout="
isMobile
? 'prev, pager, next'
: 'total, sizes, prev, pager, next, jumper'
"
:pager-count="isMobile ? 5 : 7"
:small="isMobile"
:total="users.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<el-dialog
@ -78,10 +101,20 @@
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" required>
<el-input v-model="form.confirmPassword" type="password" placeholder="请再次确认密码" show-password />
<el-input
v-model="form.confirmPassword"
type="password"
placeholder="请再次确认密码"
show-password
/>
</el-form-item>
<el-form-item label="角色" required>
<el-select v-model="form.role" class="w-full">
@ -101,7 +134,9 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="open = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save"></el-button>
<el-button type="primary" :loading="saving" @click="save"
>保存用户</el-button
>
</div>
</template>
</el-dialog>
@ -109,141 +144,189 @@
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { UserService } from '../api/user.service'
import { ElMessage } from 'element-plus'
import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { useAuthStore } from "../stores/auth";
import { UserService } from "../api/user.service";
import { ElMessage } from "element-plus";
const auth = useAuthStore()
const isMobile = ref(window.innerWidth < 768)
const updateWidth = () => { isMobile.value = window.innerWidth < 768 }
const auth = useAuthStore();
const isMobile = ref(window.innerWidth < 768);
const updateWidth = () => {
isMobile.value = window.innerWidth < 768;
};
const users = reactive<{ username:string; email:string; role:string; status:string }[]>([])
const open = ref(false)
const saving = ref(false)
const changingRoleUser = ref('')
const form = reactive({ username:'', fullName:'', email:'', password:'', confirmPassword:'', role:'operator', status:'enabled' })
const users = reactive<
{ username: string; email: string; role: string; status: string }[]
>([]);
const open = ref(false);
const saving = ref(false);
const changingRoleUser = ref("");
const form = reactive({
username: "",
fullName: "",
email: "",
password: "",
confirmPassword: "",
role: "operator",
status: "enabled",
});
function statusName(s:string){
if(s==='enabled') return '启用'
if(s==='pending') return '待审核'
if(s==='disabled') return '禁用'
return s
//
const currentPage = ref(1);
const pageSize = ref(10);
//
const displayUsers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return users.slice(start, end);
});
function handleSizeChange(val: number) {
pageSize.value = val;
currentPage.value = 1;
}
function handleCurrentChange(val: number) {
currentPage.value = val;
}
function statusName(s: string) {
if (s === "enabled") return "启用";
if (s === "pending") return "待审核";
if (s === "disabled") return "禁用";
return s;
}
function statusType(s: string) {
if (s === 'enabled') return 'success'
if (s === 'pending') return 'warning'
if (s === 'disabled') return 'danger'
return 'info'
if (s === "enabled") return "success";
if (s === "pending") return "warning";
if (s === "disabled") return "danger";
return "info";
}
async function load(){
try{
const data = await UserService.list()
const list = Array.isArray(data) ? data.map((u: any) => ({
username: u.username || u.user_name || u.name,
email: u.email || u.mail,
role: u.role,
status: u.status
})) : []
users.splice(0, users.length, ...list)
} catch(e:any){
ElMessage.error(e.friendlyMessage || '加载失败')
async function load() {
try {
const data = await UserService.list();
const list = Array.isArray(data)
? data.map((u: any) => ({
username: u.username || u.user_name || u.name,
email: u.email || u.mail,
role: u.role,
status: u.status,
}))
: [];
users.splice(0, users.length, ...list);
} catch (e: any) {
ElMessage.error(e.friendlyMessage || "加载失败");
}
}
async function save(){
if(!form.username||!form.fullName||!form.email||!form.role||!form.status){
ElMessage.warning('请填写完整信息')
return
async function save() {
if (
!form.username ||
!form.fullName ||
!form.email ||
!form.role ||
!form.status
) {
ElMessage.warning("请填写完整信息");
return;
}
if(!form.password||!form.confirmPassword){
ElMessage.warning('请输入密码并确认')
return
if (!form.password || !form.confirmPassword) {
ElMessage.warning("请输入密码并确认");
return;
}
if(form.password!==form.confirmPassword){
ElMessage.warning('两次密码不一致')
return
if (form.password !== form.confirmPassword) {
ElMessage.warning("两次密码不一致");
return;
}
try{
saving.value = true
const payload = {
username: form.username,
full_name: form.fullName,
email: form.email,
role: form.role,
status: form.status,
password: form.password
}
await UserService.create(payload)
ElMessage.success('保存成功')
await load()
open.value = false
} catch(e:any){
ElMessage.error(e.friendlyMessage || '保存失败')
try {
saving.value = true;
const payload = {
username: form.username,
full_name: form.fullName,
email: form.email,
role: form.role,
status: form.status,
password: form.password,
};
await UserService.create(payload);
ElMessage.success("保存成功");
await load();
open.value = false;
} catch (e: any) {
ElMessage.error(e.friendlyMessage || "保存失败");
} finally {
saving.value = false
saving.value = false;
}
}
function cancel(){
open.value=false
Object.assign(form, { username:'', fullName:'', email:'', password:'', confirmPassword:'', role:'operator', status:'enabled' })
function cancel() {
open.value = false;
Object.assign(form, {
username: "",
fullName: "",
email: "",
password: "",
confirmPassword: "",
role: "operator",
status: "enabled",
});
}
async function ban(u:string){
try{
await UserService.update(u, { status:'disabled' })
ElMessage.success('已封禁')
await load()
} catch(e:any){
ElMessage.error(e.friendlyMessage || '操作失败')
}
async function ban(u: string) {
try {
await UserService.update(u, { status: "disabled" });
ElMessage.success("已封禁");
await load();
} catch (e: any) {
ElMessage.error(e.friendlyMessage || "操作失败");
}
}
async function unban(u:string){
try{
await UserService.update(u, { status:'enabled' })
ElMessage.success('已解禁')
await load()
} catch(e:any){
ElMessage.error(e.friendlyMessage || '操作失败')
}
async function unban(u: string) {
try {
await UserService.update(u, { status: "enabled" });
ElMessage.success("已解禁");
await load();
} catch (e: any) {
ElMessage.error(e.friendlyMessage || "操作失败");
}
}
async function del(u:string){
try{
await UserService.remove(u)
ElMessage.success('已删除')
await load()
} catch(e:any){
ElMessage.error(e.friendlyMessage || '删除失败')
}
async function del(u: string) {
try {
await UserService.remove(u);
ElMessage.success("已删除");
await load();
} catch (e: any) {
ElMessage.error(e.friendlyMessage || "删除失败");
}
}
async function onRoleChange(u:string, r:string){
try{
changingRoleUser.value=u
await UserService.update(u, { role:r })
ElMessage.success('角色已更新')
await load()
} catch(e:any){
ElMessage.error(e.friendlyMessage || '修改角色失败')
} finally {
changingRoleUser.value=''
}
async function onRoleChange(u: string, r: string) {
try {
changingRoleUser.value = u;
await UserService.update(u, { role: r });
ElMessage.success("角色已更新");
await load();
} catch (e: any) {
ElMessage.error(e.friendlyMessage || "修改角色失败");
} finally {
changingRoleUser.value = "";
}
}
onMounted(() => {
load()
window.addEventListener('resize', updateWidth)
})
load();
window.addEventListener("resize", updateWidth);
});
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
window.removeEventListener("resize", updateWidth);
});
</script>
<style scoped>
@ -263,19 +346,14 @@ onUnmounted(() => {
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
color: var(--app-text-primary);
margin: 0;
}
.table-card {
border-radius: 8px;
border: 1px solid #ebeef5;
}
:deep(.table-header) {
background-color: #f8fafc !important;
color: #475569;
font-weight: 600;
border: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
.w-full {
@ -287,4 +365,16 @@ onUnmounted(() => {
justify-content: flex-end;
gap: 12px;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.pagination-wrapper {
justify-content: center;
}
}
</style>

@ -0,0 +1,240 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Diagnosis from "../Diagnosis.vue";
const mocks = vi.hoisted(() => ({
elMessageWarning: vi.fn(),
elMessageError: vi.fn(),
mockClusterList: vi.fn(),
mockNodesByCluster: vi.fn(),
mockGetHistory: vi.fn(),
mockDiagnoseRepair: vi.fn(),
}));
vi.mock("element-plus", () => ({
ElMessage: {
warning: mocks.elMessageWarning,
error: mocks.elMessageError,
},
}));
vi.mock("../../api/cluster.service", () => ({
ClusterService: { list: () => mocks.mockClusterList() },
}));
vi.mock("../../api/node.service", () => ({
NodeService: { listByCluster: (uuid: string) => mocks.mockNodesByCluster(uuid) },
}));
vi.mock("../../api/diagnosis.service", () => ({
DiagnosisService: {
getHistory: (sid: string) => mocks.mockGetHistory(sid),
diagnoseRepair: (p: any) => mocks.mockDiagnoseRepair(p),
},
}));
vi.mock("../../stores/auth", () => ({
useAuthStore: () => ({ token: "test-token" }),
}));
function flush() {
return new Promise((r) => setTimeout(r, 0));
}
const stubs = {
SplitPane: {
template: "<div><slot name='first' /><slot name='second' /></div>",
},
LogViewer: {
template: "<div></div>",
},
transition: { template: "<div><slot /></div>" },
"el-icon": { template: "<i><slot /></i>" },
"el-tag": { props: ["type", "size"], template: "<span><slot /></span>" },
"el-scrollbar": { template: "<div><slot /></div>" },
"el-card": {
template:
'<section class="el-card"><header class="el-card__header"><slot name="header"/></header><div class="el-card__body"><slot /></div></section>',
},
"el-select": {
props: ["modelValue"],
emits: ["update:modelValue"],
template:
'<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target && $event.target.value)"><slot /></select>',
},
"el-option": { template: "<option></option>" },
"el-input": {
props: ["modelValue", "type", "rows", "disabled", "placeholder"],
emits: ["update:modelValue"],
template:
'<textarea :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target && $event.target.value)" />',
},
"el-button": {
props: ["disabled", "type", "plain", "circle", "loading", "link"],
emits: ["click"],
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
},
"el-checkbox": {
props: ["modelValue", "label", "disabled"],
emits: ["update:modelValue"],
template:
'<label><input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target && $event.target.checked)" /><span><slot /></span></label>',
},
"el-collapse": { template: "<div><slot /></div>" },
"el-collapse-item": { template: "<div><slot /></div>" },
};
describe("Diagnosis关键链路", () => {
beforeEach(() => {
mocks.elMessageWarning.mockReset();
mocks.elMessageError.mockReset();
mocks.mockClusterList.mockReset();
mocks.mockNodesByCluster.mockReset();
mocks.mockGetHistory.mockReset();
mocks.mockDiagnoseRepair.mockReset();
});
it("页面能打开、选择节点会拉取历史", async () => {
mocks.mockClusterList.mockResolvedValue([
{ uuid: "c1", host: "cluster-1", count: 1 },
]);
mocks.mockNodesByCluster.mockResolvedValue([{ name: "node-1", status: "running" }]);
mocks.mockGetHistory
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: "global-history" }],
})
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: "node-history" }],
});
const wrapper = mount(Diagnosis, {
global: { stubs },
attachTo: document.body,
});
await flush();
await flush();
expect(mocks.mockGetHistory).toHaveBeenCalledWith("diagnosis-global");
const groupHeader = wrapper.find(".group-header");
expect(groupHeader.exists()).toBe(true);
await groupHeader.trigger("click");
await flush();
const nodeItem = wrapper.find(".node-item-v");
expect(nodeItem.exists()).toBe(true);
await nodeItem.trigger("click");
await flush();
expect(mocks.mockGetHistory).toHaveBeenCalledWith("diagnosis-node-1");
});
it("发送消息、停止、深度诊断按钮校验、滚动到底部提示", async () => {
mocks.mockClusterList.mockResolvedValue([{ uuid: "c1", host: "cluster-1", count: 1 }]);
mocks.mockNodesByCluster.mockResolvedValue([{ name: "node-1", status: "running" }]);
mocks.mockGetHistory.mockResolvedValue({ messages: [{ role: "assistant", content: "history" }] });
mocks.mockDiagnoseRepair.mockResolvedValue({ summary: "ok" });
const wrapper = mount(Diagnosis, {
global: { stubs },
attachTo: document.body,
});
await flush();
await flush();
await wrapper.find(".group-header").trigger("click");
await flush();
await wrapper.find(".node-item-v").trigger("click");
await flush();
mocks.elMessageWarning.mockReset();
const deepBtn = wrapper.findAll("button").find((b) => b.text().includes("深度诊断"));
expect(deepBtn).toBeTruthy();
const wrapperNoNode = mount(Diagnosis, { global: { stubs } });
await flush();
const deepBtn2 = wrapperNoNode.findAll("button").find((b) => b.text().includes("深度诊断"));
expect(deepBtn2).toBeTruthy();
await deepBtn2!.trigger("click");
expect(mocks.elMessageWarning).toHaveBeenCalled();
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn((_: any, init: any) => {
return new Promise((_resolve, reject) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) return reject({ name: "AbortError" });
const onAbort = () => reject({ name: "AbortError" });
if (signal.aborted) return reject({ name: "AbortError" });
signal.addEventListener("abort", onAbort, { once: true });
}) as any;
}) as any;
const textarea = wrapper.find("textarea");
await textarea.setValue("hello");
const sendBtn = wrapper.findAll("button").find((b) => b.text().trim() === "发送");
expect(sendBtn).toBeTruthy();
await sendBtn!.trigger("click");
await flush();
const stopBtn = wrapper.findAll("button").find((b) => b.text().includes("停止"));
expect(stopBtn).toBeTruthy();
await stopBtn!.trigger("click");
await flush();
globalThis.fetch = originalFetch as any;
globalThis.fetch = vi.fn(async () => {
const text = [
'data: {"content":"hi"}\n',
'data: {"content":"!"}\n',
"data: [DONE]\n",
].join("");
let used = false;
return {
ok: true,
body: {
getReader() {
return {
read: async () => {
if (used) return { done: true, value: undefined };
used = true;
return { done: false, value: new TextEncoder().encode(text) };
},
};
},
},
} as any;
}) as any;
await textarea.setValue("ping");
const sendBtnAgain = wrapper.findAll("button").find((b) => b.text().trim() === "发送");
expect(sendBtnAgain).toBeTruthy();
await sendBtnAgain!.trigger("click");
await flush();
await flush();
expect(wrapper.text()).toContain("hi");
expect(wrapper.text()).toContain("!");
globalThis.fetch = originalFetch as any;
const historyEl = wrapper.find(".chat-history").element as HTMLElement;
const scrollToSpy = vi.fn();
(historyEl as any).scrollTo = scrollToSpy;
Object.defineProperty(historyEl, "scrollHeight", { value: 1000, configurable: true });
Object.defineProperty(historyEl, "clientHeight", { value: 100, configurable: true });
Object.defineProperty(historyEl, "scrollTop", { value: 0, configurable: true });
historyEl.dispatchEvent(new Event("scroll"));
await flush();
const scrollBottomBtn = wrapper.findAll("button").find((b) => b.element.classList.contains("scroll-bottom-btn"));
expect(scrollBottomBtn).toBeTruthy();
await scrollBottomBtn!.trigger("click");
await flush();
await flush();
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
});
});

@ -9,9 +9,22 @@ export default defineConfig(({ mode }) => {
const hmrHost = "localhost";
const hmrPort = 5173;
const allowedHostsEnv = (env.VITE_ALLOWED_HOSTS || "").split(",").map((s) => s.trim()).filter(Boolean);
const isProd = mode === "production";
return {
plugins: [vue()],
cacheDir: ".vite",
esbuild: isProd ? { drop: ["console", "debugger"] } : undefined,
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
elementPlus: ["element-plus"],
vendor: ["axios", "@vueuse/core", "marked", "vue-i18n"],
},
},
},
},
server: {
host: devHost,
strictPort: true,
@ -24,6 +37,8 @@ export default defineConfig(({ mode }) => {
"/api": {
target,
changeOrigin: true,
timeout: 200000, // 代理请求超时设置为 200s (略大于业务超时 180s)
proxyTimeout: 200000, // 代理响应超时设置为 200s
},
},
},

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
environment: "happy-dom",
globals: true,
exclude: ["e2e/**", "node_modules/**", "dist/**"],
},
});
Loading…
Cancel
Save