Compare commits
28
Commits
2e1aa251d2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e393f265d | ||
|
|
ae04ffa9a0 | ||
|
|
c79b1af392 | ||
|
|
cbd8b347db | ||
|
|
a97b885081 | ||
|
|
db11548414 | ||
|
|
e5bb7ec49a | ||
|
|
cb296ba09e | ||
|
|
1de5fcf698 | ||
|
|
07ff8c2a12 | ||
|
|
681e4b3a7d | ||
|
|
c7563ede47 | ||
|
|
ff813824fa | ||
|
|
eaf13e5f51 | ||
|
|
5c0352c768 | ||
|
|
3d905b606a | ||
|
|
670d222665 | ||
|
|
8bfefd199c | ||
|
|
6d4a5c955b | ||
|
|
76045538bb | ||
|
|
10404be62d | ||
|
|
e13be8eb1c | ||
|
|
7441546804 | ||
|
|
bd3d0147f5 | ||
|
|
fbee37635b | ||
|
|
4762376d0a | ||
|
|
5a69cea3ae | ||
|
|
83112ed36d |
@@ -6,4 +6,5 @@ dist/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||||
|
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import * # noqa: F401,F403
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
url = settings.database_url
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations():
|
||||||
|
from app.database import engine
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""create users and audit_logs tables
|
||||||
|
|
||||||
|
Revision ID: ab0c56ae4242
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-22 16:25:22.698619
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ab0c56ae4242'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column('email', sa.String(255), unique=True, nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'role',
|
||||||
|
sa.Enum('owner', 'operator', 'viewer', name='userrole'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='viewer',
|
||||||
|
),
|
||||||
|
sa.Column('is_active', sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column('is_approved', sa.Boolean, nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column('login_attempts', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('locked_until', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'audit_logs',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.String(36), nullable=False, index=True),
|
||||||
|
sa.Column('username', sa.String(50), nullable=False),
|
||||||
|
sa.Column('action', sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column('target', sa.String(255), nullable=False),
|
||||||
|
sa.Column('detail', sa.Text, nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('audit_logs')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.execute('DROP TYPE IF EXISTS userrole')
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""create events table
|
||||||
|
|
||||||
|
Revision ID: e7f3a9b1c5d0
|
||||||
|
Revises: ab0c56ae4242
|
||||||
|
Create Date: 2026-07-22 17:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e7f3a9b1c5d0'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'ab0c56ae4242'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'events',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('title', sa.String(255), nullable=False),
|
||||||
|
sa.Column('level', sa.Enum('A', 'B', 'C', name='eventlevel'), nullable=False),
|
||||||
|
sa.Column('domain', sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column(
|
||||||
|
'status',
|
||||||
|
sa.Enum('active', 'fading', 'dissipating', 'closed', name='eventstatus'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='active',
|
||||||
|
),
|
||||||
|
sa.Column('occurred_date', sa.Date, nullable=False),
|
||||||
|
sa.Column('last_reviewed', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('no_new_info_count', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('next_review', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('events')
|
||||||
|
op.execute('DROP TYPE IF EXISTS eventlevel')
|
||||||
|
op.execute('DROP TYPE IF EXISTS eventstatus')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Audit API: list audit logs — only owner."""
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_role
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_audit(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
action: str | None = Query(None),
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||||
|
if action:
|
||||||
|
query = query.where(AuditLog.action.ilike(f"%{action}%"))
|
||||||
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Auth API: register, login, refresh, change-password."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_auth
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
TokenResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserResponse
|
||||||
|
from app.services.auth_service import (
|
||||||
|
authenticate_user,
|
||||||
|
create_access_token,
|
||||||
|
create_user,
|
||||||
|
decode_access_token,
|
||||||
|
get_user_by_username,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", status_code=201, response_model=UserResponse)
|
||||||
|
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
existing = await get_user_by_username(db, body.username)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.email == body.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="Email already exists")
|
||||||
|
|
||||||
|
user = await create_user(db, body.username, body.email, body.password)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await authenticate_user(db, body.username, body.password)
|
||||||
|
token = create_access_token(user.id, user.role.value)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
expires_in=settings.jwt_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(user: User = Depends(require_auth)):
|
||||||
|
token = create_access_token(user.id, user.role.value)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
expires_in=settings.jwt_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
user: User = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not verify_password(body.old_password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
||||||
|
|
||||||
|
user.hashed_password = hash_password(body.new_password)
|
||||||
|
await db.commit()
|
||||||
|
return {"detail": "Password changed successfully"}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Cron API — 跨 Profile Cron 聚合列表、日志、启停操作。"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_auth, require_role
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cron", tags=["cron"])
|
||||||
|
|
||||||
|
# ── 系统 Cron 硬编码清单 ──
|
||||||
|
|
||||||
|
SYSTEM_CRON_JOBS = [
|
||||||
|
{
|
||||||
|
"id": "system-hindsight",
|
||||||
|
"job_name": "hindsight",
|
||||||
|
"profile": "system",
|
||||||
|
"schedule": "0 21 * * *",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": None,
|
||||||
|
"last_run_success": None,
|
||||||
|
"success_rate": 100.0,
|
||||||
|
"has_logs": False,
|
||||||
|
"system": True,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "system-health-check",
|
||||||
|
"job_name": "健康巡检",
|
||||||
|
"profile": "system",
|
||||||
|
"schedule": "*/30 * * * *",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": None,
|
||||||
|
"last_run_success": None,
|
||||||
|
"success_rate": 100.0,
|
||||||
|
"has_logs": False,
|
||||||
|
"system": True,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "system-skill-check",
|
||||||
|
"job_name": "Skill检查",
|
||||||
|
"profile": "system",
|
||||||
|
"schedule": "0 2 * * *",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": None,
|
||||||
|
"last_run_success": None,
|
||||||
|
"success_rate": 100.0,
|
||||||
|
"has_logs": False,
|
||||||
|
"system": True,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "system-memory-archive",
|
||||||
|
"job_name": "Memory归档",
|
||||||
|
"profile": "system",
|
||||||
|
"schedule": "0 3 * * 0",
|
||||||
|
"status": "paused",
|
||||||
|
"last_run": None,
|
||||||
|
"last_run_success": None,
|
||||||
|
"success_rate": 100.0,
|
||||||
|
"has_logs": False,
|
||||||
|
"system": True,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 占位 Profile Cron(hermes CLI 返回为空时的后备) ──
|
||||||
|
|
||||||
|
FALLBACK_PROFILE_JOBS = [
|
||||||
|
{
|
||||||
|
"id": "profile-taiwan-briefing",
|
||||||
|
"job_name": "taiwan-briefing",
|
||||||
|
"profile": "taiwan",
|
||||||
|
"schedule": "0 7 * * *",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": "2026-07-22T07:15:00",
|
||||||
|
"last_run_success": True,
|
||||||
|
"success_rate": 95.0,
|
||||||
|
"has_logs": True,
|
||||||
|
"system": False,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "profile-guangxi-donation",
|
||||||
|
"job_name": "guangxi-donation",
|
||||||
|
"profile": "guangxi",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"status": "paused",
|
||||||
|
"last_run": "2026-07-21T09:00:00",
|
||||||
|
"last_run_success": True,
|
||||||
|
"success_rate": 100.0,
|
||||||
|
"has_logs": True,
|
||||||
|
"system": False,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "profile-daily-report",
|
||||||
|
"job_name": "daily-report",
|
||||||
|
"profile": "ops-engineer",
|
||||||
|
"schedule": "0 8 * * *",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": "2026-07-22T08:00:00",
|
||||||
|
"last_run_success": True,
|
||||||
|
"success_rate": 88.0,
|
||||||
|
"has_logs": True,
|
||||||
|
"system": False,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "profile-weekly-digest",
|
||||||
|
"job_name": "weekly-digest",
|
||||||
|
"profile": "ops-engineer",
|
||||||
|
"schedule": "30 9 * * 1",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": "2026-07-20T09:30:00",
|
||||||
|
"last_run_success": True,
|
||||||
|
"success_rate": 100.0,
|
||||||
|
"has_logs": True,
|
||||||
|
"system": False,
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "profile-hindsight",
|
||||||
|
"job_name": "hindsight",
|
||||||
|
"profile": "hebo",
|
||||||
|
"schedule": "0 21 * * *",
|
||||||
|
"status": "active",
|
||||||
|
"last_run": "2026-07-21T21:13:00",
|
||||||
|
"last_run_success": False,
|
||||||
|
"success_rate": 60.0,
|
||||||
|
"has_logs": True,
|
||||||
|
"system": False,
|
||||||
|
"consecutive_failures": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 占位日志数据 ──
|
||||||
|
|
||||||
|
PLACEHOLDER_LOGS = {
|
||||||
|
"system-hindsight": [
|
||||||
|
{"time": "2026-07-21T21:13:00", "status": "failure", "message": "ERROR: 连接数据库超时 (timeout: 30s)"},
|
||||||
|
{"time": "2026-07-20T21:05:00", "status": "failure", "message": "WARN: 响应慢 (3200ms), 重试后仍然失败"},
|
||||||
|
{"time": "2026-07-19T21:01:00", "status": "success", "message": "INFO: 归档完成, 处理 128 条记录"},
|
||||||
|
],
|
||||||
|
"profile-hindsight": [
|
||||||
|
{"time": "2026-07-21T21:13:00", "status": "failure", "message": "ERROR: API 返回 502 Bad Gateway"},
|
||||||
|
{"time": "2026-07-20T21:05:00", "status": "failure", "message": "ERROR: 上游服务未响应 (timeout: 60s)"},
|
||||||
|
{"time": "2026-07-19T21:01:00", "status": "success", "message": "INFO: 推送完成, 成功发送 12 条消息"},
|
||||||
|
{"time": "2026-07-18T21:00:00", "status": "success", "message": "INFO: 按计划执行完毕"},
|
||||||
|
{"time": "2026-07-17T21:00:00", "status": "success", "message": "INFO: 正常执行"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_profile_jobs() -> list[dict]:
|
||||||
|
"""从 hermes CLI 获取 Profile Cron 列表,失败时返回后备数据。"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[settings.hermes_cli_path, "cron", "list", "--all"],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
data = json.loads(proc.stdout)
|
||||||
|
jobs = data if isinstance(data, list) else data.get("jobs", [])
|
||||||
|
for j in jobs:
|
||||||
|
j.setdefault("system", False)
|
||||||
|
j.setdefault("consecutive_failures", 0)
|
||||||
|
j.setdefault("has_logs", False)
|
||||||
|
j.setdefault("success_rate", 100.0)
|
||||||
|
return jobs
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired) as e:
|
||||||
|
logger.warning("hermes cron list --all 失败: %s", e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("调用 hermes cron list 异常: %s", e)
|
||||||
|
return FALLBACK_PROFILE_JOBS
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_jobs(profile_jobs: list[dict]) -> list[dict]:
|
||||||
|
"""合并 Profile Cron 和系统 Cron,按 id 去重。"""
|
||||||
|
seen = {j["id"] for j in SYSTEM_CRON_JOBS}
|
||||||
|
merged = list(SYSTEM_CRON_JOBS)
|
||||||
|
for j in profile_jobs:
|
||||||
|
if j["id"] not in seen:
|
||||||
|
merged.append(j)
|
||||||
|
seen.add(j["id"])
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _check_has_failures(jobs: list[dict]) -> bool:
|
||||||
|
"""检查当日是否有任何 Cron 失败。"""
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
for j in jobs:
|
||||||
|
last_run = j.get("last_run") or ""
|
||||||
|
if last_run.startswith(today) and j.get("last_run_success") is False:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
async def list_cron_jobs(
|
||||||
|
status: str | None = Query(None, pattern="^(active|paused)$"),
|
||||||
|
profile: str = Query("all", pattern="^(all|system|[a-z][a-z0-9_-]+)$"),
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""跨 Profile Cron 聚合列表。
|
||||||
|
|
||||||
|
- ?status=active|paused 按状态筛选
|
||||||
|
- ?profile=all|system|xxx 按来源/Profile 筛选
|
||||||
|
"""
|
||||||
|
profile_jobs = _fetch_profile_jobs()
|
||||||
|
jobs = _merge_jobs(profile_jobs)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
jobs = [j for j in jobs if j.get("status") == status]
|
||||||
|
if profile == "system":
|
||||||
|
jobs = [j for j in jobs if j.get("system")]
|
||||||
|
elif profile != "all":
|
||||||
|
jobs = [j for j in jobs if j.get("profile") == profile and not j.get("system")]
|
||||||
|
|
||||||
|
has_failures = _check_has_failures(jobs)
|
||||||
|
return {"jobs": jobs, "has_failures": has_failures}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/logs")
|
||||||
|
async def get_cron_logs(
|
||||||
|
job_id: str,
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""返回最近 8 条运行日志(暂用占位数据)。"""
|
||||||
|
logs = PLACEHOLDER_LOGS.get(job_id, [])
|
||||||
|
return {"logs": logs[:8]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _operate_cron(
|
||||||
|
job_id: str,
|
||||||
|
action: str,
|
||||||
|
user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict:
|
||||||
|
"""执行 Cron 操作并写入审计日志。"""
|
||||||
|
# 解析 profile 和 job_name
|
||||||
|
if job_id.startswith("system-"):
|
||||||
|
profile = "system"
|
||||||
|
job_name = job_id[len("system-"):]
|
||||||
|
elif job_id.startswith("profile-"):
|
||||||
|
parts = job_id.split("-", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
profile = parts[1]
|
||||||
|
job_name = parts[2]
|
||||||
|
else:
|
||||||
|
profile = "unknown"
|
||||||
|
job_name = job_id
|
||||||
|
else:
|
||||||
|
profile = "unknown"
|
||||||
|
job_name = job_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# run 操作需要额外的 --job 参数
|
||||||
|
if action == "run":
|
||||||
|
args = [settings.hermes_cli_path, "cron", "run", "--profile", profile, "--job", job_name]
|
||||||
|
elif action == "pause":
|
||||||
|
args = [settings.hermes_cli_path, "cron", "pause", "--profile", profile, "--job", job_name]
|
||||||
|
elif action == "resume":
|
||||||
|
args = [settings.hermes_cli_path, "cron", "resume", "--profile", profile, "--job", job_name]
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"不支持的操作: {action}")
|
||||||
|
|
||||||
|
proc = subprocess.run(args, capture_output=True, text=True, timeout=30)
|
||||||
|
success = proc.returncode == 0
|
||||||
|
detail = (proc.stdout or proc.stderr).strip()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
detail = str(e)
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
action=f"cron:{action}",
|
||||||
|
target=f"job:{job_id}",
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Cron {action} 失败: {detail}")
|
||||||
|
|
||||||
|
return {"success": True, "action": action, "job_id": job_id, "detail": detail}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/run")
|
||||||
|
async def run_cron_job(
|
||||||
|
job_id: str,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""手动触发一次 Cron 执行。"""
|
||||||
|
return await _operate_cron(job_id, "run", user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/pause")
|
||||||
|
async def pause_cron_job(
|
||||||
|
job_id: str,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""暂停 Cron。"""
|
||||||
|
return await _operate_cron(job_id, "pause", user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/resume")
|
||||||
|
async def resume_cron_job(
|
||||||
|
job_id: str,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""恢复 Cron。"""
|
||||||
|
return await _operate_cron(job_id, "resume", user, db)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Dashboard API: aggregated stats, health distribution, gateway trend,
|
||||||
|
kanban overview, cron recent, and global search — all mock data for now."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.middleware.auth import require_auth
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/summary")
|
||||||
|
async def dashboard_summary(_user: User = Depends(require_auth)):
|
||||||
|
# TODO: replace with real data — hermes gateway status + kanban + cron
|
||||||
|
return {
|
||||||
|
"online_count": 12,
|
||||||
|
"offline_count": 46,
|
||||||
|
"total_profiles": 58,
|
||||||
|
"running_tasks": 3,
|
||||||
|
"cron_failures_today": 1,
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/health-distribution")
|
||||||
|
async def health_distribution(_user: User = Depends(require_auth)):
|
||||||
|
# TODO: replace with real health-score calculation (Phase 4)
|
||||||
|
return {
|
||||||
|
"A": 22,
|
||||||
|
"B": 18,
|
||||||
|
"C": 10,
|
||||||
|
"D": 8,
|
||||||
|
"total": 58,
|
||||||
|
"d_profiles": ["ops-engineer", "monitor", "visualizer", "hebo", "..."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/gateway-trend")
|
||||||
|
async def gateway_trend(_user: User = Depends(require_auth)):
|
||||||
|
# TODO: replace with real 24h aggregation from event store
|
||||||
|
return [
|
||||||
|
{"hour": "2026-07-22T08:00", "restarts": 2, "feishu_disconnects": 1, "heartbeat_timeouts": 0},
|
||||||
|
{"hour": "2026-07-22T09:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 1},
|
||||||
|
{"hour": "2026-07-22T10:00", "restarts": 1, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
|
||||||
|
{"hour": "2026-07-22T11:00", "restarts": 0, "feishu_disconnects": 2, "heartbeat_timeouts": 0},
|
||||||
|
{"hour": "2026-07-22T12:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
|
||||||
|
{"hour": "2026-07-22T13:00", "restarts": 3, "feishu_disconnects": 1, "heartbeat_timeouts": 2},
|
||||||
|
{"hour": "2026-07-22T14:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 1},
|
||||||
|
{"hour": "2026-07-22T15:00", "restarts": 1, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/kanban-summary")
|
||||||
|
async def kanban_summary(_user: User = Depends(require_auth)):
|
||||||
|
# TODO: replace with real kanban task data
|
||||||
|
return [
|
||||||
|
{"board": "europe-briefing", "running": "S4", "ready": "S5"},
|
||||||
|
{"board": "tech-weekly", "running": "S1", "ready": None},
|
||||||
|
{"board": "ops-review", "running": "M2", "ready": "M3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/cron-recent")
|
||||||
|
async def cron_recent(_user: User = Depends(require_auth)):
|
||||||
|
# TODO: replace with real cron execution history
|
||||||
|
return [
|
||||||
|
{"job": "taiwan-briefing", "status": "success", "time": "07:15"},
|
||||||
|
{"job": "hindsight", "status": "failure", "time": "21:13"},
|
||||||
|
{"job": "guangxi-donation", "status": "paused"},
|
||||||
|
{"job": "daily-report", "status": "success", "time": "08:00"},
|
||||||
|
{"job": "weekly-digest", "status": "success", "time": "09:30"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def global_search(
|
||||||
|
q: str = Query("", min_length=1),
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
# TODO: replace with real full-text search across profiles, tasks, cron, events
|
||||||
|
keyword = q.lower()
|
||||||
|
|
||||||
|
profiles = []
|
||||||
|
if "gateway" in keyword or "monitor" in keyword:
|
||||||
|
profiles.append({"name": "gateway-monitor", "status": "online"})
|
||||||
|
if "visual" in keyword:
|
||||||
|
profiles.append({"name": "visualizer", "status": "online"})
|
||||||
|
if "hebo" in keyword or "ops" in keyword:
|
||||||
|
profiles.append({"name": "ops-engineer", "status": "online"})
|
||||||
|
|
||||||
|
kanban_tasks = []
|
||||||
|
if "briefing" in keyword or "europe" in keyword:
|
||||||
|
kanban_tasks.append({"title": "Europe Briefing S4", "column": "RUNNING"})
|
||||||
|
if "tech" in keyword or "weekly" in keyword:
|
||||||
|
kanban_tasks.append({"title": "Tech Weekly S1", "column": "RUNNING"})
|
||||||
|
|
||||||
|
cron_jobs = []
|
||||||
|
if "taiwan" in keyword or "briefing" in keyword:
|
||||||
|
cron_jobs.append({"job": "taiwan-briefing", "status": "active"})
|
||||||
|
if "hindsight" in keyword:
|
||||||
|
cron_jobs.append({"job": "hindsight", "status": "active"})
|
||||||
|
if "donation" in keyword or "guangxi" in keyword:
|
||||||
|
cron_jobs.append({"job": "guangxi-donation", "status": "paused"})
|
||||||
|
|
||||||
|
events = []
|
||||||
|
if "gateway" in keyword or keyword == "restart":
|
||||||
|
events.append({"title": "Gateway restart detected", "status": "active"})
|
||||||
|
if "feishu" in keyword or "disconnect" in keyword:
|
||||||
|
events.append({"title": "Feishu disconnect alert", "status": "active"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profiles": profiles,
|
||||||
|
"kanban_tasks": kanban_tasks,
|
||||||
|
"cron_jobs": cron_jobs,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Events API — 事件档案列表,三维筛选。"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_auth
|
||||||
|
from app.models.event import Event, EventStatus, DOMAINS
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_events(
|
||||||
|
status: str | None = Query(None, description="按状态筛选"),
|
||||||
|
domain: str | None = Query(None, description="按领域筛选"),
|
||||||
|
search: str = Query("", description="按标题模糊搜索"),
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""事件卡片列表,支持三维筛选。"""
|
||||||
|
stmt = select(Event).order_by(Event.occurred_date.desc())
|
||||||
|
|
||||||
|
valid_statuses = {s.value for s in EventStatus}
|
||||||
|
if status and status in valid_statuses:
|
||||||
|
stmt = stmt.where(Event.status == status)
|
||||||
|
|
||||||
|
if domain and domain in DOMAINS:
|
||||||
|
stmt = stmt.where(Event.domain == domain)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
stmt = stmt.where(Event.title.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
events = result.scalars().all()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
data = []
|
||||||
|
for ev in events:
|
||||||
|
overdue_days = 0
|
||||||
|
if ev.next_review and ev.next_review < now:
|
||||||
|
overdue_days = (now - ev.next_review).days
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
"id": ev.id,
|
||||||
|
"title": ev.title,
|
||||||
|
"level": ev.level.value,
|
||||||
|
"domain": ev.domain,
|
||||||
|
"status": ev.status.value,
|
||||||
|
"occurred_date": ev.occurred_date.isoformat(),
|
||||||
|
"last_reviewed": ev.last_reviewed.isoformat() if ev.last_reviewed else None,
|
||||||
|
"no_new_info_count": ev.no_new_info_count,
|
||||||
|
"next_review": ev.next_review.isoformat() if ev.next_review else None,
|
||||||
|
"overdue_days": overdue_days,
|
||||||
|
"created_at": ev.created_at.isoformat() if ev.created_at else None,
|
||||||
|
"updated_at": ev.updated_at.isoformat() if ev.updated_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"events": data}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Gateway API — 状态、事件、启停操作。"""
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_auth, require_role
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import hermes_cli
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/gateway", tags=["gateway"])
|
||||||
|
|
||||||
|
# ── 占位事件数据(TODO: 从 hermes 日志或系统日志解析) ──
|
||||||
|
|
||||||
|
PLACEHOLDER_EVENTS = [
|
||||||
|
{"time": "2026-07-22T18:45", "profile": "monitor", "type": "heartbeat_timeout",
|
||||||
|
"detail": "连续 3 次心跳无响应"},
|
||||||
|
{"time": "2026-07-22T14:20", "profile": "feishu-gw", "type": "feishu_disconnect",
|
||||||
|
"detail": "WebSocket 断开 8 分钟"},
|
||||||
|
{"time": "2026-07-22T10:30", "profile": "gateway-monitor", "type": "restart",
|
||||||
|
"detail": "Gateway 重启"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_gateway_status(
|
||||||
|
status: str | None = Query(None, pattern="^(online|offline|warn)$"),
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""返回全部 Profile 的 Gateway 状态列表,支持 ?status= 筛选。"""
|
||||||
|
gateways = hermes_cli.gateway_status_all()
|
||||||
|
for gw in gateways:
|
||||||
|
gw.setdefault("downtime_minutes", 0)
|
||||||
|
gw.setdefault("restarts_today", 0)
|
||||||
|
gw.setdefault("last_event", "")
|
||||||
|
gw.setdefault("profile_type", "worker")
|
||||||
|
if status:
|
||||||
|
gateways = [g for g in gateways if g.get("status") == status]
|
||||||
|
return gateways
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def get_gateway_events(
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""返回最近 24h 异常事件。"""
|
||||||
|
# TODO: 从 hermes CLI 日志或系统日志解析真实事件
|
||||||
|
return PLACEHOLDER_EVENTS
|
||||||
|
|
||||||
|
|
||||||
|
async def _operate_gateway(
|
||||||
|
profile: str,
|
||||||
|
action: str,
|
||||||
|
user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict:
|
||||||
|
"""执行 hermes gateway {action} --profile {profile} 并写入审计日志。"""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[settings.hermes_cli_path, "gateway", action, "--profile", profile],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
success = proc.returncode == 0
|
||||||
|
detail = (proc.stdout or proc.stderr).strip()
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
detail = str(e)
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
action=f"gateway:{action}",
|
||||||
|
target=f"profile:{profile}",
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Gateway {action} 失败: {detail}")
|
||||||
|
|
||||||
|
return {"success": True, "action": action, "profile": profile, "detail": detail}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{profile}/start")
|
||||||
|
async def start_gateway(
|
||||||
|
profile: str,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""启动 Gateway。"""
|
||||||
|
return await _operate_gateway(profile, "start", user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{profile}/stop")
|
||||||
|
async def stop_gateway(
|
||||||
|
profile: str,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""停止 Gateway。"""
|
||||||
|
return await _operate_gateway(profile, "stop", user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{profile}/restart")
|
||||||
|
async def restart_gateway(
|
||||||
|
profile: str,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""重启 Gateway。"""
|
||||||
|
return await _operate_gateway(profile, "restart", user, db)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Health API — 健康分查询、对比、手动刷新。"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from app.middleware.auth import require_auth, require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.health_scorer import (
|
||||||
|
PLACEHOLDER_PROFILES,
|
||||||
|
calculate_all_scores,
|
||||||
|
calculate_health_score,
|
||||||
|
invalidate_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/health", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
def _score_to_row(hs, rank: int) -> dict:
|
||||||
|
return {
|
||||||
|
"rank": rank,
|
||||||
|
"name": hs.profile_name,
|
||||||
|
"total_score": hs.total_score,
|
||||||
|
"grade": hs.grade,
|
||||||
|
"gateway_score": hs.gateway.score,
|
||||||
|
"config_score": hs.config.score,
|
||||||
|
"skill_score": hs.skill.score,
|
||||||
|
"cron_score": hs.cron.score,
|
||||||
|
"deduct_summary": hs.deduct_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scores")
|
||||||
|
async def list_scores(
|
||||||
|
grade: str | None = Query(None, pattern="^(A|B|C|D)$"),
|
||||||
|
sort: str = Query("asc", pattern="^(asc|desc)$"),
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""返回全部 Profile 健康分排行列表。
|
||||||
|
|
||||||
|
- ?grade=A|B|C|D 按等级筛选
|
||||||
|
- ?sort=asc|desc 排序,默认从低到高(问题优先)
|
||||||
|
"""
|
||||||
|
scores = calculate_all_scores()
|
||||||
|
|
||||||
|
# 筛选
|
||||||
|
if grade:
|
||||||
|
scores = [s for s in scores if s.grade == grade]
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
scores.sort(key=lambda s: s.total_score, reverse=(sort == "desc"))
|
||||||
|
|
||||||
|
rows = [_score_to_row(hs, i + 1) for i, hs in enumerate(scores)]
|
||||||
|
return {
|
||||||
|
"total": len(rows),
|
||||||
|
"distribution": {
|
||||||
|
"A": sum(1 for s in scores if s.grade == "A"),
|
||||||
|
"B": sum(1 for s in scores if s.grade == "B"),
|
||||||
|
"C": sum(1 for s in scores if s.grade == "C"),
|
||||||
|
"D": sum(1 for s in scores if s.grade == "D"),
|
||||||
|
},
|
||||||
|
"scores": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scores/{profile}")
|
||||||
|
async def get_profile_score(
|
||||||
|
profile: str,
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""返回单个 Profile 的健康分详情,含四维度扣分项和修复建议。"""
|
||||||
|
if profile not in PLACEHOLDER_PROFILES:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Profile '{profile}' 不存在")
|
||||||
|
|
||||||
|
hs = calculate_health_score(profile)
|
||||||
|
return {
|
||||||
|
"profile_name": hs.profile_name,
|
||||||
|
"total_score": hs.total_score,
|
||||||
|
"grade": hs.grade,
|
||||||
|
"calculated_at": hs.calculated_at,
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"name": "Gateway 稳定性",
|
||||||
|
"weight": "40%",
|
||||||
|
"score": hs.gateway.score,
|
||||||
|
"max_score": hs.gateway.max_score,
|
||||||
|
"deductions": hs.gateway.deductions,
|
||||||
|
"suggestions": hs.gateway.suggestions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "配置完整性",
|
||||||
|
"weight": "25%",
|
||||||
|
"score": hs.config.score,
|
||||||
|
"max_score": hs.config.max_score,
|
||||||
|
"deductions": hs.config.deductions,
|
||||||
|
"suggestions": hs.config.suggestions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill 一致性",
|
||||||
|
"weight": "20%",
|
||||||
|
"score": hs.skill.score,
|
||||||
|
"max_score": hs.skill.max_score,
|
||||||
|
"deductions": hs.skill.deductions,
|
||||||
|
"suggestions": hs.skill.suggestions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cron 成功率",
|
||||||
|
"weight": "15%",
|
||||||
|
"score": hs.cron.score,
|
||||||
|
"max_score": hs.cron.max_score,
|
||||||
|
"deductions": hs.cron.deductions,
|
||||||
|
"suggestions": hs.cron.suggestions,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compare")
|
||||||
|
async def compare_profiles(
|
||||||
|
body: dict,
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""返回多个 Profile 的配置/Skill 横向对比。
|
||||||
|
|
||||||
|
body: {"profile_names": ["hebo", "ops-engineer"]}
|
||||||
|
"""
|
||||||
|
names = body.get("profile_names", [])
|
||||||
|
if len(names) < 2:
|
||||||
|
raise HTTPException(status_code=400, detail="至少选择 2 个 Profile")
|
||||||
|
if len(names) > 20:
|
||||||
|
raise HTTPException(status_code=400, detail="最多对比 20 个 Profile")
|
||||||
|
|
||||||
|
invalid = [n for n in names if n not in PLACEHOLDER_PROFILES]
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Profile 不存在: {', '.join(invalid)}")
|
||||||
|
|
||||||
|
scores = {hs.profile_name: hs for hs in calculate_all_scores(names)}
|
||||||
|
|
||||||
|
# 收集所有配置字段和 Skill
|
||||||
|
from app.services.health_scorer import _REQUIRED_CONFIG_FIELDS, _BASELINE_SKILLS
|
||||||
|
from app.services.health_scorer import _placeholder_config_data, _placeholder_skill_data
|
||||||
|
|
||||||
|
config_fields = list(_REQUIRED_CONFIG_FIELDS)
|
||||||
|
skill_fields = sorted(_BASELINE_SKILLS)
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for field in config_fields:
|
||||||
|
row: dict = {"field": field, "category": "config"}
|
||||||
|
for name in names:
|
||||||
|
s = scores.get(name)
|
||||||
|
if s is None:
|
||||||
|
row[name] = "⚠"
|
||||||
|
else:
|
||||||
|
data = _placeholder_config_data(name)
|
||||||
|
row[name] = "✅" if field in data["present_fields"] else "❌"
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
for field in skill_fields:
|
||||||
|
row = {"field": field, "category": "skill"}
|
||||||
|
for name in names:
|
||||||
|
s = scores.get(name)
|
||||||
|
if s is None:
|
||||||
|
row[name] = "⚠"
|
||||||
|
else:
|
||||||
|
data = _placeholder_skill_data(name)
|
||||||
|
row[name] = "✅" if field in data["profile_skills"] else "❌"
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
# 检测差异行
|
||||||
|
for row in rows:
|
||||||
|
vals = {str(row[n]) for n in names}
|
||||||
|
row["has_diff"] = len(vals) > 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profiles": names,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calculate")
|
||||||
|
async def recalculate_scores(
|
||||||
|
_user: User = Depends(require_role("owner")),
|
||||||
|
):
|
||||||
|
"""手动触发全量评分重算(仅 owner)。"""
|
||||||
|
invalidate_cache()
|
||||||
|
scores = calculate_all_scores()
|
||||||
|
return {
|
||||||
|
"message": f"已重新计算 {len(scores)} 个 Profile 的健康分",
|
||||||
|
"count": len(scores),
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Kanban API — 五列任务看板 + 异常检测。"""
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from app.middleware.auth import require_auth
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/kanban", tags=["kanban"])
|
||||||
|
|
||||||
|
# ── Mock 数据(TODO: CLI integration) ──
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(hours: int = 0, minutes: int = 0) -> str:
|
||||||
|
return (NOW - timedelta(hours=hours, minutes=minutes)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
MOCK_TASKS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "task-001",
|
||||||
|
"title": "生成 taiwan 早报简报",
|
||||||
|
"board": "简报生成",
|
||||||
|
"worker_profile": "taiwan",
|
||||||
|
"worker_type": "news-crawler",
|
||||||
|
"priority": 2,
|
||||||
|
"column": "running",
|
||||||
|
"created_at": _ago(hours=2),
|
||||||
|
"updated_at": _ago(minutes=15),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n每日 07:00 生成 taiwan 地区早报简报\n\n### 步骤\n\n1. 爬取新闻源\n2. 摘要生成\n3. 推送飞书",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": ["task-004"],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(minutes=15), "user": "system", "action": "开始执行"},
|
||||||
|
{"time": _ago(hours=2), "user": "hermes", "action": "加入队列"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-002",
|
||||||
|
"title": "guangxi 捐赠数据汇总",
|
||||||
|
"board": "数据采集",
|
||||||
|
"worker_profile": "guangxi",
|
||||||
|
"worker_type": "data-collector",
|
||||||
|
"priority": 1,
|
||||||
|
"column": "ready",
|
||||||
|
"created_at": _ago(hours=6),
|
||||||
|
"updated_at": _ago(hours=6),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n汇总 guangxi 地区捐赠数据\n\n### 数据源\n\n- 内部 API\n- 公开报表",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=6), "user": "hermes", "action": "就绪"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-003",
|
||||||
|
"title": "运维日报推送 (ops-engineer)",
|
||||||
|
"board": "日报推送",
|
||||||
|
"worker_profile": "ops-engineer",
|
||||||
|
"worker_type": "reporter",
|
||||||
|
"priority": 3,
|
||||||
|
"column": "todo",
|
||||||
|
"created_at": _ago(hours=96),
|
||||||
|
"updated_at": _ago(hours=96),
|
||||||
|
"goal_mode": True,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n每日 08:00 推送运维日报\n\n### 验收标准\n\n- [ ] 数据源正常\n- [ ] 推送成功",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": ["task-002"],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=96), "user": "hermes", "action": "创建任务"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-004",
|
||||||
|
"title": "hebo hindsight 归档",
|
||||||
|
"board": "数据归档",
|
||||||
|
"worker_profile": "hebo",
|
||||||
|
"worker_type": "archiver",
|
||||||
|
"priority": 2,
|
||||||
|
"column": "blocked",
|
||||||
|
"created_at": _ago(hours=30),
|
||||||
|
"updated_at": _ago(hours=30),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 2,
|
||||||
|
"failures": 3,
|
||||||
|
"description": "## 任务\n\n每日 21:00 hindsight 归档处理\n\n### 说明\n\n最近连续失败,上游 API 不稳定",
|
||||||
|
"block_reason": "上游 API 连续返回 502,需联系运维排查",
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=30), "user": "system", "action": "标记阻塞"},
|
||||||
|
{"time": _ago(hours=32), "user": "hermes", "action": "重试失败 (第3次)"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-005",
|
||||||
|
"title": "guangxi 周报生成",
|
||||||
|
"board": "简报生成",
|
||||||
|
"worker_profile": "guangxi",
|
||||||
|
"worker_type": "reporter",
|
||||||
|
"priority": 1,
|
||||||
|
"column": "todo",
|
||||||
|
"created_at": _ago(hours=12),
|
||||||
|
"updated_at": _ago(hours=12),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n每周一生成 guangxi 周报",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": ["task-002"],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=12), "user": "hermes", "action": "创建任务"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-006",
|
||||||
|
"title": "taiwan 突发新闻推送",
|
||||||
|
"board": "简报生成",
|
||||||
|
"worker_profile": "taiwan",
|
||||||
|
"worker_type": "news-crawler",
|
||||||
|
"priority": 0,
|
||||||
|
"column": "running",
|
||||||
|
"created_at": _ago(hours=1),
|
||||||
|
"updated_at": _ago(minutes=5),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 1,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n监测到突发新闻,立即推送飞书通知",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(minutes=5), "user": "system", "action": "内容生成中"},
|
||||||
|
{"time": _ago(hours=1), "user": "hermes", "action": "触发执行"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-007",
|
||||||
|
"title": "飞书健康巡检",
|
||||||
|
"board": "系统维护",
|
||||||
|
"worker_profile": "ops-engineer",
|
||||||
|
"worker_type": "health-checker",
|
||||||
|
"priority": 0,
|
||||||
|
"column": "done",
|
||||||
|
"created_at": _ago(hours=72),
|
||||||
|
"updated_at": _ago(hours=48),
|
||||||
|
"goal_mode": True,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n检查飞书连接健康状况\n\n### 验收标准\n\n- [x] API 连通性正常\n- [x] Webhook 可达",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=48), "user": "system", "action": "标记完成"},
|
||||||
|
{"time": _ago(hours=60), "user": "system", "action": "开始执行"},
|
||||||
|
{"time": _ago(hours=72), "user": "hermes", "action": "加入队列"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-008",
|
||||||
|
"title": "密钥轮换检查 (all profiles)",
|
||||||
|
"board": "安全审计",
|
||||||
|
"worker_profile": "ops-engineer",
|
||||||
|
"worker_type": "auditor",
|
||||||
|
"priority": 1,
|
||||||
|
"column": "blocked",
|
||||||
|
"created_at": _ago(hours=50),
|
||||||
|
"updated_at": _ago(hours=48),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n检查所有 Profile 密钥有效期,轮换即将过期的密钥",
|
||||||
|
"block_reason": "等待安全审批,预计下周二完成",
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=48), "user": "manual", "action": "标记阻塞"},
|
||||||
|
{"time": _ago(hours=50), "user": "hermes", "action": "创建任务"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-009",
|
||||||
|
"title": "Gateway 配置同步",
|
||||||
|
"board": "系统维护",
|
||||||
|
"worker_profile": "taiwan",
|
||||||
|
"worker_type": "syncer",
|
||||||
|
"priority": 2,
|
||||||
|
"column": "ready",
|
||||||
|
"created_at": _ago(hours=4),
|
||||||
|
"updated_at": _ago(hours=4),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 1,
|
||||||
|
"description": "## 任务\n\n同步 taiwan Gateway 最新配置",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=4), "user": "hermes", "action": "就绪"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-010",
|
||||||
|
"title": "历史数据归档 (daily)",
|
||||||
|
"board": "数据归档",
|
||||||
|
"worker_profile": "hebo",
|
||||||
|
"worker_type": "archiver",
|
||||||
|
"priority": 2,
|
||||||
|
"column": "todo",
|
||||||
|
"created_at": _ago(hours=18),
|
||||||
|
"updated_at": _ago(hours=18),
|
||||||
|
"goal_mode": False,
|
||||||
|
"retries": 0,
|
||||||
|
"failures": 0,
|
||||||
|
"description": "## 任务\n\n每日历史数据归档",
|
||||||
|
"block_reason": None,
|
||||||
|
"dependencies": [],
|
||||||
|
"dependents": [],
|
||||||
|
"activities": [
|
||||||
|
{"time": _ago(hours=18), "user": "hermes", "action": "创建任务"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 用 createdAt 判断 unclaimed,为了测试效果把 task-003 设为 96h ago (>72h)
|
||||||
|
# task-004 failure=3 触发 retry_failure
|
||||||
|
# task-004/task-008 blocked 且 updated_at>24h 触发 blocked_timeout
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_anomalies(task: dict) -> list[str]:
|
||||||
|
"""返回任务异常标记列表。"""
|
||||||
|
anomalies: list[str] = []
|
||||||
|
updated = datetime.fromisoformat(task["updated_at"])
|
||||||
|
created = datetime.fromisoformat(task["created_at"])
|
||||||
|
now = NOW
|
||||||
|
|
||||||
|
# blocked_timeout:BLOCKED 列超 24h → 标红
|
||||||
|
if task["column"] == "blocked" and (now - updated) > timedelta(hours=24):
|
||||||
|
anomalies.append("blocked_timeout")
|
||||||
|
|
||||||
|
# unclaimed:TODO 列超 3 天 → 标黄
|
||||||
|
if task["column"] == "todo" and (now - created) > timedelta(days=3):
|
||||||
|
anomalies.append("unclaimed")
|
||||||
|
|
||||||
|
# retry_failure:失败重试≥3 次 → 标红
|
||||||
|
if task.get("failures", 0) >= 3:
|
||||||
|
anomalies.append("retry_failure")
|
||||||
|
|
||||||
|
return anomalies
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
async def list_tasks(
|
||||||
|
worker: str = Query("", description="Worker profile 筛选"),
|
||||||
|
board: str = Query("", description="看板名筛选"),
|
||||||
|
anomaly: str = Query("", description="异常类型: blocked_timeout|unclaimed|retry_failure"),
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""跨 Profile 五列任务聚合。"""
|
||||||
|
tasks = copy.deepcopy(MOCK_TASKS)
|
||||||
|
|
||||||
|
if worker:
|
||||||
|
tasks = [t for t in tasks if t["worker_profile"] == worker]
|
||||||
|
if board:
|
||||||
|
tasks = [t for t in tasks if t["board"] == board]
|
||||||
|
if anomaly:
|
||||||
|
tasks = [t for t in tasks if anomaly in _detect_anomalies(t)]
|
||||||
|
|
||||||
|
# 五列分组
|
||||||
|
columns = {"todo": [], "ready": [], "running": [], "blocked": [], "done": []}
|
||||||
|
for t in tasks:
|
||||||
|
anomalies = _detect_anomalies(t)
|
||||||
|
col = t["column"]
|
||||||
|
if col in columns:
|
||||||
|
columns[col].append({**t, "anomalies": anomalies})
|
||||||
|
|
||||||
|
# 每列按优先级排序
|
||||||
|
for col in columns:
|
||||||
|
columns[col].sort(key=lambda x: (x["priority"], x["created_at"]))
|
||||||
|
|
||||||
|
profiles = {t["worker_profile"] for t in tasks}
|
||||||
|
boards = {t["board"] for t in tasks}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"todo": columns["todo"],
|
||||||
|
"ready": columns["ready"],
|
||||||
|
"running": columns["running"],
|
||||||
|
"blocked": columns["blocked"],
|
||||||
|
"done": columns["done"],
|
||||||
|
"overview": {
|
||||||
|
"total_tasks": len(tasks),
|
||||||
|
"profile_count": len(profiles),
|
||||||
|
"board_count": len(boards),
|
||||||
|
"running_count": len(columns["running"]),
|
||||||
|
"blocked_count": len(columns["blocked"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}")
|
||||||
|
async def get_task_detail(
|
||||||
|
task_id: str,
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""任务详情。"""
|
||||||
|
for t in MOCK_TASKS:
|
||||||
|
if t["id"] == task_id:
|
||||||
|
detail = copy.deepcopy(t)
|
||||||
|
detail["anomalies"] = _detect_anomalies(t)
|
||||||
|
# 补全依赖/被依赖任务信息
|
||||||
|
dep_ids = detail.get("dependencies", [])
|
||||||
|
dep_tasks = [m for m in MOCK_TASKS if m["id"] in dep_ids]
|
||||||
|
detail["dependency_tasks"] = [
|
||||||
|
{"id": m["id"], "title": m["title"], "column": m["column"]}
|
||||||
|
for m in dep_tasks
|
||||||
|
]
|
||||||
|
dep_by_ids = detail.get("dependents", [])
|
||||||
|
dep_by_tasks = [m for m in MOCK_TASKS if m["id"] in dep_by_ids]
|
||||||
|
detail["dependent_tasks"] = [
|
||||||
|
{"id": m["id"], "title": m["title"], "column": m["column"]}
|
||||||
|
for m in dep_by_tasks
|
||||||
|
]
|
||||||
|
return detail
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tasks/{task_id}/move")
|
||||||
|
async def move_task(
|
||||||
|
task_id: str,
|
||||||
|
body: dict,
|
||||||
|
_user: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""拖拽移动任务。"""
|
||||||
|
column = body.get("column")
|
||||||
|
block_reason = body.get("block_reason")
|
||||||
|
|
||||||
|
valid_columns = {"todo", "ready", "running", "blocked", "done"}
|
||||||
|
if column not in valid_columns:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的列名")
|
||||||
|
|
||||||
|
for t in MOCK_TASKS:
|
||||||
|
if t["id"] == task_id:
|
||||||
|
# 拖入 BLOCKED 需填写阻塞原因
|
||||||
|
if column == "blocked":
|
||||||
|
if not block_reason or len(block_reason.strip()) < 10:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="拖入 BLOCKED 列需填写阻塞原因(至少 10 字)",
|
||||||
|
)
|
||||||
|
t["block_reason"] = block_reason.strip()
|
||||||
|
else:
|
||||||
|
t["block_reason"] = None
|
||||||
|
|
||||||
|
t["column"] = column
|
||||||
|
t["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
t["activities"].insert(0, {
|
||||||
|
"time": t["updated_at"],
|
||||||
|
"user": "manual",
|
||||||
|
"action": f"移动到 {column}",
|
||||||
|
})
|
||||||
|
return {"success": True, "task": {**t, "anomalies": _detect_anomalies(t)}}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Keys API — 密钥批量管理。"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_role
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.key_manager import (
|
||||||
|
WHITELIST_FIELDS,
|
||||||
|
get_keys,
|
||||||
|
batch_overwrite,
|
||||||
|
batch_scan as scan_profiles,
|
||||||
|
get_diff,
|
||||||
|
export_csv,
|
||||||
|
list_profiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/keys", tags=["keys"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_keys(
|
||||||
|
profiles: str = Query("", description="逗号分隔的 Profile 名"),
|
||||||
|
_user: User = Depends(require_role("owner", "operator")),
|
||||||
|
):
|
||||||
|
"""返回密钥字段列表(脱敏显示)。"""
|
||||||
|
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
|
||||||
|
data = get_keys(profile_list)
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-overwrite")
|
||||||
|
async def batch_overwrite_keys(
|
||||||
|
body: dict,
|
||||||
|
user: User = Depends(require_role("owner", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""批量覆盖密钥。"""
|
||||||
|
profile_names = body.get("profile_names", [])
|
||||||
|
key_name = body.get("key_name", "")
|
||||||
|
new_value = body.get("new_value", "")
|
||||||
|
confirmation = body.get("confirmation", "")
|
||||||
|
|
||||||
|
if key_name not in WHITELIST_FIELDS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"不支持的密钥字段: {key_name}")
|
||||||
|
|
||||||
|
# 验证确认短语
|
||||||
|
expected = f"我确认覆盖 {len(profile_names)} 个 Profile"
|
||||||
|
if confirmation != expected:
|
||||||
|
raise HTTPException(status_code=400, detail="确认短语不匹配")
|
||||||
|
|
||||||
|
results = batch_overwrite(profile_names, key_name, new_value)
|
||||||
|
|
||||||
|
# 审计日志(不记录原文)
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
action="keys:batch-overwrite",
|
||||||
|
target=f"profiles:{','.join(profile_names)}",
|
||||||
|
detail=f"key={key_name}, profiles={','.join(profile_names)}, "
|
||||||
|
f"success={sum(1 for r in results if r['success'])}/{len(results)}",
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-scan")
|
||||||
|
async def batch_scan_keys(
|
||||||
|
body: dict,
|
||||||
|
_user: User = Depends(require_role("owner", "operator")),
|
||||||
|
):
|
||||||
|
"""批量检查缺失密钥。"""
|
||||||
|
profile_names = body.get("profile_names")
|
||||||
|
if profile_names is not None and not isinstance(profile_names, list):
|
||||||
|
raise HTTPException(status_code=400, detail="profile_names 必须为数组")
|
||||||
|
results = scan_profiles(profile_names)
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diff")
|
||||||
|
async def diff_keys(
|
||||||
|
profiles: str = Query("", description="逗号分隔的 Profile 名"),
|
||||||
|
_user: User = Depends(require_role("owner", "operator")),
|
||||||
|
):
|
||||||
|
"""差异视图。"""
|
||||||
|
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else list_profiles()
|
||||||
|
rows = get_diff(profile_list)
|
||||||
|
return {"profiles": profile_list, "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export", response_class=PlainTextResponse)
|
||||||
|
async def export_keys_csv(
|
||||||
|
profiles: str = Query("", description="逗号分隔的 Profile 名"),
|
||||||
|
_user: User = Depends(require_role("owner", "operator")),
|
||||||
|
):
|
||||||
|
"""CSV 导出批量检查结果。"""
|
||||||
|
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
|
||||||
|
return export_csv(profile_list)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""User management API: list, detail, update, approve — only owner."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserResponse, UserUpdateRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserResponse])
|
||||||
|
async def list_users(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(
|
||||||
|
user_id: str,
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserResponse)
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
body: UserUpdateRequest,
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if body.role is not None:
|
||||||
|
target.role = body.role
|
||||||
|
if body.is_active is not None:
|
||||||
|
target.is_active = body.is_active
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/approve", response_model=UserResponse)
|
||||||
|
async def approve_user(
|
||||||
|
user_id: str,
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if target.is_approved:
|
||||||
|
raise HTTPException(status_code=400, detail="User already approved")
|
||||||
|
|
||||||
|
target.is_approved = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target)
|
||||||
|
return target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""应用配置 — 从环境变量 / .env 读取。"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "postgresql+asyncpg://hive_admin:changeme@localhost:5432/hive_manage_v3"
|
||||||
|
jwt_secret: str = "change-me"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_expire_minutes: int = 1440
|
||||||
|
hermes_cli_path: str = "/home/enzo/.local/bin/hermes"
|
||||||
|
hermes_profiles_root: str = "/home/enzo/.hermes/profiles"
|
||||||
|
admin_email: str = "admin@zhiwei.local"
|
||||||
|
admin_password: str = "admin123"
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""异步 SQLAlchemy 引擎 + session 工厂。"""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""FastAPI 应用入口。"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.database import engine, Base
|
||||||
|
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys, kanban, events
|
||||||
|
from app.middleware.audit import AuditMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Hive Manage v3",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(audit.router)
|
||||||
|
app.include_router(dashboard.router, prefix="/api/v1")
|
||||||
|
app.include_router(gateway.router, prefix="/api/v1")
|
||||||
|
app.include_router(health.router, prefix="/api/v1")
|
||||||
|
app.include_router(cron.router, prefix="/api/v1")
|
||||||
|
app.include_router(kanban.router, prefix="/api/v1")
|
||||||
|
app.include_router(events.router, prefix="/api/v1")
|
||||||
|
app.include_router(keys.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Audit middleware: log all mutating requests to AuditLog."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.database import async_session
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.services.auth_service import decode_access_token, get_user_by_id
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
response = await call_next(request)
|
||||||
|
await self._log(request, response)
|
||||||
|
return response
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
async def _log(self, request: Request, response: Response):
|
||||||
|
try:
|
||||||
|
user_id = "anonymous"
|
||||||
|
username = "anonymous"
|
||||||
|
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(auth_header[7:])
|
||||||
|
async with async_session() as db:
|
||||||
|
user = await get_user_by_id(db, payload["sub"])
|
||||||
|
if user:
|
||||||
|
user_id = user.id
|
||||||
|
username = user.username
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
action = f"{request.method} {path}"
|
||||||
|
|
||||||
|
detail = {}
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = b""
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
if body:
|
||||||
|
detail["request_body"] = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
action=action,
|
||||||
|
target=path,
|
||||||
|
detail=json.dumps(detail) if detail else None,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.auth_service import decode_access_token, get_user_by_id
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
user = await get_user_by_id(db, payload["sub"])
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or inactive user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles: str):
|
||||||
|
async def role_check(user=Depends(require_auth)):
|
||||||
|
if user.role.value not in roles:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
return role_check
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.event import Event
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""审计日志 ORM 模型。"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
target: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""事件档案 ORM 模型。"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
from sqlalchemy import String, DateTime, Date, Integer, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
FADING = "fading"
|
||||||
|
DISSIPATING = "dissipating"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class EventLevel(str, enum.Enum):
|
||||||
|
A = "A"
|
||||||
|
B = "B"
|
||||||
|
C = "C"
|
||||||
|
|
||||||
|
|
||||||
|
DOMAINS = [
|
||||||
|
"gateway",
|
||||||
|
"crawler",
|
||||||
|
"reporter",
|
||||||
|
"feishu",
|
||||||
|
"database",
|
||||||
|
"security",
|
||||||
|
"network",
|
||||||
|
"storage",
|
||||||
|
"monitor",
|
||||||
|
"scheduler",
|
||||||
|
"api",
|
||||||
|
"other",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
level: Mapped[EventLevel] = mapped_column(SAEnum(EventLevel), nullable=False)
|
||||||
|
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
status: Mapped[EventStatus] = mapped_column(SAEnum(EventStatus), default=EventStatus.ACTIVE, nullable=False, index=True)
|
||||||
|
occurred_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
last_reviewed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
no_new_info_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_review: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""用户 ORM 模型。"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
owner = "owner"
|
||||||
|
operator = "operator"
|
||||||
|
viewer = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.viewer, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
login_attempts: Mapped[int] = mapped_column(default=0)
|
||||||
|
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str # 先用 str,后续升级 EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
is_approved: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Auth service: password hashing, JWT, user queries."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from jose import JWTError, ExpiredSignatureError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _pwd.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return _pwd.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: str, role: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
payload = {"sub": user_id, "role": role, "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.username == username))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User:
|
||||||
|
user = await get_user_by_username(db, username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# Check lock
|
||||||
|
if user.locked_until and user.locked_until > datetime.utcnow():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail=f"Account locked until {user.locked_until.isoformat()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
user.login_attempts += 1
|
||||||
|
if user.login_attempts >= 5:
|
||||||
|
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# Success — reset
|
||||||
|
user.login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
await db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(db: AsyncSession, username: str, email: str, password: str) -> User:
|
||||||
|
result = await db.execute(select(User))
|
||||||
|
existing_count = len(result.scalars().all())
|
||||||
|
|
||||||
|
if existing_count == 0:
|
||||||
|
role = UserRole.owner
|
||||||
|
is_approved = True
|
||||||
|
else:
|
||||||
|
role = UserRole.viewer
|
||||||
|
is_approved = False
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
hashed_password=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
is_approved=is_approved,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Event Crawler — 爬取 hm.zhiweidata.ai/events 数据存入 events 表。
|
||||||
|
|
||||||
|
TODO: 替换为真实爬虫逻辑。当前返回占位数据。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, date, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import async_session
|
||||||
|
from app.models.event import Event, EventStatus, EventLevel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLACEHOLDER_EVENTS = [
|
||||||
|
{
|
||||||
|
"title": "taiwan Gateway 频繁重启",
|
||||||
|
"level": EventLevel.A,
|
||||||
|
"domain": "gateway",
|
||||||
|
"status": EventStatus.ACTIVE,
|
||||||
|
"occurred_date": date.today() - timedelta(days=1),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=6),
|
||||||
|
"no_new_info_count": 0,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(hours=6),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "guangxi 爬虫数据延迟",
|
||||||
|
"level": EventLevel.B,
|
||||||
|
"domain": "crawler",
|
||||||
|
"status": EventStatus.ACTIVE,
|
||||||
|
"occurred_date": date.today() - timedelta(days=2),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=1),
|
||||||
|
"no_new_info_count": 1,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(hours=12),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "飞书 Webhook 推送异常",
|
||||||
|
"level": EventLevel.B,
|
||||||
|
"domain": "feishu",
|
||||||
|
"status": EventStatus.FADING,
|
||||||
|
"occurred_date": date.today() - timedelta(days=5),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=2),
|
||||||
|
"no_new_info_count": 3,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(days=1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "ops-engineer 日报生成失败",
|
||||||
|
"level": EventLevel.C,
|
||||||
|
"domain": "reporter",
|
||||||
|
"status": EventStatus.FADING,
|
||||||
|
"occurred_date": date.today() - timedelta(days=7),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=3),
|
||||||
|
"no_new_info_count": 4,
|
||||||
|
"next_review": datetime.now(timezone.utc) - timedelta(days=1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "hebo 数据库连接超时",
|
||||||
|
"level": EventLevel.A,
|
||||||
|
"domain": "database",
|
||||||
|
"status": EventStatus.DISSIPATING,
|
||||||
|
"occurred_date": date.today() - timedelta(days=14),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=7),
|
||||||
|
"no_new_info_count": 6,
|
||||||
|
"next_review": datetime.now(timezone.utc) - timedelta(days=3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "密钥轮换提醒 (taiwan)",
|
||||||
|
"level": EventLevel.C,
|
||||||
|
"domain": "security",
|
||||||
|
"status": EventStatus.DISSIPATING,
|
||||||
|
"occurred_date": date.today() - timedelta(days=20),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=10),
|
||||||
|
"no_new_info_count": 5,
|
||||||
|
"next_review": datetime.now(timezone.utc) - timedelta(days=5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Gateway 配置同步冲突",
|
||||||
|
"level": EventLevel.B,
|
||||||
|
"domain": "gateway",
|
||||||
|
"status": EventStatus.CLOSED,
|
||||||
|
"occurred_date": date.today() - timedelta(days=30),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=25),
|
||||||
|
"no_new_info_count": 8,
|
||||||
|
"next_review": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "网络延迟告警 (overseas node)",
|
||||||
|
"level": EventLevel.B,
|
||||||
|
"domain": "network",
|
||||||
|
"status": EventStatus.ACTIVE,
|
||||||
|
"occurred_date": date.today() - timedelta(days=3),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=12),
|
||||||
|
"no_new_info_count": 2,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(hours=18),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "存储空间不足告警",
|
||||||
|
"level": EventLevel.C,
|
||||||
|
"domain": "storage",
|
||||||
|
"status": EventStatus.ACTIVE,
|
||||||
|
"occurred_date": date.today() - timedelta(days=4),
|
||||||
|
"last_reviewed": None,
|
||||||
|
"no_new_info_count": 0,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(days=1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Monitor 心跳超时",
|
||||||
|
"level": EventLevel.A,
|
||||||
|
"domain": "monitor",
|
||||||
|
"status": EventStatus.ACTIVE,
|
||||||
|
"occurred_date": date.today() - timedelta(hours=8),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=4),
|
||||||
|
"no_new_info_count": 0,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(hours=2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Scheduler 任务积压",
|
||||||
|
"level": EventLevel.B,
|
||||||
|
"domain": "scheduler",
|
||||||
|
"status": EventStatus.FADING,
|
||||||
|
"occurred_date": date.today() - timedelta(days=6),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=2),
|
||||||
|
"no_new_info_count": 3,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(hours=10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "API 响应延迟 (p95 > 5s)",
|
||||||
|
"level": EventLevel.A,
|
||||||
|
"domain": "api",
|
||||||
|
"status": EventStatus.ACTIVE,
|
||||||
|
"occurred_date": date.today() - timedelta(hours=12),
|
||||||
|
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=3),
|
||||||
|
"no_new_info_count": 0,
|
||||||
|
"next_review": datetime.now(timezone.utc) + timedelta(hours=4),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_placeholder_events() -> list[Event]:
|
||||||
|
"""向数据库写入占位事件数据(幂等:清空后重写)。"""
|
||||||
|
async with async_session() as db:
|
||||||
|
existing = await db.execute(select(Event).limit(1))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
logger.info("events 表已有数据,跳过占位种子")
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for data in PLACEHOLDER_EVENTS:
|
||||||
|
event = Event(**data)
|
||||||
|
db.add(event)
|
||||||
|
events.append(event)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("写入 %d 条占位事件", len(events))
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def run_crawl():
|
||||||
|
"""定时爬取入口(TODO: 真实 HTTP 爬取 hm.zhiweidata.ai/events)。"""
|
||||||
|
logger.info("Event crawler 执行开始")
|
||||||
|
await seed_placeholder_events()
|
||||||
|
logger.info("Event crawler 执行结束")
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""健康分计算引擎 — 评分规则 + 缓存。
|
||||||
|
|
||||||
|
评分维度:
|
||||||
|
- Gateway 稳定性 40%: 7天在线率 + 日均重启惩罚
|
||||||
|
- 配置完整性 25%: .env + config.yaml 必填字段检查
|
||||||
|
- Skill 一致性 20%: 与基准 Skill 清单对比
|
||||||
|
- Cron 成功率 15%: 24h Cron 成功率
|
||||||
|
|
||||||
|
等级: A(80-100) B(60-79) C(40-59) D(0-39)
|
||||||
|
|
||||||
|
数据来源暂用占位(TODO: hermes CLI / 文件读取),评分逻辑完整实现。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── 类型 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DimensionScore:
|
||||||
|
score: float
|
||||||
|
max_score: float
|
||||||
|
deductions: list[str] = field(default_factory=list)
|
||||||
|
suggestions: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthScore:
|
||||||
|
profile_name: str
|
||||||
|
total_score: float
|
||||||
|
grade: str
|
||||||
|
gateway: DimensionScore
|
||||||
|
config: DimensionScore
|
||||||
|
skill: DimensionScore
|
||||||
|
cron: DimensionScore
|
||||||
|
deduct_summary: list[str] = field(default_factory=list)
|
||||||
|
calculated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
GRADE_THRESHOLDS = [
|
||||||
|
(80, "A"),
|
||||||
|
(60, "B"),
|
||||||
|
(40, "C"),
|
||||||
|
(0, "D"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_grade(score: float) -> str:
|
||||||
|
for threshold, grade in GRADE_THRESHOLDS:
|
||||||
|
if score >= threshold:
|
||||||
|
return grade
|
||||||
|
return "D"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dt(dt: datetime | None = None) -> str:
|
||||||
|
return (dt or datetime.now(timezone.utc)).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 占位数据源(TODO: 对接真实数据) ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_gateway_data(profile: str) -> dict:
|
||||||
|
"""返回 Profile 的 Gateway 占位状态数据。"""
|
||||||
|
# 根据 profile 名 hash 出不同状态以产生多样结果
|
||||||
|
seed = sum(ord(c) for c in profile)
|
||||||
|
uptime = 0.85 + (seed % 15) / 100 # 0.85~0.99
|
||||||
|
restarts = (seed % 7) / 10 # 0.0~0.6
|
||||||
|
return {
|
||||||
|
"uptime_7d": min(uptime, 0.999),
|
||||||
|
"avg_daily_restarts": restarts,
|
||||||
|
"online": seed % 5 != 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_REQUIRED_CONFIG_FIELDS = [
|
||||||
|
"BOT_TOKEN",
|
||||||
|
"BOT_SECRET",
|
||||||
|
"APP_ID",
|
||||||
|
"APP_SECRET",
|
||||||
|
"PROFILE_NAME",
|
||||||
|
"LOG_LEVEL",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_config_data(profile: str) -> dict:
|
||||||
|
"""返回 Profile 的配置完整性占位数据。"""
|
||||||
|
seed = sum(ord(c) for c in profile)
|
||||||
|
# 每个 Profile 缺失 0~3 个字段
|
||||||
|
missing_count = seed % 4
|
||||||
|
rng = random.Random(profile)
|
||||||
|
missing = rng.sample(_REQUIRED_CONFIG_FIELDS, missing_count) if missing_count else []
|
||||||
|
return {
|
||||||
|
"total_required": len(_REQUIRED_CONFIG_FIELDS),
|
||||||
|
"missing_fields": missing,
|
||||||
|
"present_fields": [f for f in _REQUIRED_CONFIG_FIELDS if f not in missing],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_BASELINE_SKILLS = {"gateway", "feishu", "cron", "kanban", "search", "keys"}
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_skill_data(profile: str) -> dict:
|
||||||
|
"""返回 Skill 一致性占位数据。"""
|
||||||
|
seed = sum(ord(c) for c in profile)
|
||||||
|
rng = random.Random(profile)
|
||||||
|
profile_skills = set(_BASELINE_SKILLS)
|
||||||
|
# 可能缺失几个
|
||||||
|
for s in rng.sample(list(_BASELINE_SKILLS), seed % 4):
|
||||||
|
profile_skills.discard(s)
|
||||||
|
# 可能多出几个
|
||||||
|
extras = {"notion", "slack", "jira", "github", "gitlab"}
|
||||||
|
for s in rng.sample(list(extras), seed % 3):
|
||||||
|
profile_skills.add(s)
|
||||||
|
|
||||||
|
missing = _BASELINE_SKILLS - profile_skills
|
||||||
|
surplus = profile_skills - _BASELINE_SKILLS
|
||||||
|
return {
|
||||||
|
"baseline": list(_BASELINE_SKILLS),
|
||||||
|
"profile_skills": list(profile_skills),
|
||||||
|
"missing": list(missing),
|
||||||
|
"surplus": list(surplus),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_cron_data(profile: str) -> dict:
|
||||||
|
"""返回 Cron 执行状态占位数据。"""
|
||||||
|
seed = sum(ord(c) for c in profile)
|
||||||
|
success_rate = 0.7 + (seed % 6) / 20 # 0.7~0.95
|
||||||
|
return {
|
||||||
|
"total_executions_24h": 10 + seed % 10,
|
||||||
|
"success_count": int((10 + seed % 10) * success_rate),
|
||||||
|
"success_rate": success_rate,
|
||||||
|
"recent_failures": [f"cron-job-{i}" for i in range(seed % 4)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 评分逻辑 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _score_gateway(profile: str) -> DimensionScore:
|
||||||
|
data = _placeholder_gateway_data(profile)
|
||||||
|
deductions: list[str] = []
|
||||||
|
suggestions: list[str] = []
|
||||||
|
|
||||||
|
uptime = data["uptime_7d"]
|
||||||
|
restarts = data["avg_daily_restarts"]
|
||||||
|
|
||||||
|
# 基础分: 7天在线率 × 40
|
||||||
|
score = uptime * 40
|
||||||
|
|
||||||
|
if uptime < 0.99:
|
||||||
|
deductions.append(f"7天在线率 {uptime:.1%}(目标 ≥99%)")
|
||||||
|
suggestions.append("检查 Gateway 进程稳定性,考虑设置自动重启")
|
||||||
|
|
||||||
|
# 日均重启惩罚: >0.3 次/天 开始扣分
|
||||||
|
if restarts > 0.3:
|
||||||
|
penalty = min(score, (restarts - 0.3) * 15)
|
||||||
|
score -= penalty
|
||||||
|
deductions.append(f"日均重启 {restarts:.1f} 次(阈值 ≤0.3),扣 {penalty:.1f} 分")
|
||||||
|
suggestions.append("排查频繁重启原因,检查内存泄漏或日志错误")
|
||||||
|
|
||||||
|
if not data["online"]:
|
||||||
|
score *= 0.5
|
||||||
|
deductions.append("Gateway 当前离线,得分减半")
|
||||||
|
suggestions.append("立即启动 Gateway 进程")
|
||||||
|
|
||||||
|
return DimensionScore(
|
||||||
|
score=max(0, round(score, 1)),
|
||||||
|
max_score=40,
|
||||||
|
deductions=deductions,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _score_config(profile: str) -> DimensionScore:
|
||||||
|
data = _placeholder_config_data(profile)
|
||||||
|
deductions: list[str] = []
|
||||||
|
suggestions: list[str] = []
|
||||||
|
|
||||||
|
missing = data["missing_fields"]
|
||||||
|
total = data["total_required"]
|
||||||
|
|
||||||
|
score = 25.0 # 满分 25
|
||||||
|
if missing:
|
||||||
|
deduction_pct = len(missing) * 0.1 # 缺 1 个扣 10%
|
||||||
|
score *= max(0, 1 - deduction_pct)
|
||||||
|
deductions.append(f"缺 {len(missing)} 个必填字段: {', '.join(missing)}")
|
||||||
|
suggestions.append(f"在 .env 或 config.yaml 中补充: {', '.join(missing)}")
|
||||||
|
|
||||||
|
return DimensionScore(
|
||||||
|
score=max(0, round(score, 1)),
|
||||||
|
max_score=25,
|
||||||
|
deductions=deductions,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _score_skill(profile: str) -> DimensionScore:
|
||||||
|
data = _placeholder_skill_data(profile)
|
||||||
|
deductions: list[str] = []
|
||||||
|
suggestions: list[str] = []
|
||||||
|
|
||||||
|
missing = data["missing"]
|
||||||
|
surplus = data["surplus"]
|
||||||
|
|
||||||
|
# 基准技能匹配率 → 满分 20
|
||||||
|
baseline_hit = len(data["baseline"]) - len(missing)
|
||||||
|
if data["baseline"]:
|
||||||
|
match_ratio = baseline_hit / len(data["baseline"])
|
||||||
|
else:
|
||||||
|
match_ratio = 1.0
|
||||||
|
score = match_ratio * 20
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
deductions.append(f"缺失基准 Skill: {', '.join(missing)}")
|
||||||
|
suggestions.append(f"安装缺失 Skill: {', '.join(missing)}")
|
||||||
|
if surplus:
|
||||||
|
deductions.append(f"多余未识别 Skill: {', '.join(surplus)}")
|
||||||
|
suggestions.append(f"检查多余 Skill 是否必要: {', '.join(surplus)}")
|
||||||
|
|
||||||
|
return DimensionScore(
|
||||||
|
score=max(0, round(score, 1)),
|
||||||
|
max_score=20,
|
||||||
|
deductions=deductions,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _score_cron(profile: str) -> DimensionScore:
|
||||||
|
data = _placeholder_cron_data(profile)
|
||||||
|
deductions: list[str] = []
|
||||||
|
suggestions: list[str] = []
|
||||||
|
|
||||||
|
success_rate = data["success_rate"]
|
||||||
|
score = success_rate * 15 # 成功率 × 15
|
||||||
|
|
||||||
|
if success_rate < 0.95:
|
||||||
|
deductions.append(f"24h Cron 成功率 {success_rate:.1%}(目标 ≥95%)")
|
||||||
|
suggestions.append("检查失败 Cron 任务的日志,确认是否因超时或配置错误")
|
||||||
|
|
||||||
|
if data["recent_failures"]:
|
||||||
|
deductions.append(f"失败任务: {', '.join(data['recent_failures'])}")
|
||||||
|
suggestions.append("逐个排查失败任务,优先修复高频失败项")
|
||||||
|
|
||||||
|
return DimensionScore(
|
||||||
|
score=max(0, round(score, 1)),
|
||||||
|
max_score=15,
|
||||||
|
deductions=deductions,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 对外接口 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
SCORE_CACHE: dict[str, HealthScore] = {}
|
||||||
|
_LAST_CALCULATED: datetime | None = None
|
||||||
|
_CACHE_TTL = timedelta(hours=24)
|
||||||
|
|
||||||
|
# 占位 Profile 列表(TODO: 从 hermes 配置目录读取)
|
||||||
|
PLACEHOLDER_PROFILES = [
|
||||||
|
"hebo", "ops-engineer", "monitor", "visualizer",
|
||||||
|
"gateway-monitor", "feishu-gw", "taiwan-briefing",
|
||||||
|
"daily-report", "weekly-digest", "hindsight",
|
||||||
|
"guangxi-donation", "europe-briefing",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_health_score(profile_name: str) -> HealthScore:
|
||||||
|
"""计算单个 Profile 的健康分。"""
|
||||||
|
gw = _score_gateway(profile_name)
|
||||||
|
cf = _score_config(profile_name)
|
||||||
|
sk = _score_skill(profile_name)
|
||||||
|
cr = _score_cron(profile_name)
|
||||||
|
|
||||||
|
total = round(gw.score + cf.score + sk.score + cr.score, 1)
|
||||||
|
grade = _to_grade(total)
|
||||||
|
|
||||||
|
deduct_summary = []
|
||||||
|
for dim, label in [(gw, "Gateway"), (cf, "配置"), (sk, "Skill"), (cr, "Cron")]:
|
||||||
|
for d in dim.deductions:
|
||||||
|
deduct_summary.append(f"[{label}] {d}")
|
||||||
|
|
||||||
|
return HealthScore(
|
||||||
|
profile_name=profile_name,
|
||||||
|
total_score=total,
|
||||||
|
grade=grade,
|
||||||
|
gateway=gw,
|
||||||
|
config=cf,
|
||||||
|
skill=sk,
|
||||||
|
cron=cr,
|
||||||
|
deduct_summary=deduct_summary[:5], # 最多显示 5 条
|
||||||
|
calculated_at=_fmt_dt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cache_stale() -> bool:
|
||||||
|
if _LAST_CALCULATED is None:
|
||||||
|
return True
|
||||||
|
return datetime.now(timezone.utc) - _LAST_CALCULATED > _CACHE_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_all_scores(profiles: list[str] | None = None) -> list[HealthScore]:
|
||||||
|
"""计算全部 Profile 的健康分,使用缓存(TTL 24h)。"""
|
||||||
|
global _LAST_CALCULATED
|
||||||
|
|
||||||
|
if not _is_cache_stale() and SCORE_CACHE:
|
||||||
|
logger.info("健康分缓存命中(%d 条)", len(SCORE_CACHE))
|
||||||
|
scores = list(SCORE_CACHE.values())
|
||||||
|
if profiles:
|
||||||
|
return [s for s in scores if s.profile_name in profiles]
|
||||||
|
return scores
|
||||||
|
|
||||||
|
target_profiles = profiles or PLACEHOLDER_PROFILES
|
||||||
|
scores: list[HealthScore] = []
|
||||||
|
for name in target_profiles:
|
||||||
|
try:
|
||||||
|
hs = calculate_health_score(name)
|
||||||
|
SCORE_CACHE[name] = hs
|
||||||
|
scores.append(hs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("计算 %s 健康分异常: %s", name, e)
|
||||||
|
|
||||||
|
_LAST_CALCULATED = datetime.now(timezone.utc)
|
||||||
|
logger.info("健康分已刷新(%d 条)", len(scores))
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
"""强制清除缓存,下次调用重新计算。"""
|
||||||
|
global _LAST_CALCULATED
|
||||||
|
SCORE_CACHE.clear()
|
||||||
|
_LAST_CALCULATED = None
|
||||||
|
logger.info("健康分缓存已清除")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Hermes CLI 封装 — 通过 subprocess 调用 hermes 命令。"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hermes(*args: str, timeout: int = 15) -> subprocess.CompletedProcess:
|
||||||
|
"""执行 hermes CLI 命令,返回 CompletedProcess。"""
|
||||||
|
return subprocess.run(
|
||||||
|
[settings.hermes_cli_path, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_status_all() -> list[dict]:
|
||||||
|
"""执行 `hermes gateway status`,返回解析后的 Gateway 状态列表。
|
||||||
|
|
||||||
|
出错时返回空列表并记录告警日志。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = _run_hermes("gateway", "status")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.warning("hermes gateway status 返回非零: %s", proc.stderr.strip())
|
||||||
|
return []
|
||||||
|
data = json.loads(proc.stdout)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
return data.get("gateways", [])
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("Hermes CLI 未找到: %s", settings.hermes_cli_path)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning("解析 hermes gateway status 输出失败: %s", e)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("hermes gateway status 执行超时")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("调用 hermes gateway status 异常: %s", e)
|
||||||
|
return []
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Key Manager 服务 — .env 密钥读写、脱敏、批量操作。"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 可管理的密钥白名单
|
||||||
|
WHITELIST_FIELDS = [
|
||||||
|
"DEEPSEEK_API_KEY",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
"FEISHU_APP_ID",
|
||||||
|
"FEISHU_APP_SECRET",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROFILES_ROOT = Path(settings.hermes_profiles_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env(content: str) -> dict[str, str]:
|
||||||
|
"""解析 .env 文件内容为 key-value 字典。"""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in stripped:
|
||||||
|
continue
|
||||||
|
key, _, val = stripped.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip().strip("\"'")
|
||||||
|
if key in WHITELIST_FIELDS:
|
||||||
|
result[key] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_value(value: str) -> str:
|
||||||
|
"""脱敏: 前3字符 + **...** + 后4字符。"""
|
||||||
|
if len(value) <= 7:
|
||||||
|
return value[:3] + "**...**" + (value[-4:] if len(value) > 3 else "")
|
||||||
|
return value[:3] + "**...**" + value[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_file(profile: str) -> dict[str, str]:
|
||||||
|
"""读取指定 Profile 的 .env 文件中白名单字段。"""
|
||||||
|
env_path = PROFILES_ROOT / profile / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
content = env_path.read_text(encoding="utf-8")
|
||||||
|
return _parse_env(content)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("读取 %s/.env 失败: %s", profile, e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env_file(profile: str, updates: dict[str, str]) -> bool:
|
||||||
|
"""写入指定 Profile 的 .env 文件,操作前自动备份。"""
|
||||||
|
env_path = PROFILES_ROOT / profile / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
try:
|
||||||
|
shutil.copy2(env_path, env_path.with_suffix(".env.bak"))
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("备份 %s/.env 失败: %s", profile, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = env_path.read_text(encoding="utf-8")
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
updated_keys = set(updates.keys())
|
||||||
|
new_lines: list[str] = []
|
||||||
|
found: set[str] = set()
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
if "=" not in stripped:
|
||||||
|
new_lines.append(line)
|
||||||
|
continue
|
||||||
|
key = stripped.partition("=")[0].strip()
|
||||||
|
if key in updated_keys:
|
||||||
|
new_lines.append(f"{key}={updates[key]}\n")
|
||||||
|
found.add(key)
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
# 追加未找到的字段
|
||||||
|
for key in updated_keys:
|
||||||
|
if key not in found:
|
||||||
|
new_lines.append(f"{key}={updates[key]}\n")
|
||||||
|
env_path.write_text("".join(new_lines), encoding="utf-8")
|
||||||
|
logger.info("已写入 %s/.env (%d 个字段)", profile, len(updates))
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("写入 %s/.env 失败: %s", profile, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> list[str]:
|
||||||
|
"""返回所有包含 .env 文件的 Profile 名称列表。"""
|
||||||
|
if not PROFILES_ROOT.exists():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
d.name
|
||||||
|
for d in PROFILES_ROOT.iterdir()
|
||||||
|
if d.is_dir() and (d / ".env").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_keys(profile_names: Optional[list[str]] = None) -> list[dict]:
|
||||||
|
"""获取指定 Profile 的密钥列表(脱敏显示)。"""
|
||||||
|
profiles = profile_names if profile_names else list_profiles()
|
||||||
|
result: list[dict] = []
|
||||||
|
for name in profiles:
|
||||||
|
fields = _read_env_file(name)
|
||||||
|
masked = {k: _mask_value(v) for k, v in fields.items()}
|
||||||
|
missing = len(WHITELIST_FIELDS) - len(fields)
|
||||||
|
result.append({"profile": name, "fields": masked, "missing": missing})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def batch_overwrite(
|
||||||
|
profile_names: list[str],
|
||||||
|
key_name: str,
|
||||||
|
new_value: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""批量覆盖指定 Profile 的某个密钥字段。"""
|
||||||
|
results: list[dict] = []
|
||||||
|
for name in profile_names:
|
||||||
|
success = _write_env_file(name, {key_name: new_value})
|
||||||
|
results.append({"profile": name, "success": success})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def batch_scan(profile_names: Optional[list[str]] = None) -> list[dict]:
|
||||||
|
"""批量检查缺失的密钥字段。"""
|
||||||
|
profiles = profile_names if profile_names else list_profiles()
|
||||||
|
results: list[dict] = []
|
||||||
|
for name in profiles:
|
||||||
|
fields = _read_env_file(name)
|
||||||
|
missing = [f for f in WHITELIST_FIELDS if f not in fields]
|
||||||
|
results.append({
|
||||||
|
"profile": name,
|
||||||
|
"missing": missing,
|
||||||
|
"missing_count": len(missing),
|
||||||
|
"total": len(WHITELIST_FIELDS),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_diff(profile_names: list[str]) -> list[dict]:
|
||||||
|
"""差异视图: 横向 Profile, 纵向字段, 值状态(有/无/长度)。"""
|
||||||
|
profiles = profile_names if profile_names else list_profiles()
|
||||||
|
field_map: dict[str, dict[str, str | int]] = {}
|
||||||
|
for name in profiles:
|
||||||
|
fields = _read_env_file(name)
|
||||||
|
for key in WHITELIST_FIELDS:
|
||||||
|
if key not in field_map:
|
||||||
|
field_map[key] = {}
|
||||||
|
if key in fields:
|
||||||
|
field_map[key][name] = len(fields[key])
|
||||||
|
else:
|
||||||
|
field_map[key][name] = 0 # 0 表示缺失
|
||||||
|
rows: list[dict] = []
|
||||||
|
for key in WHITELIST_FIELDS:
|
||||||
|
row: dict[str, str | int] = {"field": key}
|
||||||
|
for name in profiles:
|
||||||
|
val = field_map.get(key, {}).get(name, 0)
|
||||||
|
row[name] = val
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def export_csv(profile_names: Optional[list[str]] = None) -> str:
|
||||||
|
"""CSV 导出批量检查结果。"""
|
||||||
|
scan = batch_scan(profile_names)
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["Profile", "缺失字段", "缺失数", "总字段"])
|
||||||
|
for row in scan:
|
||||||
|
writer.writerow([
|
||||||
|
row["profile"],
|
||||||
|
"; ".join(row["missing"]),
|
||||||
|
row["missing_count"],
|
||||||
|
row["total"],
|
||||||
|
])
|
||||||
|
return buf.getvalue()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Test fixtures: async client + in-memory SQLite database."""
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.database import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def engine():
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db(engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(engine) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Auth API tests."""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegister:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_success(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"email": "owner@test.local",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["username"] == "owner1"
|
||||||
|
assert data["role"] == "owner"
|
||||||
|
assert data["is_approved"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_duplicate(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"email": "another@test.local",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "Username" in resp.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_second_user_is_viewer(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"username": "viewer1",
|
||||||
|
"email": "viewer@test.local",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["role"] == "viewer"
|
||||||
|
assert data["is_approved"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogin:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_success(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_wrong_password(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "wrongpass",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_locked(self, client: AsyncClient):
|
||||||
|
# Fail 4 more times (already failed once above) → total 5 failures
|
||||||
|
for _ in range(4):
|
||||||
|
await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "wrongpass",
|
||||||
|
})
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "wrongpass",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 423
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthGuard:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_auth_no_token(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/refresh")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_viewer_cannot_manage_users(self, client: AsyncClient):
|
||||||
|
# Login as viewer1 → token
|
||||||
|
login = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "viewer1",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/users", headers=headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 2053;
|
||||||
|
server_name hm.zhiweidata.ai;
|
||||||
|
|
||||||
|
root /home/enzo/public/hive-manage-v3;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/backend/ 下实现完整的 JWT 鉴权 + 用户管理 + 审计日志系统。全部使用 backend/.venv/bin/python 和 backend/.venv/bin/pip。
|
||||||
|
|
||||||
|
已有: User + AuditLog ORM 模型, FastAPI 入口, database.py, config.py
|
||||||
|
|
||||||
|
## 第一部分: auth_service.py
|
||||||
|
|
||||||
|
创建 backend/app/services/__init__.py (空)
|
||||||
|
创建 backend/app/services/auth_service.py
|
||||||
|
|
||||||
|
核心函数:
|
||||||
|
- hash_password(password: str) -> str — bcrypt (passlib)
|
||||||
|
- verify_password(plain: str, hashed: str) -> bool
|
||||||
|
- create_access_token(user_id: str, role: str) -> str — JWT(HS256, config.jwt_secret, expires=config.jwt_expire_minutes)
|
||||||
|
- decode_access_token(token: str) -> dict — jose.jwt.decode, 捕获 ExpiredSignatureError/JWTError 抛 HTTPException(401)
|
||||||
|
- get_user_by_id(db, user_id) -> User | None
|
||||||
|
- get_user_by_username(db, username) -> User | None
|
||||||
|
- authenticate_user(db, username, password) -> User — 验证密码, 失败检查/递增 login_attempts, >=5 次锁定 15 分钟, 成功后重置 login_attempts
|
||||||
|
- create_user(db, username, email, password) -> User — 首个用户自动 Owner + is_approved=True, 后续用户 Viewer + is_approved=False
|
||||||
|
|
||||||
|
## 第二部分: auth middleware
|
||||||
|
|
||||||
|
创建 backend/app/middleware/__init__.py (空)
|
||||||
|
创建 backend/app/middleware/auth.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.auth_service import decode_access_token, get_user_by_id
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
user = await get_user_by_id(db, payload["sub"])
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or inactive user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
def require_role(*roles: str):
|
||||||
|
async def role_check(user = Depends(require_auth)):
|
||||||
|
if user.role.value not in roles:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
return role_check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 第三部分: Auth Schemas
|
||||||
|
|
||||||
|
创建 backend/app/schemas/__init__.py (空)
|
||||||
|
创建 backend/app/schemas/auth.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str # 先用 str,后续升级 EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 backend/app/schemas/user.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
is_approved: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
## 第四部分: Auth API
|
||||||
|
|
||||||
|
创建 backend/app/api/__init__.py (空)
|
||||||
|
创建 backend/app/api/auth.py
|
||||||
|
|
||||||
|
POST /api/v1/auth/register — 注册, 返回 201
|
||||||
|
POST /api/v1/auth/login — 登录, 返回 TokenResponse
|
||||||
|
POST /api/v1/auth/refresh — 刷新 token, 需 require_auth
|
||||||
|
POST /api/v1/auth/change-password — 改密码, 需 require_auth
|
||||||
|
|
||||||
|
## 第五部分: User Management API
|
||||||
|
|
||||||
|
创建 backend/app/api/users.py
|
||||||
|
|
||||||
|
GET /api/v1/users — 用户列表 (需 owner), 支持分页
|
||||||
|
GET /api/v1/users/{id} — 用户详情 (需 owner)
|
||||||
|
PATCH /api/v1/users/{id} — 改角色/状态 (需 owner)
|
||||||
|
POST /api/v1/users/{id}/approve — 审批新用户 (需 owner)
|
||||||
|
|
||||||
|
## 第六部分: Audit Middleware + API
|
||||||
|
|
||||||
|
创建 backend/app/middleware/audit.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 审计中间件: 拦截所有 POST/PUT/PATCH/DELETE 请求
|
||||||
|
# 从 request.state 获取当前用户 (需 auth middleware 先执行)
|
||||||
|
# 记录: user_id, username, action, target, detail, ip_address
|
||||||
|
# 写入 AuditLog 表 (异步)
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 backend/app/api/audit.py
|
||||||
|
|
||||||
|
GET /api/v1/audit — 审计日志列表 (需 owner), 支持分页 + 按 action 筛选
|
||||||
|
|
||||||
|
## 第七部分: 注册路由
|
||||||
|
|
||||||
|
修改 backend/app/main.py, 注册所有 router:
|
||||||
|
```python
|
||||||
|
from app.api import auth, users, audit
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(audit.router)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 第八部分: 测试
|
||||||
|
|
||||||
|
创建 backend/tests/test_auth.py
|
||||||
|
- test_register_success (201)
|
||||||
|
- test_register_duplicate (409)
|
||||||
|
- test_login_success (200 + JWT)
|
||||||
|
- test_login_wrong_password (401)
|
||||||
|
- test_login_locked (423 after 5 failures)
|
||||||
|
- test_require_auth_no_token (401)
|
||||||
|
- test_viewer_cannot_manage_users (403)
|
||||||
|
|
||||||
|
创建 backend/tests/conftest.py
|
||||||
|
- async test fixtures: async_client (httpx.AsyncClient + FastAPI app)
|
||||||
|
- test database (SQLite in-memory 或者用现有的 PG)
|
||||||
|
|
||||||
|
注意: 测试使用 pytest + pytest-asyncio + httpx.AsyncClient
|
||||||
|
|
||||||
|
## 第九部分: 提交
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && \
|
||||||
|
git add backend/app/services/ backend/app/middleware/ backend/app/schemas/ backend/app/api/ backend/tests/ backend/app/main.py && \
|
||||||
|
git commit -m "feat: JWT auth, user management, audit logging system"
|
||||||
|
```
|
||||||
|
|
||||||
|
请按顺序执行以上 9 个部分,每个部分完成后再进入下一部分。pip install 需要时使用 `cd backend && .venv/bin/pip install <package>`。
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/frontend/ 下实现前端鉴权体系。
|
||||||
|
|
||||||
|
## 1. API 客户端
|
||||||
|
|
||||||
|
创建 frontend/src/api/client.ts:
|
||||||
|
- axios 实例 baseURL='/api/v1'
|
||||||
|
- 请求拦截器: 从 localStorage('access_token') 读取 JWT, 加到 Authorization: Bearer header
|
||||||
|
- 响应拦截器: 401 自动清除 token → 跳转 /login
|
||||||
|
|
||||||
|
创建 frontend/src/api/auth.ts:
|
||||||
|
```typescript
|
||||||
|
import client from './client'
|
||||||
|
export const login = (username: string, password: string) => client.post('/auth/login', {username, password})
|
||||||
|
export const register = (data: {username, email, password}) => client.post('/auth/register', data)
|
||||||
|
export const refresh = () => client.post('/auth/refresh')
|
||||||
|
export const changePassword = (data: {old_password, new_password}) => client.post('/auth/change-password', data)
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 frontend/src/api/users.ts:
|
||||||
|
```typescript
|
||||||
|
import client from './client'
|
||||||
|
export const getUsers = (params?) => client.get('/users', {params})
|
||||||
|
export const getUser = (id: string) => client.get(`/users/${id}`)
|
||||||
|
export const updateUser = (id: string, data) => client.patch(`/users/${id}`, data)
|
||||||
|
export const approveUser = (id: string) => client.post(`/users/${id}/approve`)
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 frontend/src/api/audit.ts:
|
||||||
|
```typescript
|
||||||
|
import client from './client'
|
||||||
|
export const getAuditLogs = (params?) => client.get('/audit', {params})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. useAuth hook
|
||||||
|
|
||||||
|
创建/修改 frontend/src/hooks/useAuth.ts:
|
||||||
|
```typescript
|
||||||
|
import { useState, useEffect, createContext, useContext } from 'react'
|
||||||
|
// AuthContext 提供: user, token, login(), logout(), isAuthenticated
|
||||||
|
// token 存 localStorage('access_token')
|
||||||
|
// user 从 JWT payload 解码获取 (username, role, user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Login 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/LoginPage.tsx:
|
||||||
|
- 居中卡片, 暗色背景
|
||||||
|
- Logo "🐝 知微蜂巢" + "Hive Manage v3"
|
||||||
|
- 用户名 + 密码输入框
|
||||||
|
- 登入按钮 (primary, full width)
|
||||||
|
- 注册链接 (跳转注册页)
|
||||||
|
- 错误提示 (用户名/密码错误, 账户锁定 15 分钟等)
|
||||||
|
- 成功后跳转 /dashboard
|
||||||
|
|
||||||
|
## 4. 用户管理页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/UserManagementPage.tsx:
|
||||||
|
- 使用 Table 组件
|
||||||
|
- 列: 用户名, 邮箱, 角色(下拉可改), 状态(active/inactive), 审批状态, 创建时间, 操作
|
||||||
|
- 仅 owner 可见操作按钮
|
||||||
|
- 角色修改: owner 可改, API PATCH /users/{id}
|
||||||
|
- 审批: owner 可点, API POST /users/{id}/approve
|
||||||
|
- 分页: 每页 20 条
|
||||||
|
|
||||||
|
## 5. 审计日志页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/AuditLogPage.tsx:
|
||||||
|
- 使用 Table 组件
|
||||||
|
- 列: 时间, 用户, 操作(action), 目标(target), 详情(detail), IP
|
||||||
|
- action 筛选: 下拉列表 (全部/keys.batch_overwrite/gateway.toggle/cron.trigger/...)
|
||||||
|
- 分页: 每页 20 条
|
||||||
|
- 仅 owner 可见
|
||||||
|
|
||||||
|
## 6. 路由守卫
|
||||||
|
|
||||||
|
修改 App.tsx:
|
||||||
|
- 未登录: 任何页面 → 重定向 /login
|
||||||
|
- 登录后: 根据角色隐藏/显示导航项和操作按钮
|
||||||
|
- Viewer 看不到: 用户管理、审计日志、Gateway 启停、密钥覆盖、Cron 操作
|
||||||
|
|
||||||
|
完成后提交:
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/api/ frontend/src/hooks/useAuth.ts frontend/src/pages/LoginPage.tsx frontend/src/pages/UserManagementPage.tsx frontend/src/pages/AuditLogPage.tsx frontend/src/App.tsx && git commit -m "feat: frontend auth flow (login, user management, audit logs, route guards)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/ 实现 Dashboard 增强(P0-3)。后端 API + 前端页面。
|
||||||
|
|
||||||
|
## 后端: Dashboard API
|
||||||
|
|
||||||
|
创建 backend/app/api/dashboard.py,所有路由需 require_auth。
|
||||||
|
|
||||||
|
### 1. GET /api/v1/dashboard/summary
|
||||||
|
返回聚合统计数据:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"online_count": 12,
|
||||||
|
"offline_count": 46,
|
||||||
|
"total_profiles": 58,
|
||||||
|
"running_tasks": 3,
|
||||||
|
"cron_failures_today": 1,
|
||||||
|
"updated_at": "2026-07-22T16:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
数据来源: hermes CLI (`hermes gateway status` 解析) + Kanban 聚合 + Cron 统计。如需真实数据可先返回 Mock 数据(标注 TODO: replace with CLI)。
|
||||||
|
|
||||||
|
### 2. GET /api/v1/dashboard/health-distribution
|
||||||
|
返回健康分四级分布:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"A": 22, "B": 18, "C": 10, "D": 8, "total": 58,
|
||||||
|
"d_profiles": ["ops-engineer", "monitor", "visualizer", "hebo", "..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
暂用占位数据,Phase 4 实现真实计算。
|
||||||
|
|
||||||
|
### 3. GET /api/v1/dashboard/gateway-trend
|
||||||
|
返回最近 24h Gateway 异常事件按小时聚合:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"hour": "2026-07-22T08:00", "restarts": 2, "feishu_disconnects": 1, "heartbeat_timeouts": 0},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
暂用占位数据。
|
||||||
|
|
||||||
|
### 4. GET /api/v1/dashboard/kanban-summary
|
||||||
|
返回活跃看板概览:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"board": "europe-briefing", "running": "S4", "ready": "S5"},
|
||||||
|
{"board": "tech-weekly", "running": "S1", "ready": null}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
暂用占位数据。
|
||||||
|
|
||||||
|
### 5. GET /api/v1/dashboard/cron-recent
|
||||||
|
返回最近 Cron 执行记录:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"job": "taiwan-briefing", "status": "success", "time": "07:15"},
|
||||||
|
{"job": "hindsight", "status": "failure", "time": "21:13"},
|
||||||
|
{"job": "guangxi-donation", "status": "paused"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
暂用占位数据。
|
||||||
|
|
||||||
|
### 6. GET /api/v1/search?q=keyword
|
||||||
|
全局搜索: 搜索 Profile 名、任务名、Cron 名、事件关键词。返回分组结果:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": [{"name": "gateway-monitor", "status": "online"}],
|
||||||
|
"kanban_tasks": [{"title": "...", "column": "RUNNING"}],
|
||||||
|
"cron_jobs": [{"job": "...", "status": "active"}],
|
||||||
|
"events": [{"title": "...", "status": "active"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
暂用占位数据,含模糊匹配逻辑。
|
||||||
|
|
||||||
|
## 前端: Dashboard 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/DashboardPage.tsx:
|
||||||
|
|
||||||
|
### 统计卡片行 (4 列 grid)
|
||||||
|
- 在线 Gateway (绿色数值 + "↑ N vs 昨天")
|
||||||
|
- 离线 Gateway (红色数值 + "↓ N vs 昨天")
|
||||||
|
- 运行中任务 (蓝色数值)
|
||||||
|
- 今日 Cron 失败 (红色数值,失败>3 标红)
|
||||||
|
|
||||||
|
### 左侧: 蜂群健康分布
|
||||||
|
- 使用 Card 组件 + 自定义柱状图 (CSS bar chart)
|
||||||
|
- A(绿) B(蓝) C(黄) D(红) 四柱
|
||||||
|
- 点击柱跳转 /health?filter=X
|
||||||
|
|
||||||
|
### 右侧: Gateway 异常趋势 (24h)
|
||||||
|
- 使用 Timeline 组件,展示 24h 内事件点分布
|
||||||
|
- 图例: 🟢正常 🔴心跳超时 🟠飞书断连
|
||||||
|
|
||||||
|
### 底部: Kanban 概览 + Cron 最近执行
|
||||||
|
- 两列并排
|
||||||
|
- Kanban 概览: 看板名 + RUNNING 任务 + READY 任务
|
||||||
|
- Cron 最近: Job 名 + 状态(✅/❌/⏸) + 时间
|
||||||
|
|
||||||
|
### 搜索框
|
||||||
|
- 已在 Header 中通过 SearchBox 组件实现
|
||||||
|
- 输入关键词 → 调 /api/v1/search → 下拉面板分组展示
|
||||||
|
- 点击结果跳转对应页面
|
||||||
|
|
||||||
|
### 四态覆盖
|
||||||
|
- Loading: Skeleton (card 形状)
|
||||||
|
- Empty: "暂无数据"
|
||||||
|
- Error: 错误提示 + 重试按钮
|
||||||
|
- Normal: 数据正常展示
|
||||||
|
|
||||||
|
注册路由: 在 main.py 添加 `from app.api import dashboard; app.include_router(dashboard.router, prefix="/api/v1")`
|
||||||
|
|
||||||
|
完成后提交:
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add backend/app/api/dashboard.py backend/app/main.py frontend/src/pages/DashboardPage.tsx frontend/src/components/layout/Header.tsx && git commit -m "feat: Dashboard with summary cards, health distribution, gateway trend, global search"
|
||||||
|
```
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
实现 Gateway 健康监控(P0-1)。后端 Hermes CLI 封装 + API + 前端页面。
|
||||||
|
|
||||||
|
## 第一部分: Hermes CLI 服务(真实数据源)
|
||||||
|
|
||||||
|
创建 backend/app/services/hermes_cli.py:
|
||||||
|
- 封装 `hermes gateway status` CLI 调用
|
||||||
|
- 用 subprocess.run 执行,解析 JSON 输出
|
||||||
|
- 出错时返回空数据 + 日志告警,不抛异常
|
||||||
|
- 函数: gateway_status_all() -> list[GatewayInfo]
|
||||||
|
- 数据类型: id, name, status(online/offline/warn), downtime_minutes, restarts_today, last_event
|
||||||
|
|
||||||
|
## 第二部分: Gateway API
|
||||||
|
|
||||||
|
创建 backend/app/api/gateway.py,全部 require_auth
|
||||||
|
|
||||||
|
### GET /api/v1/gateway/status
|
||||||
|
返回全部 Profile 的 Gateway 状态列表,支持:
|
||||||
|
- query param `?status=online|offline|warn` 筛选
|
||||||
|
- 每行: name, status(🟢/🔴/🟡), downtime, restarts_today, last_event, profile_type
|
||||||
|
- 数据来源: hermes_cli.gateway_status_all()
|
||||||
|
|
||||||
|
### GET /api/v1/gateway/events
|
||||||
|
返回最近 24h 异常事件:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"time": "2026-07-22T18:45", "profile": "monitor", "type": "heartbeat_timeout", "detail": "连续 3 次心跳无响应"},
|
||||||
|
{"time": "2026-07-22T14:20", "profile": "feishu-gw", "type": "feishu_disconnect", "detail": "WebSocket 断开 8 分钟"},
|
||||||
|
{"time": "2026-07-22T10:30", "profile": "gateway-monitor", "type": "restart", "detail": "Gateway 重启"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
数据来源: hermes CLI 日志或系统日志解析。暂用占位数据(TODO 标注)。
|
||||||
|
|
||||||
|
### POST /api/v1/gateway/{profile}/start, /stop, /restart
|
||||||
|
Gateway 启停操作:
|
||||||
|
- 执行 `hermes gateway start/stop/restart --profile {profile}`
|
||||||
|
- 需要 operator 或 owner 角色(require_role("owner","operator"))
|
||||||
|
- 返回操作结果 + 写入审计日志
|
||||||
|
- 操作前二次确认(前端处理)
|
||||||
|
|
||||||
|
## 第三部分: 前端 Gateway 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/GatewayPage.tsx:
|
||||||
|
|
||||||
|
### 顶部: 异常事件时间线
|
||||||
|
- Timeline 组件,24h 横向展示
|
||||||
|
- 事件类型: 🔴心跳超时 🟠飞书断连 🔵Gateway 重启
|
||||||
|
- Hover 显示详情 tooltip
|
||||||
|
|
||||||
|
### 筛选栏
|
||||||
|
- 状态筛选: 全部/在线/离线/异常
|
||||||
|
- 蜂型筛选: 全部/工蜂/蜂王
|
||||||
|
- 筛选器即时生效(前端过滤,不重新请求)
|
||||||
|
|
||||||
|
### 状态表
|
||||||
|
- Table 组件,10s 轮询 (usePolling hook)
|
||||||
|
- 列: Profile 名, 状态 Badge, 掉线时长, 今日重启, 最近异常, 操作
|
||||||
|
- 异常行标红: Gateway 连续重启≥3次 或 掉线≥30分钟
|
||||||
|
- 操作按钮: 启动/停止/重启 (二次确认 Modal)
|
||||||
|
|
||||||
|
### 四态覆盖
|
||||||
|
- Loading: Skeleton (table 形状)
|
||||||
|
- Empty: "全部 Gateway 正常运行 🎉"
|
||||||
|
- Error: 错误 + 重试
|
||||||
|
- Normal: 数据展示
|
||||||
|
|
||||||
|
### usePolling hook
|
||||||
|
创建 frontend/src/hooks/usePolling.ts:
|
||||||
|
```typescript
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
export function usePolling(callback: () => void, intervalMs: number, enabled: boolean = true) {
|
||||||
|
const savedCallback = useRef(callback)
|
||||||
|
useEffect(() => { savedCallback.current = callback }, [callback])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
savedCallback.current() // 立即执行一次
|
||||||
|
const id = setInterval(() => savedCallback.current(), intervalMs)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [intervalMs, enabled])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注册路由: main.py 添加 `from app.api import gateway; app.include_router(gateway.router, prefix="/api/v1")`
|
||||||
|
|
||||||
|
完成后提交。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
实现 Profile 健康分(P0-2)。评分引擎 + API + 前端页面。
|
||||||
|
|
||||||
|
## 第一部分: 健康分计算引擎
|
||||||
|
|
||||||
|
创建 backend/app/services/health_scorer.py:
|
||||||
|
|
||||||
|
评分规则(PRD §P0-2):
|
||||||
|
- Gateway 稳定性 40%: 7天在线率×40 + 日均重启≤0.3得满分
|
||||||
|
- 配置完整性 25%: 必填字段检查(.env+config.yaml),缺1个扣10%
|
||||||
|
- Skill 一致性 20%: 与基准 Skill 清单对比
|
||||||
|
- Cron 成功率 15%: 24h Cron 成功率×15
|
||||||
|
|
||||||
|
等级: A(80-100) B(60-79) C(40-59) D(0-39)
|
||||||
|
|
||||||
|
函数:
|
||||||
|
- calculate_health_score(profile_name: str) -> HealthScore
|
||||||
|
- calculate_all_scores() -> list[HealthScore]
|
||||||
|
- 数据来源: 读取 .env + config.yaml 文件, 调 hermes CLI 获取 Gateway/Cron 状态
|
||||||
|
- 暂用占位数据(TODO: connect to real data sources),但评分逻辑要完整实现
|
||||||
|
- 评分结果缓存到 score_results dict, 每天凌晨自动刷新
|
||||||
|
|
||||||
|
## 第二部分: Health API
|
||||||
|
|
||||||
|
创建 backend/app/api/health.py,全部 require_auth
|
||||||
|
|
||||||
|
### GET /api/v1/health/scores
|
||||||
|
返回全部 Profile 健康分排行列表
|
||||||
|
- query: ?grade=A|B|C|D&sort=asc|desc
|
||||||
|
- 默认: 从低到高(D 排最前)
|
||||||
|
- 每行: rank, name, total_score, grade, gateway_score, config_score, skill_score, cron_score, deduct_summary
|
||||||
|
|
||||||
|
### GET /api/v1/health/scores/{profile}
|
||||||
|
返回单 Profile 详情:
|
||||||
|
- 四维度得分/满分 + 具体扣分项列表 + 修复建议
|
||||||
|
|
||||||
|
### POST /api/v1/health/compare
|
||||||
|
body: {"profile_names": ["hebo", "ops-engineer"]}
|
||||||
|
返回对比表: 横向 Profile, 纵向配置/Skill 字段, ✅/❌/⚠
|
||||||
|
|
||||||
|
### POST /api/v1/health/calculate
|
||||||
|
手动触发全量评分计算 (需 owner)
|
||||||
|
|
||||||
|
## 第三部分: 前端 Health 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/HealthPage.tsx:
|
||||||
|
|
||||||
|
### 顶部: 四级分布卡片
|
||||||
|
- 4 个 Card: A(绿) B(蓝) C(黄) D(红)
|
||||||
|
- 显示数量 + 等级标签
|
||||||
|
- 点击卡片筛选对应等级
|
||||||
|
|
||||||
|
### 筛选栏
|
||||||
|
- 等级筛选: 全部 A B C D
|
||||||
|
- 对比按钮: 选中≥2 个 Profile 后启用
|
||||||
|
- 排序切换: 默认问题优先(D→A)
|
||||||
|
|
||||||
|
### 评分列表
|
||||||
|
- Table 组件
|
||||||
|
- 列: 排名, Profile名, 总分(颜色编码), 等级 Badge, 四维度分, 主要扣分项, 操作
|
||||||
|
- 可展开行: 点击展开四维度扣分明细面板(内嵌展开,不跳转)
|
||||||
|
- D 级行标红
|
||||||
|
|
||||||
|
### 批量对比视图
|
||||||
|
- 勾选 2-20 个 Profile → "对比选中"按钮
|
||||||
|
- 对比表: 横向 Profile, 纵向字段, ✅有/❌无/⚠版本不同
|
||||||
|
- 差异行黄色背景高亮
|
||||||
|
- 支持"只看差异"切换
|
||||||
|
|
||||||
|
### 四态覆盖
|
||||||
|
|
||||||
|
注册路由: main.py 添加 health router。
|
||||||
|
|
||||||
|
完成后提交。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
实现 Cron 管理增强(P0-4b)。后端 API + 前端页面。
|
||||||
|
|
||||||
|
## 后端: Cron API
|
||||||
|
|
||||||
|
创建 backend/app/api/cron.py,全部 require_auth
|
||||||
|
|
||||||
|
### 1. GET /api/v1/cron/jobs
|
||||||
|
跨 Profile Cron 聚合列表,支持 ?status=active|paused&profile=all|xxx|system
|
||||||
|
- 每行: job_name, profile, schedule, status, last_run(time+✅/❌), success_rate, has_logs
|
||||||
|
- 数据来源: hermes CLI `hermes cron list --all` + 系统 Cron 硬编码清单
|
||||||
|
- 系统 Cron 示例: Hindsight(0 21 * * *), 健康巡检(*/30 * * * *), Skill检查(0 2 * * *), Memory归档(0 3 * * 0)
|
||||||
|
- 系统 Cron 行有 [system] 标记
|
||||||
|
- Cron 连续失败≥2次 → 行标红
|
||||||
|
- 当日有失败时返回 has_failures: true
|
||||||
|
|
||||||
|
### 2. GET /api/v1/cron/jobs/{id}/logs
|
||||||
|
返回最近 8 条运行日志:
|
||||||
|
```json
|
||||||
|
{"logs": [{"time": "", "status": "success|failure", "message": "..."}]}
|
||||||
|
```
|
||||||
|
数据来源: hermes CLI + 日志文件。暂用占位数据。
|
||||||
|
|
||||||
|
### 3. POST /api/v1/cron/jobs/{id}/run, /pause, /resume
|
||||||
|
操作 Cron (需 operator+):
|
||||||
|
- run: 手动触发一次
|
||||||
|
- pause: 暂停 Cron
|
||||||
|
- resume: 恢复 Cron
|
||||||
|
- 操作结果写入审计日志
|
||||||
|
|
||||||
|
## 前端: Cron 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/CronPage.tsx:
|
||||||
|
|
||||||
|
### 筛选栏
|
||||||
|
- 状态筛选: 全部/活跃/暂停/今日失败
|
||||||
|
- Profile 筛选: 全部/各 Profile/仅系统 Cron
|
||||||
|
- 前端即时过滤
|
||||||
|
|
||||||
|
### Cron 列表
|
||||||
|
- Table 组件, 30s 轮询
|
||||||
|
- 列: Job名, Profile, Cron表达式(等宽字体), 状态 Badge, 上次运行(时间+✅❌), 成功率(<80%标黄), 操作
|
||||||
|
- 系统 Cron 灰底 + [system] 标记
|
||||||
|
- 连续失败≥2 行标红
|
||||||
|
|
||||||
|
### 日志面板
|
||||||
|
- 每行操作列 "📋 日志" 按钮
|
||||||
|
- 点击弹出居中面板(遮罩+Modal)
|
||||||
|
- 标题: "{job} — 最近运行日志"
|
||||||
|
- 列表(最多8条): 时间戳, 状态✅/❌, 日志消息(INFO/WARN/ERROR 按级别着色)
|
||||||
|
- 失败消息红色背景, 包含完整错误详情
|
||||||
|
- 关闭: ✕ / 点遮罩 / Esc
|
||||||
|
|
||||||
|
### 操作按钮
|
||||||
|
- 运行(二次确认), 暂停, 恢复
|
||||||
|
- Toast 结果反馈
|
||||||
|
|
||||||
|
### 导航栏 Badge
|
||||||
|
- Dashboard 今日 Cron 失败计数同步
|
||||||
|
- 导航栏 Cron 项显示红色 Badge(失败数)
|
||||||
|
|
||||||
|
### 四态覆盖
|
||||||
|
|
||||||
|
注册路由, 完成后提交。
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
实现密钥批量管理(P0-5)。后端 .env 读写 + API + 前端页面。
|
||||||
|
|
||||||
|
## 后端: Key Manager 服务
|
||||||
|
|
||||||
|
创建 backend/app/services/key_manager.py:
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
- 读取 Profile 的 .env 文件中指定密钥字段
|
||||||
|
- 脱敏显示: 前3字符 + "**...**" + 后4字符 (如 "sk-**...**b12c")
|
||||||
|
- 白名单字段: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, FEISHU_APP_ID, FEISHU_APP_SECRET
|
||||||
|
- 批量覆盖: 写入新值到 .env 文件 (操作前自动备份 .env → .env.bak)
|
||||||
|
- 批量检查: 扫描全部 Profile 的 .env, 返回缺失字段列表
|
||||||
|
- 差异视图: 对比各 Profile 同一字段的值 (只显示有无和长度, 不显示原文)
|
||||||
|
- 不写入审计日志原文(只记录 action + profile 名 + 字段名)
|
||||||
|
|
||||||
|
## 后端: Keys API
|
||||||
|
|
||||||
|
创建 backend/app/api/keys.py, 全部 require_role("owner","operator")
|
||||||
|
|
||||||
|
### GET /api/v1/keys
|
||||||
|
返回密钥字段列表:
|
||||||
|
```json
|
||||||
|
[{"profile": "hebo", "fields": {"DEEPSEEK_API_KEY": "sk-**...**b12c", ...}, "missing": 0}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/keys/batch-overwrite
|
||||||
|
批量覆盖密钥:
|
||||||
|
- body: {profile_names: [...], key_name: "DEEPSEEK_API_KEY", new_value: "sk-xxx", confirmation: "我确认覆盖 N 个 Profile"}
|
||||||
|
- 验证 confirmation 短语精确匹配
|
||||||
|
- 逐 Profile 写入 + 返回成功/失败列表
|
||||||
|
|
||||||
|
### POST /api/v1/keys/batch-scan
|
||||||
|
批量检查缺失:
|
||||||
|
- body: {profile_names: [...]} 或全部 Profile
|
||||||
|
- 返回缺失统计 + 详情
|
||||||
|
|
||||||
|
### GET /api/v1/keys/diff
|
||||||
|
差异视图:
|
||||||
|
- query: ?profiles=hebo,ops-engineer
|
||||||
|
- 返回对比表: 横向 Profile, 纵向字段, 值状态(有/无/长度)
|
||||||
|
|
||||||
|
### GET /api/v1/keys/export
|
||||||
|
CSV 导出批量检查结果
|
||||||
|
|
||||||
|
## 前端: Keys 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/KeysPage.tsx:
|
||||||
|
|
||||||
|
### 操作栏
|
||||||
|
- [批量覆盖] 按钮 → Modal
|
||||||
|
- [批量检查] 按钮 → 扫描结果展示
|
||||||
|
- Profile 筛选下拉
|
||||||
|
- 搜索密钥字段
|
||||||
|
|
||||||
|
### 密钥列表
|
||||||
|
- Table 组件
|
||||||
|
- 列: ☐复选框, Profile名, 各密钥字段(脱敏显示), 缺失计数
|
||||||
|
- 缺失字段标红 ❌
|
||||||
|
- 差异视图切换
|
||||||
|
|
||||||
|
### 批量覆盖 Modal
|
||||||
|
- 下拉选择密钥字段
|
||||||
|
- 输入新值(密码框, 可切换明文)
|
||||||
|
- 预览: "将覆盖 N 个 Profile 的 {KEY_NAME}"
|
||||||
|
- 当前值脱敏预览
|
||||||
|
- 输入确认短语: "我确认覆盖 N 个 Profile"
|
||||||
|
- 确认后执行 → 进度提示 → 成功/失败列表
|
||||||
|
|
||||||
|
### 批量检查结果
|
||||||
|
- 显示扫描结果表: Profile, 缺失字段列表, 缺失数
|
||||||
|
- 支持导出 CSV (下载按钮)
|
||||||
|
- 缺失行标红
|
||||||
|
|
||||||
|
### 差异视图
|
||||||
|
- 勾选 2+ Profile → "查看差异"
|
||||||
|
- 对比表: 横向 Profile, 纵向字段, ✅有/❌无
|
||||||
|
- 差异行黄色高亮
|
||||||
|
- "只看差异" 切换
|
||||||
|
|
||||||
|
### 四态覆盖 + 审计日志
|
||||||
|
|
||||||
|
注册路由, 完成后提交。
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
实现 Kanban 增强(P0-4)。后端 API + 前端五列看板页面。
|
||||||
|
|
||||||
|
核心定位: Kanban 是蜂群任务的电子沙盘——观察全貌、发现异常。不做批量 dispatch/创建任务/goal_mode 配置(这些走 Hermes CLI)。
|
||||||
|
|
||||||
|
## 后端: Kanban API
|
||||||
|
|
||||||
|
创建 backend/app/api/kanban.py,全部 require_auth
|
||||||
|
|
||||||
|
### 1. GET /api/v1/kanban/tasks
|
||||||
|
跨 Profile 五列任务聚合:
|
||||||
|
- 数据来源: hermes CLI `hermes kanban list` 解析
|
||||||
|
- 支持 ?worker=xxx&board=xxx&anomaly=blocked_timeout|unclaimed|retry_failure
|
||||||
|
- 返回五列数据: {todo: [...], ready: [...], running: [...], blocked: [...], done: [...]}
|
||||||
|
- 概览条: total_tasks, profile_count, running_count, blocked_count
|
||||||
|
- 暂用 Mock 数据(TODO: CLI integration)
|
||||||
|
|
||||||
|
### 2. GET /api/v1/kanban/tasks/{id}
|
||||||
|
任务详情:
|
||||||
|
- Meta: board, worker_profile, worker_type, priority, created_at, updated_at, goal_mode, retries/failures
|
||||||
|
- Body: 任务描述(Markdown)
|
||||||
|
- 关联: 依赖任务, 被依赖任务
|
||||||
|
- 活动: 最近操作时间线
|
||||||
|
|
||||||
|
### 3. PATCH /api/v1/kanban/tasks/{id}/move
|
||||||
|
拖拽移动:
|
||||||
|
- body: {column: "ready"|"running"|"blocked"|"done", block_reason?: "阻塞原因(≥10字)"}
|
||||||
|
- 拖入 BLOCKED 需填写阻塞原因
|
||||||
|
- 拖入 DONE 二次确认(前端)
|
||||||
|
|
||||||
|
### 4. 异常检测(内置逻辑)
|
||||||
|
服务端计算任务异常标记:
|
||||||
|
- blocked_timeout: BLOCKED 列超 24h → 标红
|
||||||
|
- unclaimed: TODO 列超 3 天 → 标黄
|
||||||
|
- retry_failure: 失败重试≥3 次 → 标红
|
||||||
|
|
||||||
|
## 前端: Kanban 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/KanbanPage.tsx:
|
||||||
|
|
||||||
|
### 顶部概览条
|
||||||
|
- "当前 N 个任务 · M 个 Profile · K 个 RUNNING · B 个阻塞"
|
||||||
|
- RUNNING 蓝色, 阻塞橙色
|
||||||
|
- 右侧筛选下拉: Worker / 看板 / 异常状态
|
||||||
|
|
||||||
|
### 五列看板
|
||||||
|
- TODO / READY / RUNNING / BLOCKED / DONE
|
||||||
|
- 每列头: 列名(彩色) + 任务计数
|
||||||
|
- BLOCKED 列橙色边框 + 左边框
|
||||||
|
- 列宽 min 240px, 五列一屏可见
|
||||||
|
- 列内容纵向滚动, 列头 sticky
|
||||||
|
|
||||||
|
### 任务卡片
|
||||||
|
- 标题(粗体, 最多两行) + Worker Profile 名 + 蜂型图标
|
||||||
|
- 状态标记: 🎯 goal_mode, ⚡ 高优先级
|
||||||
|
- 创建时间(相对时间 "2h ago")
|
||||||
|
- BLOCKED 列卡片显示阻塞原因(一行截断)
|
||||||
|
- 卡片左侧色条: P0=红, goal_mode=金, blocked=橙
|
||||||
|
- 卡片 hover 边框变 honey
|
||||||
|
- 异常卡片: 阻塞超时红色, 长期无主黄色, 失败重试红色
|
||||||
|
- 点击卡片 → 打开详情抽屉
|
||||||
|
- 卡片 Checkbox (hover 显示)
|
||||||
|
|
||||||
|
### 拖拽
|
||||||
|
- 桌面端支持拖拽卡片到任意列
|
||||||
|
- 拖入 BLOCKED 弹出阻塞原因输入框(必填 ≥10字)
|
||||||
|
- 拖入 DONE 二次确认
|
||||||
|
- 移动端禁用拖拽
|
||||||
|
|
||||||
|
### 任务详情抽屉 (Drawer 组件, 480px)
|
||||||
|
- Header: 状态 Badge + 标题(可编辑) + ✕
|
||||||
|
- Meta: 看板, Worker, 类型, 优先级, 创建/更新时间, goal_mode, 重试次数
|
||||||
|
- Body: Markdown 描述, goal_mode 验收标准 checklist(可勾选), 阻塞原因
|
||||||
|
- 关联: 依赖/被依赖任务链接(点击跳转)
|
||||||
|
- 活动: 时间线(操作人+时间)
|
||||||
|
- Footer: [编辑] [标记阻塞] [标记完成] [删除]
|
||||||
|
|
||||||
|
### 批量操作栏(底部浮现)
|
||||||
|
- 勾选≥1 张卡片后底部浮现操作栏
|
||||||
|
- [标记完成] [标记阻塞] [清除选择]
|
||||||
|
- 不做 Dispatch(走 CLI)
|
||||||
|
|
||||||
|
### 四态覆盖 + 乐观更新(拖拽后即时更新 UI,失败回滚)
|
||||||
|
|
||||||
|
注册路由, 完成后提交。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
实现事件档案管理(P0-7)。后端数据爬取 + API + 前端卡片页面。
|
||||||
|
|
||||||
|
## 后端: Event Crawler
|
||||||
|
|
||||||
|
创建 backend/app/services/event_crawler.py:
|
||||||
|
- 爬取 hm.zhiweidata.ai/events 数据
|
||||||
|
- 存入 events 表 (新建 ORM 模型)
|
||||||
|
- 定时每天凌晨执行
|
||||||
|
- 暂用占位数据(TODO: real crawler)
|
||||||
|
|
||||||
|
## 后端: Event Model
|
||||||
|
|
||||||
|
创建 backend/app/models/event.py:
|
||||||
|
```python
|
||||||
|
class EventStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
FADING = "fading"
|
||||||
|
DISSIPATING = "dissipating"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
id: str (UUID)
|
||||||
|
title: str
|
||||||
|
level: str (A/B/C)
|
||||||
|
domain: str (12个领域之一)
|
||||||
|
status: EventStatus
|
||||||
|
occurred_date: date
|
||||||
|
last_reviewed: datetime | None
|
||||||
|
no_new_info_count: int (连续无新知次数)
|
||||||
|
next_review: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 Alembic 迁移。
|
||||||
|
|
||||||
|
## 后端: Events API
|
||||||
|
|
||||||
|
创建 backend/app/api/events.py,全部 require_auth
|
||||||
|
|
||||||
|
### GET /api/v1/events
|
||||||
|
事件卡片列表,三维筛选(前端即时过滤):
|
||||||
|
- query: ?status=active|fading|dissipating|closed&domain=xxx&search=keyword
|
||||||
|
- 每行含超期标记: overdue_days (下一次复查逾期天数)
|
||||||
|
- 空结果返回 []
|
||||||
|
|
||||||
|
## 前端: Event Archive 页面
|
||||||
|
|
||||||
|
修改 frontend/src/pages/EventArchivePage.tsx:
|
||||||
|
|
||||||
|
### 筛选工具栏
|
||||||
|
- 状态筛选: 全部/活跃期/消减期/消散期/已完结
|
||||||
|
- 领域筛选: 12 个领域下拉
|
||||||
|
- 搜索框: 按标题模糊搜索
|
||||||
|
- 前端即时过滤,组合生效
|
||||||
|
- [+ 新建事件] 按钮(P0 先做占位 Toast)
|
||||||
|
|
||||||
|
### 事件卡片网格
|
||||||
|
- 自适应列数 grid (minmax 340px)
|
||||||
|
- 每张卡片:
|
||||||
|
- 标题(粗体)
|
||||||
|
- 等级 Badge + 领域 Tag (蓝色)
|
||||||
|
- 状态 Badge(绿/黄/灰)
|
||||||
|
- Meta: 发生日期, 上次复查, 连续无新知(≥5标黄), 下次复查
|
||||||
|
- 超期标记: 红色 "超期 N 天"
|
||||||
|
- Hover: 边框变 honey-dim
|
||||||
|
- 点击: Toast 显示事件标题(P1 升级详情面板)
|
||||||
|
|
||||||
|
### 四态覆盖
|
||||||
|
- Empty: "无匹配事件"
|
||||||
|
|
||||||
|
注册路由 + 迁移 + 提交。
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Hive Manage v3 前端视觉重写 — 像素级对齐参考图
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- 所有 CSS 使用 `design-tokens.css` 中的变量(已在 `frontend/src/styles/tokens.css`)
|
||||||
|
- 禁止硬编码任何颜色值
|
||||||
|
- 间距必须是 4 的倍数
|
||||||
|
- 表格行高 44px,padding: 10px 14px
|
||||||
|
- 动画 ≤300ms,页面切换无动画
|
||||||
|
- 暗色模式下的阴影仅三层:悬浮/弹出/模态
|
||||||
|
|
||||||
|
## 1. 登录页 (参考 assets/ref-images/02-login.png)
|
||||||
|
|
||||||
|
### 布局
|
||||||
|
- 页面背景: var(--bg-root),纯暗色无纹理
|
||||||
|
- 登录卡片: 水平垂直居中,宽约 400px,大圆角(24px),var(--bg-card) 背景
|
||||||
|
- 卡片阴影: var(--shadow-lg)
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
- 顶部居中: 🐝 蜜蜂图标 + "知微蜂巢" 白色粗体 28px
|
||||||
|
- 下方: "Hive Manage v3" 浅灰色 14px var(--text-dim)
|
||||||
|
|
||||||
|
### 输入框
|
||||||
|
- 标签: 白色小字,输入框上方
|
||||||
|
- 输入框: var(--bg-root) 背景,1px solid var(--border),圆角 8px,高 42px
|
||||||
|
- Placeholder: var(--text-muted),"请输入用户名"/"请输入密码"
|
||||||
|
- 密码框右侧有眼睛图标(显示/隐藏切换)
|
||||||
|
|
||||||
|
### 按钮
|
||||||
|
- "登入": 全宽,var(--honey) 背景,黑色粗体文字,高 48px,圆角 8px
|
||||||
|
- 下方 "还没有账号?注册" var(--text-dim) 文字,var(--honey) 链接
|
||||||
|
|
||||||
|
## 2. 侧边栏 (参考 01-dashboard-dark.png)
|
||||||
|
|
||||||
|
### 分组结构(从上到下)
|
||||||
|
**蜂群态势**
|
||||||
|
- 📊 蜂群总览 (active: var(--honey-glow) 背景 + var(--honey) 文字)
|
||||||
|
- 📡 Gateway 健康 [NEW Badge: 金色小标签]
|
||||||
|
- 🏥 Profile 健康分 [NEW Badge]
|
||||||
|
- 🤖 工蜂列表
|
||||||
|
|
||||||
|
**任务编排**
|
||||||
|
- 📋 看板追踪
|
||||||
|
- ⏰ Cron 管理 [红色计数 Badge]
|
||||||
|
- 🔔 事件管理 [红色计数 Badge]
|
||||||
|
|
||||||
|
**通信频道**
|
||||||
|
- 📶 频道配置
|
||||||
|
- 📡 频道监控 [NEW Badge]
|
||||||
|
|
||||||
|
**运维工具**
|
||||||
|
- 🔑 密钥管理
|
||||||
|
- 📁 文件浏览 [NEW Badge]
|
||||||
|
- 💻 Web 终端 [NEW Badge]
|
||||||
|
- 📜 蜂群日志 [NEW Badge]
|
||||||
|
- 🔌 MCP 接入管理 [NEW Badge]
|
||||||
|
- 📤 MCP 对外服务 [NEW Badge]
|
||||||
|
|
||||||
|
**知识库管理**
|
||||||
|
- 📚 事件档案管理 [NEW Badge]
|
||||||
|
|
||||||
|
**系统**
|
||||||
|
- 👥 用户管理
|
||||||
|
- 📋 审计日志
|
||||||
|
- ⚙ 系统设置
|
||||||
|
|
||||||
|
### 样式
|
||||||
|
- 分组标题: 11px uppercase,var(--text-muted),letter-spacing 0.5px,padding 8px 10px 4px
|
||||||
|
- NavItem: padding 8px 10px,border-radius 4px
|
||||||
|
- NavItem 默认: var(--text-dim)
|
||||||
|
- NavItem hover: var(--bg-hover) 背景,var(--text) 文字
|
||||||
|
- NavItem active: var(--honey-glow) 背景,var(--honey) 文字,font-weight 600
|
||||||
|
- Badge: 小圆角标签,红色 Badge 用于计数,金色 Badge 用于 NEW
|
||||||
|
|
||||||
|
## 3. Header (参考 01-dashboard-dark.png)
|
||||||
|
|
||||||
|
- 高度: 48px,var(--bg-nav) 背景,底边框 1px var(--border)
|
||||||
|
- 左侧: 🐝 Logo + 面包屑 "/ 蜂群总览" var(--text-dim)
|
||||||
|
- 搜索框: var(--bg-card) 背景,260px 宽,placeholder "搜索 Profile / 任务 / Cron / 事件..."
|
||||||
|
- 右侧: 状态标签(在线 12 / 运行中 3)+ 主题切换按钮 + 用户头像
|
||||||
|
|
||||||
|
## 4. Dashboard (参考 01-dashboard-dark.png)
|
||||||
|
|
||||||
|
### 统计卡片(4列网格)
|
||||||
|
- 在线 Gateway: 12(绿色 var(--green)), ↑ 绿色箭头 "2 vs 昨天"
|
||||||
|
- 离线 Gateway: 46(红色 var(--red)), ↓ 红色箭头 "1 vs 昨天"
|
||||||
|
- 运行中任务: 3(蓝色 var(--blue))
|
||||||
|
- 今日 Cron 失败: 1(红色,失败>3标红)
|
||||||
|
|
||||||
|
卡片: var(--bg-card) 背景,1px var(--border),padding 16px,圆角 8px
|
||||||
|
|
||||||
|
### 健康分布
|
||||||
|
- 横向堆叠柱状图: A(绿22) B(蓝18) C(黄10) D(红8)
|
||||||
|
- 柱高 8px,圆角 4px
|
||||||
|
- "查看全部 →" 链接
|
||||||
|
- D 级 Profile: 逗号分隔列出名称
|
||||||
|
|
||||||
|
### Gateway 异常趋势 (24h)
|
||||||
|
- 48 根迷你竖条(每30分钟),绿/红/橙三色
|
||||||
|
- 图例: 🟢 正常 🔴 心跳超时 🟠 飞书断连
|
||||||
|
|
||||||
|
### Kanban 概览
|
||||||
|
- 表格: 看板名 | RUNNING | READY
|
||||||
|
- 如: europe-briefing | S4 质检 | S5 编排
|
||||||
|
|
||||||
|
### Cron 最近执行
|
||||||
|
- 表格: Job | 状态(✅/❌/⏸) | 时间
|
||||||
|
- 如: 涉台早报 ✅ 07:15
|
||||||
|
|
||||||
|
### 最近异常事件
|
||||||
|
- 完整表格: 时间 | Profile | 类型(Badge) | 摘要 | 状态(Badge)
|
||||||
|
- 至少 3 行数据
|
||||||
|
|
||||||
|
## 5. Gateway 页面 (参考 04-gateway.png)
|
||||||
|
|
||||||
|
### 顶部
|
||||||
|
- 标题 "Gateway 健康监控"
|
||||||
|
- 统计: 共 N 个 · 在线 N · 离线 N · 异常 N
|
||||||
|
- 右上角: "最后更新 N 秒前"
|
||||||
|
|
||||||
|
### 筛选栏
|
||||||
|
- 状态筛选: 全部/在线🔵/离线🔴/异常🟡,选中态 var(--honey) 背景
|
||||||
|
- 右侧: 搜索框 + 蜂型筛选下拉
|
||||||
|
|
||||||
|
### 异常时间线 (24h)
|
||||||
|
- 横向轴线 + 彩色圆点标记事件
|
||||||
|
- 红=心跳超时,橙=飞书断连,蓝=重启
|
||||||
|
- 圆点下方标注时间+事件名
|
||||||
|
|
||||||
|
### 表格
|
||||||
|
- 列: Profile名 | 状态Badge | 掉线时长 | 今日重启 | 最新异常 | 操作
|
||||||
|
- 异常行: row-danger 样式
|
||||||
|
- 操作按钮: var(--honey) 边框 + var(--honey) 文字,深色背景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
1. 先读 `frontend/src/styles/tokens.css` 确认变量名
|
||||||
|
2. 重写 `LoginPage.tsx` 对齐登录参考图
|
||||||
|
3. 重写 `Sidebar.tsx` 对齐侧边栏参考图(分组+Badge)
|
||||||
|
4. 重写 `Header.tsx` 对齐 Header 参考图
|
||||||
|
5. 重写 `DashboardPage.tsx` 对齐 Dashboard 参考图
|
||||||
|
6. 重写 `GatewayPage.tsx` 对齐 Gateway 参考图
|
||||||
|
7. 每完成一个文件运行 `npx tsc --noEmit`
|
||||||
|
8. 全部完成后 `npm run build`
|
||||||
|
9. 提交
|
||||||
|
|
||||||
|
**质量红线**: 不允许出现以下情况——
|
||||||
|
- 硬编码颜色(必须用 var(--*))
|
||||||
|
- 间距不是 4 的倍数
|
||||||
|
- 表格行高不是 44px
|
||||||
|
- 动画超过 300ms
|
||||||
|
- 阴影超过三层
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
请创建以下 3 个文件。backend 目录已存在,但 backend/app/ 需要新建。
|
||||||
|
|
||||||
|
## 文件 1: backend/app/__init__.py
|
||||||
|
空文件。
|
||||||
|
|
||||||
|
## 文件 2: backend/app/config.py
|
||||||
|
```python
|
||||||
|
"""应用配置 — 从环境变量 / .env 读取。"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "postgresql+asyncpg://hive_admin:changeme@localhost:5432/hive_manage_v3"
|
||||||
|
jwt_secret: str = "change-me"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_expire_minutes: int = 1440
|
||||||
|
hermes_cli_path: str = "/home/enzo/.local/bin/hermes"
|
||||||
|
hermes_profiles_root: str = "/home/enzo/.hermes/profiles"
|
||||||
|
admin_email: str = "admin@zhiwei.local"
|
||||||
|
admin_password: str = "admin123"
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件 3: backend/app/database.py
|
||||||
|
```python
|
||||||
|
"""异步 SQLAlchemy 引擎 + session 工厂。"""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件 4: backend/app/main.py
|
||||||
|
```python
|
||||||
|
"""FastAPI 应用入口。"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.database import engine, Base
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Hive Manage v3",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
创建完成后,执行以下验证:
|
||||||
|
1. `cat` 检查每个文件内容
|
||||||
|
2. `cd backend && pip install -e ".[dev]"` 安装依赖
|
||||||
|
3. `cd backend && python -c "from app.main import app; print('FastAPI app created OK')"` 验证导入
|
||||||
|
4. git add + git commit -m "feat: FastAPI skeleton with config, database, health endpoint"
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
请完成以下任务。项目在 /home/enzo/projects/hive-manage-v3/,已有 backend/ 目录和 venv (.venv/)。
|
||||||
|
|
||||||
|
## 步骤 1: 初始化 Alembic
|
||||||
|
|
||||||
|
在 backend/ 目录执行:
|
||||||
|
```
|
||||||
|
cd backend && .venv/bin/alembic init alembic
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 2: 修改 backend/alembic/env.py
|
||||||
|
|
||||||
|
配置异步引擎,关键改动:
|
||||||
|
- 导入 `from app.database import Base, engine`
|
||||||
|
- 导入 `from app.models import * # noqa: F401,F403`
|
||||||
|
- `target_metadata = Base.metadata`
|
||||||
|
- 重写 `run_migrations_online` 以使用异步引擎:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import Base, engine
|
||||||
|
from app.models import * # noqa: F401,F403
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
url = settings.database_url
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
async def run_async_migrations():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 3: 创建 backend/app/models/__init__.py
|
||||||
|
|
||||||
|
空的 __init__.py。
|
||||||
|
|
||||||
|
## 步骤 4: 创建 backend/app/models/user.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""用户 ORM 模型。"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
OWNER = "owner"
|
||||||
|
OPERATOR = "operator"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.VIEWER, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
login_attempts: Mapped[int] = mapped_column(default=0)
|
||||||
|
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 5: 创建 backend/app/models/audit.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""审计日志 ORM 模型。"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
target: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 6: 生成并执行迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && .venv/bin/alembic revision --autogenerate -m "create users and audit_logs tables"
|
||||||
|
cd backend && .venv/bin/alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 7: 验证并提交
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add backend/alembic/ backend/app/models/ && git commit -m "feat: Alembic setup, User and AuditLog models with migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
请严格按照以上步骤执行。遇到 pip/alembic 命令时使用 backend/.venv/bin/ 前缀。
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/frontend/ 下创建 React 19 + TypeScript + Vite + Tailwind 前端脚手架。
|
||||||
|
|
||||||
|
## 步骤 1: 创建 frontend/package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hive-manage-v3-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0",
|
||||||
|
"react-dom": "^19.0",
|
||||||
|
"react-router-dom": "^7.0",
|
||||||
|
"axios": "^1.7",
|
||||||
|
"lucide-react": "^0.460"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0",
|
||||||
|
"@types/react-dom": "^19.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3",
|
||||||
|
"autoprefixer": "^10.4",
|
||||||
|
"postcss": "^8.4",
|
||||||
|
"tailwindcss": "^3.4",
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vite": "^6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 2: 创建配置文件
|
||||||
|
|
||||||
|
frontend/tsconfig.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend/vite.config.ts:
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend/tailwind.config.ts:
|
||||||
|
```typescript
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
honey: { DEFAULT: '#f0b90b', dim: '#b8970e' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend/postcss.config.js:
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 3: 创建入口文件
|
||||||
|
|
||||||
|
frontend/index.html:
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hive Manage v3</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[var(--bg-root)] text-[var(--text)]">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend/src/main.tsx:
|
||||||
|
```tsx
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode><App /></React.StrictMode>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
frontend/src/App.tsx:
|
||||||
|
```tsx
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={{background:'var(--bg-root)', color:'var(--text)'}}>
|
||||||
|
<h1 className="text-2xl font-bold">Hive Manage v3</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 4: 安装依赖并验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 5: 提交
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add frontend/ && git commit -m "feat: Vite + React 19 + Tailwind + TypeScript scaffold"
|
||||||
|
```
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/frontend/src/styles/ 下创建 CSS 变量双模体系文件 tokens.css。
|
||||||
|
|
||||||
|
内容必须精确如下(这是 UI 设计规范的核心,不可改动):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ===== 暗色模式(默认) ===== */
|
||||||
|
:root {
|
||||||
|
--bg-root: #0f1117;
|
||||||
|
--bg-nav: #161822;
|
||||||
|
--bg-card: #1a1d2e;
|
||||||
|
--bg-hover: #1e2134;
|
||||||
|
--bg-elevated: #222639;
|
||||||
|
--border: #2a2d3e;
|
||||||
|
--border-light: rgba(42, 45, 62, 0.5);
|
||||||
|
--honey: #f0b90b;
|
||||||
|
--honey-dim: #b8970e;
|
||||||
|
--honey-glow: rgba(240, 185, 11, 0.12);
|
||||||
|
--green: #00c853;
|
||||||
|
--red: #ff3d3d;
|
||||||
|
--yellow: #ffab00;
|
||||||
|
--blue: #448aff;
|
||||||
|
--orange: #ff6d00;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #8b8d9a;
|
||||||
|
--text-muted: #5a5d6a;
|
||||||
|
--bg-success: #0d2818;
|
||||||
|
--bg-error: #2d0f0f;
|
||||||
|
--bg-warn: #2d2000;
|
||||||
|
--bg-info: #0d1b2d;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55);
|
||||||
|
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.65);
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;
|
||||||
|
--header-h: 48px;
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 浅色模式 ===== */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-root: #f5f6fa;
|
||||||
|
--bg-nav: #ffffff;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-hover: #edf0f5;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--border: #e2e5ee;
|
||||||
|
--border-light: rgba(0, 0, 0, 0.06);
|
||||||
|
--honey: #e5a800;
|
||||||
|
--honey-dim: #c48f00;
|
||||||
|
--honey-glow: rgba(229, 168, 0, 0.08);
|
||||||
|
--green: #008c3a;
|
||||||
|
--red: #d32f2f;
|
||||||
|
--yellow: #e65100;
|
||||||
|
--blue: #1565c0;
|
||||||
|
--orange: #d84315;
|
||||||
|
--text: #1a1d2e;
|
||||||
|
--text-dim: #555a6e;
|
||||||
|
--text-muted: #8b8f9e;
|
||||||
|
--bg-success: #e8f5e9;
|
||||||
|
--bg-error: #fdecea;
|
||||||
|
--bg-warn: #fff3e0;
|
||||||
|
--bg-info: #e3f2fd;
|
||||||
|
--shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 全局基础 ===== */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-root);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题切换过渡 */
|
||||||
|
* {
|
||||||
|
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
创建后,在 frontend/src/main.tsx 中添加 `import './styles/tokens.css'`。
|
||||||
|
|
||||||
|
完成后提交:
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/styles/ && git commit -m "feat: CSS variable dual-theme system (dark default + light)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/frontend/src/components/ui/ 下创建 11 个 React + TypeScript 共享 UI 组件。
|
||||||
|
|
||||||
|
所有组件必须:
|
||||||
|
- 使用 CSS 变量(如 var(--bg-card), var(--text), var(--honey) 等,定义在 tokens.css)
|
||||||
|
- 支持 className prop 扩展
|
||||||
|
- 导出类型(Props interface)
|
||||||
|
- 使用 Tailwind 辅助类 + CSS 变量混合
|
||||||
|
|
||||||
|
## 组件 1: Button.tsx
|
||||||
|
|
||||||
|
Props: variant ('primary'|'danger'|'outline'|'ghost'), size ('sm'|'md'), loading, disabled, children, onClick, className, type
|
||||||
|
- Primary: bg=var(--honey) color=#000 font-weight:600
|
||||||
|
- Danger: border=var(--red) color=var(--red)
|
||||||
|
- Outline: border=var(--border) color=var(--text-dim)
|
||||||
|
- Ghost: color=var(--text-dim), border:none
|
||||||
|
- 尺寸: sm padding:4px 10px font-size:12px, md padding:7px 14px font-size:13px
|
||||||
|
- Loading 态: 文字透明 + 14px spinner
|
||||||
|
- Disabled: opacity:0.4 cursor:not-allowed
|
||||||
|
|
||||||
|
## 组件 2: Card.tsx
|
||||||
|
|
||||||
|
Props: label, value, valueColor, change, changeDirection ('up'|'down'|'neutral'), onClick, className
|
||||||
|
- 渲染: 顶部 label(12px uppercase), 中间 value(28px bold), 底部 change(12px)
|
||||||
|
- valueColor 支持: 'green','red','yellow','blue' 映射到对应 CSS 变量
|
||||||
|
- changeDirection: up=var(--green) '↑', down=var(--red) '↓', neutral=var(--text-dim) '→'
|
||||||
|
|
||||||
|
## 组件 3: Table.tsx
|
||||||
|
|
||||||
|
Props: columns ({key,label,width?}[]), data (record[]), rowKey, onRowClick, rowClassName (record=>string), emptyText
|
||||||
|
- 表头 sticky top:0, 12px uppercase text-dim
|
||||||
|
- 行高 44px, hover 时 bg=var(--bg-hover)
|
||||||
|
- 支持 row-danger 样式(bg=var(--bg-error))
|
||||||
|
- 空数据: 显示 emptyText 或 "暂无数据"
|
||||||
|
|
||||||
|
## 组件 4: Badge.tsx
|
||||||
|
|
||||||
|
Props: variant ('success'|'danger'|'warning'|'info'|'honey'|'outline'), children, className
|
||||||
|
- 胶囊形: padding:2px 8px, border-radius:10px, font-size:11px, font-weight:600
|
||||||
|
- 每种 variant 对应不同背景色(rgba 半透明)+ 文字色
|
||||||
|
|
||||||
|
## 组件 5: Modal.tsx
|
||||||
|
|
||||||
|
Props: open, onClose, title, children, footer, confirmText, onConfirm, confirmDanger, width
|
||||||
|
- 遮罩: rgba(0,0,0,0.6), 居中 flex
|
||||||
|
- 弹窗: bg=var(--bg-nav), border-radius:8px, max-width:500px
|
||||||
|
- 关闭方式: ✕ 按钮 / 点遮罩 / Esc 键
|
||||||
|
- confirmDanger 时确认按钮变红色
|
||||||
|
- Portal 渲染到 document.body
|
||||||
|
|
||||||
|
## 组件 6: Drawer.tsx
|
||||||
|
|
||||||
|
Props: open, onClose, title, children, footer, width (默认 480px)
|
||||||
|
- 右侧滑入: transform translateX(100%→0), transition 250ms ease
|
||||||
|
- 遮罩 + Esc 关闭
|
||||||
|
- Header 固定顶部, Body 可滚动, Footer 固定底部
|
||||||
|
|
||||||
|
## 组件 7: Toast.tsx
|
||||||
|
|
||||||
|
导出: toast 函数(命令式调用),非组件
|
||||||
|
```typescript
|
||||||
|
toast.success('操作成功')
|
||||||
|
toast.error('操作失败')
|
||||||
|
toast.warn('请注意')
|
||||||
|
toast.info('提示信息')
|
||||||
|
```
|
||||||
|
- 顶部居中, 最大宽度 480px
|
||||||
|
- success 3s 消失, error 5s 消失, warn 手动关闭
|
||||||
|
- 堆叠: 最多 3 条
|
||||||
|
- 动画: translateY + opacity
|
||||||
|
|
||||||
|
## 组件 8: Skeleton.tsx
|
||||||
|
|
||||||
|
Props: variant ('card'|'table'|'text'), count (行数)
|
||||||
|
- card: 矩形块占位
|
||||||
|
- table: 横向条占位
|
||||||
|
- text: 短条占位
|
||||||
|
- shimmer 动画: linear-gradient 1500ms infinite
|
||||||
|
|
||||||
|
## 组件 9: Toggle.tsx
|
||||||
|
|
||||||
|
Props: checked, onChange, label, disabled
|
||||||
|
- 44×24px, 滑块 20×20px 白色
|
||||||
|
- Off: bg=var(--bg-hover), On: bg=var(--honey)
|
||||||
|
- 滑块动画 translateX(20px)
|
||||||
|
- 标签在右侧, gap 8px, font-size 13px
|
||||||
|
|
||||||
|
## 组件 10: Timeline.tsx
|
||||||
|
|
||||||
|
Props: events ({time, type, label, color?}[])
|
||||||
|
- 横向时间线, flex 布局
|
||||||
|
- 每个点 8px 直径, 线 2px var(--border)
|
||||||
|
- Hover 点上浮 2px + tooltip
|
||||||
|
- 颜色: green(心跳恢复), red(超时), orange(飞书断连), blue(重启)
|
||||||
|
|
||||||
|
## 组件 11: SearchBox.tsx
|
||||||
|
|
||||||
|
Props: onSearch, placeholder, debounceMs (默认 300)
|
||||||
|
- 输入框: bg=var(--bg-card), border focus 变 var(--honey)
|
||||||
|
- 下拉面板: 分组结果, bg=var(--bg-elevated)
|
||||||
|
- 键盘: ↑↓ 导航, Enter 选择, Esc 关闭
|
||||||
|
- 宽度: 桌面 420px
|
||||||
|
|
||||||
|
创建完成后提交:
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/components/ui/ && git commit -m "feat: shared UI component library (11 components)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
在 /home/enzo/projects/hive-manage-v3/frontend/src/components/layout/ 下创建布局组件,并在 App.tsx 中集成路由框架。
|
||||||
|
|
||||||
|
## 组件 1: Sidebar.tsx
|
||||||
|
|
||||||
|
导航项结构(参考原型):
|
||||||
|
- 蜂群态势: 📊 蜂群总览, 📡 Gateway, 🏥 健康分, 🤖 工蜂列表
|
||||||
|
- 任务编排: 📋 看板追踪, ⏰ Cron, 🔔 事件管理
|
||||||
|
- 通信频道: 📶 频道配置
|
||||||
|
- 运维工具: 🔑 密钥, 📁 文件, 💻 终端, 📜 日志, 🔌 MCP接入, 📤 MCP对外
|
||||||
|
- 知识库管理: 📚 事件档案
|
||||||
|
- 系统: ⚙ 设置
|
||||||
|
|
||||||
|
Props: activePath, onNavigate(path:string)
|
||||||
|
- 宽度 220px, bg=var(--bg-nav), 右侧 border
|
||||||
|
- 顶部 Logo 48px: "🐝 知微蜂巢"
|
||||||
|
- Section 标题 11px uppercase text-muted
|
||||||
|
- NavItem: 默认 text-dim, hover bg-hover, active honey-glow + honey 文字 + 600 weight
|
||||||
|
- Badge: 红色(失败计数)
|
||||||
|
|
||||||
|
## 组件 2: Header.tsx
|
||||||
|
|
||||||
|
Props: breadcrumb, onlineCount, runningCount, onSearch, theme, onToggleTheme
|
||||||
|
- 高度 48px, bg=var(--bg-nav), 底部 border
|
||||||
|
- 左侧: Logo + 面包屑
|
||||||
|
- 中间: 搜索框 260px, bg=var(--bg-card)
|
||||||
|
- 右侧: 在线数/运行中 标签 + 主题切换按钮(🌙/☀️) + 用户头像
|
||||||
|
- useTheme hook: 读取 localStorage 'hive-theme', 默认跟随系统 prefers-color-scheme
|
||||||
|
|
||||||
|
## 组件 3/4: MobileNav.tsx + TabBar.tsx
|
||||||
|
|
||||||
|
MobileNav: 汉堡菜单 → 左侧抽屉 280px 滑入 + 遮罩
|
||||||
|
TabBar: 底部固定 56px, 4项(📊总览/📡Gateway/📋Kanban/⏰Cron)等宽分布, 激活态 honey
|
||||||
|
|
||||||
|
## 组件 5: AppLayout.tsx
|
||||||
|
|
||||||
|
桌面: CSS Grid (sidebar 220px + header 48px + main)
|
||||||
|
平板: sidebar 180px
|
||||||
|
移动 (<768px): 顶部 Header + TabBar + 汉堡抽屉
|
||||||
|
使用 useTheme hook 管理 data-theme 属性
|
||||||
|
|
||||||
|
## App.tsx 更新
|
||||||
|
|
||||||
|
集成 react-router-dom:
|
||||||
|
- / → 重定向到 /dashboard
|
||||||
|
- /dashboard → DashboardPage
|
||||||
|
- /gateway → GatewayPage
|
||||||
|
- /health → HealthPage
|
||||||
|
- /kanban → KanbanPage
|
||||||
|
- /cron → CronPage
|
||||||
|
- /keys → KeysPage
|
||||||
|
- /events → EventArchivePage
|
||||||
|
- /users → UserManagementPage
|
||||||
|
- /audit → AuditLogPage
|
||||||
|
- /login → LoginPage
|
||||||
|
- 其余 → 404
|
||||||
|
|
||||||
|
路由配置用懒加载 React.lazy + Suspense(Skeleton)。
|
||||||
|
|
||||||
|
## useTheme hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/useTheme.ts
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
return (localStorage.getItem('hive-theme') as Theme) || 'system'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme === 'system') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
|
||||||
|
} else {
|
||||||
|
root.setAttribute('data-theme', theme)
|
||||||
|
}
|
||||||
|
localStorage.setItem('hive-theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
||||||
|
return { theme, setTheme, toggle }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
创建完成后提交:
|
||||||
|
```bash
|
||||||
|
cd /home/enzo/projects/hive-manage-v3 && git add frontend/src/components/layout/ frontend/src/App.tsx frontend/src/hooks/ && git commit -m "feat: layout components (Sidebar, Header, MobileNav, TabBar, AppLayout) with routing"
|
||||||
|
```
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
修复前端视觉质量问题。项目在 /home/enzo/projects/hive-manage-v3/frontend/。
|
||||||
|
|
||||||
|
## 问题清单
|
||||||
|
|
||||||
|
### 1. 全局:浅色背景
|
||||||
|
当前 Dashboard 显示白色/浅灰背景,应该是暗色主题。原因:`data-theme` 属性未正确初始化,导致 CSS 变量默认值被 Tailwind 覆盖。
|
||||||
|
修复:确保 `<html>` 元素初始化时设置 `data-theme="dark"`,且所有页面使用 CSS 变量而非硬编码颜色。
|
||||||
|
|
||||||
|
### 2. 侧边栏:导航项 typo
|
||||||
|
"Cron1" 应为 "Cron 管理"。
|
||||||
|
|
||||||
|
### 3. Dashboard 卡片:视觉粗糙
|
||||||
|
- 在线/离线 Gateway 卡片应该有颜色区分(绿色数值/红色数值),当前只有文字
|
||||||
|
- "vs 昨天" 的变化指示应该有 ↑/↓ 箭头
|
||||||
|
- Cron 失败 > 3 的卡片应标红
|
||||||
|
- 整体卡片间距和 padding 应符合设计规范(gap:14px, padding:16px)
|
||||||
|
|
||||||
|
### 4. 蜂群健康分布
|
||||||
|
柱状图样式简陋,应有:
|
||||||
|
- 四色柱子(A绿 B蓝 C黄 D红)
|
||||||
|
- 点击柱子跳转 /health 页面
|
||||||
|
- D 级 Profile 名称在下方列出
|
||||||
|
- "共 58 个 Profile" 标题
|
||||||
|
|
||||||
|
### 5. Gateway 异常趋势
|
||||||
|
- Timeline 组件时间轴应无 "R/F/H/OK" 文字,改用彩色圆点
|
||||||
|
- 每个点 Hover 显示 tooltip(时间和事件详情)
|
||||||
|
- 图例正确:●正常 ●心跳超时 ●飞书断连 ●重启
|
||||||
|
|
||||||
|
### 6. Kanban 概览
|
||||||
|
- 当前显示静态数据,应调用 GET /api/v1/dashboard/kanban-summary API
|
||||||
|
- 看板名 + RUNNING/READY 状态显示
|
||||||
|
|
||||||
|
### 7. Cron 最近执行
|
||||||
|
- 调用 GET /api/v1/dashboard/cron-recent API
|
||||||
|
- 状态正确显示:✅ 成功 / ❌ 失败 / ⏸ 暂停
|
||||||
|
- 时间显示精确到分钟
|
||||||
|
|
||||||
|
### 8. 侧边栏整体样式
|
||||||
|
- 背景色:var(--bg-nav) 暗色
|
||||||
|
- Section 标题:11px uppercase letter-spacing
|
||||||
|
- NavItem 激活态:honey-glow 背景 + honey 文字色
|
||||||
|
- 各导航项名称应与 PRD/原型一致
|
||||||
|
|
||||||
|
### 9. Header
|
||||||
|
- "在线 0 运行中 0 离线 0" 应为实时数据(从 usePolling 或 context 获取)
|
||||||
|
- 搜索框 placeholder "搜索 Profile / 任务 / Cron / 事件..."
|
||||||
|
- 主题切换按钮(🌙/☀️)正常工作
|
||||||
|
|
||||||
|
### 10. useTheme hook
|
||||||
|
确保:
|
||||||
|
- 初始默认 dark(根元素设置 data-theme="dark")
|
||||||
|
- localStorage 持久化
|
||||||
|
- 切换即时生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
先读取当前所有相关文件,然后逐个修复上述问题。每修复 2-3 个问题就运行 `npx tsc --noEmit` 确认编译通过。最后 `npm run build` 并提交。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hive Manage v3</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[var(--bg-root)] text-[var(--text)]">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3096
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "hive-manage-v3-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0",
|
||||||
|
"react-dom": "^19.0",
|
||||||
|
"react-router-dom": "^7.0",
|
||||||
|
"axios": "^1.7",
|
||||||
|
"lucide-react": "^0.460"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0",
|
||||||
|
"@types/react-dom": "^19.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3",
|
||||||
|
"autoprefixer": "^10.4",
|
||||||
|
"postcss": "^8.4",
|
||||||
|
"tailwindcss": "^3.4",
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"vite": "^6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { lazy, Suspense, type ReactNode } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuth } from './hooks/useAuth'
|
||||||
|
import Skeleton from './components/ui/Skeleton'
|
||||||
|
import AppLayout from './components/layout/AppLayout'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import NotFoundPage from './pages/NotFoundPage'
|
||||||
|
import { globalSearch } from './api/dashboard'
|
||||||
|
import type { SearchGroup } from './components/ui/SearchBox'
|
||||||
|
import Badge from './components/ui/Badge'
|
||||||
|
|
||||||
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||||
|
const GatewayPage = lazy(() => import('./pages/GatewayPage'))
|
||||||
|
const HealthPage = lazy(() => import('./pages/HealthPage'))
|
||||||
|
const KanbanPage = lazy(() => import('./pages/KanbanPage'))
|
||||||
|
const CronPage = lazy(() => import('./pages/CronPage'))
|
||||||
|
const KeysPage = lazy(() => import('./pages/KeysPage'))
|
||||||
|
const EventArchivePage = lazy(() => import('./pages/EventArchivePage'))
|
||||||
|
const UserManagementPage = lazy(() => import('./pages/UserManagementPage'))
|
||||||
|
const AuditLogPage = lazy(() => import('./pages/AuditLogPage'))
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
<Skeleton variant="card" count={2} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protected route wrapper — redirects to /login if not authenticated */
|
||||||
|
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login page wrapper — redirects to / if already authenticated */
|
||||||
|
function LoginRoute() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
return <LoginPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSearch = async (query: string): Promise<SearchGroup[]> => {
|
||||||
|
if (!query.trim()) return []
|
||||||
|
try {
|
||||||
|
const res = await globalSearch(query)
|
||||||
|
const groups: SearchGroup[] = []
|
||||||
|
if (res.data.profiles.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: 'Profiles',
|
||||||
|
items: res.data.profiles.map((p: any) => ({
|
||||||
|
value: '/gateway',
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{p.name}
|
||||||
|
<Badge variant={p.status === 'online' ? 'success' : 'danger'}>{p.status}</Badge>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (res.data.kanban_tasks.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: 'Kanban 任务',
|
||||||
|
items: res.data.kanban_tasks.map((t: any) => ({
|
||||||
|
value: '/kanban',
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{t.title}
|
||||||
|
<Badge variant="honey">{t.column}</Badge>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (res.data.cron_jobs.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: 'Cron 任务',
|
||||||
|
items: res.data.cron_jobs.map((j: any) => ({
|
||||||
|
value: '/cron',
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{j.job}
|
||||||
|
<Badge variant={j.status === 'active' ? 'success' : 'honey'}>{j.status}</Badge>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (res.data.events.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: '事件',
|
||||||
|
items: res.data.events.map((e: any) => ({
|
||||||
|
value: '/events',
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{e.title}
|
||||||
|
<Badge variant="honey">{e.status}</Badge>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchNavigate = (value: string) => {
|
||||||
|
navigate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public */}
|
||||||
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
|
||||||
|
{/* Protected — each page is its own route (no nested Routes) */}
|
||||||
|
<Route path="/" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><DashboardPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/dashboard" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><DashboardPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/gateway" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><GatewayPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/health" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><HealthPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/kanban" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><KanbanPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/cron" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><CronPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/keys" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><KeysPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/events" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><EventArchivePage /></AppLayout></ProtectedRoute>} />
|
||||||
|
{user?.role === 'owner' && (
|
||||||
|
<>
|
||||||
|
<Route path="/users" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><UserManagementPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/audit" element={<ProtectedRoute><AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}><AuditLogPage /></AppLayout></ProtectedRoute>} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Catch-all */}
|
||||||
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const getAuditLogs = (params?: { page?: number; page_size?: number; action?: string }) =>
|
||||||
|
client.get('/audit', { params })
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const login = (username: string, password: string) =>
|
||||||
|
client.post('/auth/login', { username, password })
|
||||||
|
|
||||||
|
export const register = (data: { username: string; email: string; password: string }) =>
|
||||||
|
client.post('/auth/register', data)
|
||||||
|
|
||||||
|
export const refresh = () => client.post('/auth/refresh')
|
||||||
|
|
||||||
|
export const changePassword = (data: { old_password: string; new_password: string }) =>
|
||||||
|
client.post('/auth/change-password', data)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default client
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface CronJob {
|
||||||
|
id: string
|
||||||
|
job_name: string
|
||||||
|
profile: string
|
||||||
|
schedule: string
|
||||||
|
status: string
|
||||||
|
last_run: string | null
|
||||||
|
last_run_success: boolean | null
|
||||||
|
success_rate: number
|
||||||
|
has_logs: boolean
|
||||||
|
system: boolean
|
||||||
|
consecutive_failures: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronListResponse {
|
||||||
|
jobs: CronJob[]
|
||||||
|
has_failures: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronLogItem {
|
||||||
|
time: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronLogsResponse {
|
||||||
|
logs: CronLogItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronActionResponse {
|
||||||
|
success: boolean
|
||||||
|
action: string
|
||||||
|
job_id: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCronJobs = (params?: { status?: string; profile?: string }) =>
|
||||||
|
client.get<CronListResponse>('/cron/jobs', { params })
|
||||||
|
|
||||||
|
export const getCronLogs = (jobId: string) =>
|
||||||
|
client.get<CronLogsResponse>(`/cron/jobs/${jobId}/logs`)
|
||||||
|
|
||||||
|
export const runCronJob = (jobId: string) =>
|
||||||
|
client.post<CronActionResponse>(`/cron/jobs/${jobId}/run`)
|
||||||
|
|
||||||
|
export const pauseCronJob = (jobId: string) =>
|
||||||
|
client.post<CronActionResponse>(`/cron/jobs/${jobId}/pause`)
|
||||||
|
|
||||||
|
export const resumeCronJob = (jobId: string) =>
|
||||||
|
client.post<CronActionResponse>(`/cron/jobs/${jobId}/resume`)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface SummaryData {
|
||||||
|
online_count: number
|
||||||
|
offline_count: number
|
||||||
|
total_profiles: number
|
||||||
|
running_tasks: number
|
||||||
|
cron_failures_today: number
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthDistribution {
|
||||||
|
A: number
|
||||||
|
B: number
|
||||||
|
C: number
|
||||||
|
D: number
|
||||||
|
total: number
|
||||||
|
d_profiles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayTrendItem {
|
||||||
|
hour: string
|
||||||
|
restarts: number
|
||||||
|
feishu_disconnects: number
|
||||||
|
heartbeat_timeouts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanSummaryItem {
|
||||||
|
board: string
|
||||||
|
running: string | null
|
||||||
|
ready: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronRecentItem {
|
||||||
|
job: string
|
||||||
|
status: string
|
||||||
|
time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
profiles: { name: string; status: string }[]
|
||||||
|
kanban_tasks: { title: string; column: string }[]
|
||||||
|
cron_jobs: { job: string; status: string }[]
|
||||||
|
events: { title: string; status: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDashboardSummary = () =>
|
||||||
|
client.get<SummaryData>('/dashboard/summary')
|
||||||
|
|
||||||
|
export const getHealthDistribution = () =>
|
||||||
|
client.get<HealthDistribution>('/dashboard/health-distribution')
|
||||||
|
|
||||||
|
export const getGatewayTrend = () =>
|
||||||
|
client.get<GatewayTrendItem[]>('/dashboard/gateway-trend')
|
||||||
|
|
||||||
|
export const getKanbanSummary = () =>
|
||||||
|
client.get<KanbanSummaryItem[]>('/dashboard/kanban-summary')
|
||||||
|
|
||||||
|
export const getCronRecent = () =>
|
||||||
|
client.get<CronRecentItem[]>('/dashboard/cron-recent')
|
||||||
|
|
||||||
|
export const globalSearch = (q: string) =>
|
||||||
|
client.get<SearchResult>('/search', { params: { q } })
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface EventItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
level: 'A' | 'B' | 'C'
|
||||||
|
domain: string
|
||||||
|
status: 'active' | 'fading' | 'dissipating' | 'closed'
|
||||||
|
occurred_date: string
|
||||||
|
last_reviewed: string | null
|
||||||
|
no_new_info_count: number
|
||||||
|
next_review: string | null
|
||||||
|
overdue_days: number
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventsResponse {
|
||||||
|
events: EventItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEvents = (params?: {
|
||||||
|
status?: string
|
||||||
|
domain?: string
|
||||||
|
search?: string
|
||||||
|
}) => client.get<EventsResponse>('/events', { params })
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface GatewayInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'online' | 'offline' | 'warn'
|
||||||
|
downtime_minutes: number
|
||||||
|
restarts_today: number
|
||||||
|
last_event: string
|
||||||
|
profile_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayEvent {
|
||||||
|
time: string
|
||||||
|
profile: string
|
||||||
|
type: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationResult {
|
||||||
|
success: boolean
|
||||||
|
action: string
|
||||||
|
profile: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGatewayStatus(status?: string) {
|
||||||
|
const params = status ? { status } : undefined
|
||||||
|
return client.get<GatewayInfo[]>('/gateway/status', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGatewayEvents() {
|
||||||
|
return client.get<GatewayEvent[]>('/gateway/events')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGateway(profile: string) {
|
||||||
|
return client.post<OperationResult>(`/gateway/${profile}/start`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopGateway(profile: string) {
|
||||||
|
return client.post<OperationResult>(`/gateway/${profile}/stop`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restartGateway(profile: string) {
|
||||||
|
return client.post<OperationResult>(`/gateway/${profile}/restart`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
/* ── types ── */
|
||||||
|
|
||||||
|
export interface ScoreRow {
|
||||||
|
rank: number
|
||||||
|
name: string
|
||||||
|
total_score: number
|
||||||
|
grade: string
|
||||||
|
gateway_score: number
|
||||||
|
config_score: number
|
||||||
|
skill_score: number
|
||||||
|
cron_score: number
|
||||||
|
deduct_summary: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreListResponse {
|
||||||
|
total: number
|
||||||
|
distribution: { A: number; B: number; C: number; D: number }
|
||||||
|
scores: ScoreRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DimensionDetail {
|
||||||
|
name: string
|
||||||
|
weight: string
|
||||||
|
score: number
|
||||||
|
max_score: number
|
||||||
|
deductions: string[]
|
||||||
|
suggestions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileScoreDetail {
|
||||||
|
profile_name: string
|
||||||
|
total_score: number
|
||||||
|
grade: string
|
||||||
|
calculated_at: string
|
||||||
|
dimensions: DimensionDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareRow {
|
||||||
|
field: string
|
||||||
|
category: string
|
||||||
|
has_diff: boolean
|
||||||
|
[profileName: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareResponse {
|
||||||
|
profiles: string[]
|
||||||
|
rows: CompareRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculateResponse {
|
||||||
|
message: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── API functions ── */
|
||||||
|
|
||||||
|
export const getHealthScores = (params?: { grade?: string; sort?: string }) =>
|
||||||
|
client.get<ScoreListResponse>('/health/scores', { params })
|
||||||
|
|
||||||
|
export const getProfileScore = (profile: string) =>
|
||||||
|
client.get<ProfileScoreDetail>(`/health/scores/${encodeURIComponent(profile)}`)
|
||||||
|
|
||||||
|
export const compareProfiles = (profile_names: string[]) =>
|
||||||
|
client.post<CompareResponse>('/health/compare', { profile_names })
|
||||||
|
|
||||||
|
export const recalculateScores = () =>
|
||||||
|
client.post<CalculateResponse>('/health/calculate')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface KanbanTask {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
board: string
|
||||||
|
worker_profile: string
|
||||||
|
worker_type: string
|
||||||
|
priority: number
|
||||||
|
column: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
goal_mode: boolean
|
||||||
|
retries: number
|
||||||
|
failures: number
|
||||||
|
description: string
|
||||||
|
block_reason: string | null
|
||||||
|
dependencies: string[]
|
||||||
|
dependents: string[]
|
||||||
|
activities: { time: string; user: string; action: string }[]
|
||||||
|
anomalies: string[]
|
||||||
|
dependency_tasks?: { id: string; title: string; column: string }[]
|
||||||
|
dependent_tasks?: { id: string; title: string; column: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanOverview {
|
||||||
|
total_tasks: number
|
||||||
|
profile_count: number
|
||||||
|
board_count: number
|
||||||
|
running_count: number
|
||||||
|
blocked_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanResponse {
|
||||||
|
todo: KanbanTask[]
|
||||||
|
ready: KanbanTask[]
|
||||||
|
running: KanbanTask[]
|
||||||
|
blocked: KanbanTask[]
|
||||||
|
done: KanbanTask[]
|
||||||
|
overview: KanbanOverview
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveResponse {
|
||||||
|
success: boolean
|
||||||
|
task: KanbanTask
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getKanbanTasks = (params?: { worker?: string; board?: string; anomaly?: string }) =>
|
||||||
|
client.get<KanbanResponse>('/kanban/tasks', { params })
|
||||||
|
|
||||||
|
export const getKanbanTaskDetail = (taskId: string) =>
|
||||||
|
client.get<KanbanTask>(`/kanban/tasks/${taskId}`)
|
||||||
|
|
||||||
|
export const moveKanbanTask = (taskId: string, column: string, blockReason?: string) =>
|
||||||
|
client.patch<MoveResponse>(`/kanban/tasks/${taskId}/move`, { column, block_reason: blockReason })
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export interface KeyFieldEntry {
|
||||||
|
profile: string
|
||||||
|
fields: Record<string, string>
|
||||||
|
missing: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyListResponse {
|
||||||
|
data: KeyFieldEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverwriteResult {
|
||||||
|
profile: string
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverwriteResponse {
|
||||||
|
results: OverwriteResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
profile: string
|
||||||
|
missing: string[]
|
||||||
|
missing_count: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResponse {
|
||||||
|
results: ScanResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffRow {
|
||||||
|
field: string
|
||||||
|
[profile: string]: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffResponse {
|
||||||
|
profiles: string[]
|
||||||
|
rows: DiffRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getKeys = (profiles?: string) =>
|
||||||
|
client.get<KeyListResponse>('/keys', { params: profiles ? { profiles } : {} })
|
||||||
|
|
||||||
|
export const batchOverwrite = (body: {
|
||||||
|
profile_names: string[]
|
||||||
|
key_name: string
|
||||||
|
new_value: string
|
||||||
|
confirmation: string
|
||||||
|
}) => client.post<OverwriteResponse>('/keys/batch-overwrite', body)
|
||||||
|
|
||||||
|
export const batchScan = (profile_names?: string[]) =>
|
||||||
|
client.post<ScanResponse>('/keys/batch-scan', { profile_names })
|
||||||
|
|
||||||
|
export const getDiff = (profiles: string) =>
|
||||||
|
client.get<DiffResponse>('/keys/diff', { params: { profiles } })
|
||||||
|
|
||||||
|
export const exportCsv = (profiles?: string) =>
|
||||||
|
client.get<string>('/keys/export', {
|
||||||
|
params: profiles ? { profiles } : {},
|
||||||
|
responseType: 'text',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const WHITELIST_FIELDS = [
|
||||||
|
'DEEPSEEK_API_KEY',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
'FEISHU_APP_ID',
|
||||||
|
'FEISHU_APP_SECRET',
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
export const getUsers = (params?: { page?: number; page_size?: number }) =>
|
||||||
|
client.get('/users', { params })
|
||||||
|
|
||||||
|
export const getUser = (id: string) => client.get(`/users/${id}`)
|
||||||
|
|
||||||
|
export const updateUser = (id: string, data: { role?: string; is_active?: boolean }) =>
|
||||||
|
client.patch(`/users/${id}`, data)
|
||||||
|
|
||||||
|
export const approveUser = (id: string) => client.post(`/users/${id}/approve`)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useTheme } from '../../hooks/useTheme'
|
||||||
|
import { usePolling } from '../../hooks/usePolling'
|
||||||
|
import { getDashboardSummary } from '../../api/dashboard'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import Header from './Header'
|
||||||
|
import MobileNav from './MobileNav'
|
||||||
|
import TabBar from './TabBar'
|
||||||
|
|
||||||
|
function useMediaQuery(query: string) {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.matchMedia(query).matches
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const mql = window.matchMedia(query)
|
||||||
|
mql.addEventListener('change', (e) => setMatches(e.matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
breadcrumb?: string
|
||||||
|
onlineCount?: number
|
||||||
|
runningCount?: number
|
||||||
|
offlineCount?: number
|
||||||
|
onSearch?: (query: string) => any
|
||||||
|
onSearchNavigate?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({
|
||||||
|
children,
|
||||||
|
breadcrumb,
|
||||||
|
onlineCount = 0,
|
||||||
|
runningCount = 0,
|
||||||
|
offlineCount = 0,
|
||||||
|
onSearch,
|
||||||
|
onSearchNavigate,
|
||||||
|
}: AppLayoutProps) {
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||||
|
const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarWidth = isTablet ? 180 : 220
|
||||||
|
|
||||||
|
/* ---- live header counts ---- */
|
||||||
|
const [liveOnline, setLiveOnline] = useState(0)
|
||||||
|
const [liveRunning, setLiveRunning] = useState(0)
|
||||||
|
const [liveOffline, setLiveOffline] = useState(0)
|
||||||
|
usePolling(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDashboardSummary()
|
||||||
|
setLiveOnline(res.data.online_count)
|
||||||
|
setLiveRunning(res.data.running_tasks)
|
||||||
|
setLiveOffline(res.data.offline_count)
|
||||||
|
} catch { /* ignore polling errors */ }
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
const hOnline = onlineCount || liveOnline
|
||||||
|
const hRunning = runningCount || liveRunning
|
||||||
|
const hOffline = offlineCount || liveOffline
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col" style={{ background: 'var(--bg-root)' }}>
|
||||||
|
{/* Mobile header */}
|
||||||
|
<header
|
||||||
|
className="flex items-center justify-between px-4 shrink-0"
|
||||||
|
style={{
|
||||||
|
height: 'var(--header-h)',
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileNavOpen(true)}
|
||||||
|
className="cursor-pointer bg-transparent border-none text-[20px] p-1"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 14 }}>知微蜂巢</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1"
|
||||||
|
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-3 pb-[64px]">{children}</main>
|
||||||
|
|
||||||
|
{/* Mobile tab bar */}
|
||||||
|
<TabBar activePath={location.pathname} />
|
||||||
|
|
||||||
|
{/* Mobile nav drawer */}
|
||||||
|
<MobileNav
|
||||||
|
open={mobileNavOpen}
|
||||||
|
onClose={() => setMobileNavOpen(false)}
|
||||||
|
activePath={location.pathname}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `${sidebarWidth}px 1fr`,
|
||||||
|
gridTemplateRows: 'var(--header-h) 1fr',
|
||||||
|
gridTemplateAreas: '"sidebar header" "sidebar main"',
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div style={{ gridArea: 'sidebar' }}>
|
||||||
|
<Sidebar activePath={location.pathname} onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ gridArea: 'header' }}>
|
||||||
|
<Header
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
onlineCount={hOnline}
|
||||||
|
runningCount={hRunning}
|
||||||
|
offlineCount={hOffline}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onSearchNavigate={onSearchNavigate}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<main
|
||||||
|
className="overflow-y-auto p-5"
|
||||||
|
style={{ gridArea: 'main', background: 'var(--bg-root)' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
breadcrumb?: string
|
||||||
|
onlineCount?: number
|
||||||
|
runningCount?: number
|
||||||
|
offlineCount?: number
|
||||||
|
onSearch?: (query: string) => any
|
||||||
|
onSearchNavigate?: (value: string) => void
|
||||||
|
theme: string
|
||||||
|
onToggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
breadcrumb,
|
||||||
|
onlineCount = 0,
|
||||||
|
runningCount = 0,
|
||||||
|
onSearch,
|
||||||
|
onSearchNavigate,
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
|
}: HeaderProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?'
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value.trim()
|
||||||
|
if (e.key === 'Enter' && onSearchNavigate) {
|
||||||
|
onSearchNavigate(value)
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && onSearch) {
|
||||||
|
onSearch(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
gridArea: 'header',
|
||||||
|
height: 'var(--header-h)',
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 20px',
|
||||||
|
gap: 16,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--honey)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🐝 知微蜂巢
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
|
/{' '}
|
||||||
|
<span style={{ color: 'var(--text)' }}>
|
||||||
|
{breadcrumb || '蜂群总览'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 10,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="搜索 Profile / 任务 / Cron / 事件..."
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
height: 32,
|
||||||
|
padding: '0 12px 0 30px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--honey)'
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--green)' }}>●</span>
|
||||||
|
{onlineCount} online
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--yellow)' }}>●</span>
|
||||||
|
{runningCount} running
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 18,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
lineHeight: 1,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<div
|
||||||
|
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
|
||||||
|
onMouseEnter={() => setMenuOpen(true)}
|
||||||
|
onMouseLeave={() => setMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'var(--honey-glow)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
}}
|
||||||
|
title={user?.role ?? ''}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '100%',
|
||||||
|
marginTop: 4,
|
||||||
|
width: 120,
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: '4px 0',
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--red)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useCallback, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
|
||||||
|
export interface MobileNavProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
activePath: string
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileNav({ open, onClose, activePath, onNavigate }: MobileNavProps) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
requestAnimationFrame(() => setVisible(true))
|
||||||
|
} else {
|
||||||
|
setVisible(false)
|
||||||
|
const timer = setTimeout(() => setMounted(false), 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [open, handleKeyDown])
|
||||||
|
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
onNavigate(path)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999]">
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 transition-opacity duration-250 ${
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Drawer */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 left-0 h-full transition-transform duration-[250ms] ease-in-out`}
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
transform: visible ? 'translateX(0)' : 'translateX(-100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar activePath={activePath} onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { getDashboardSummary } from '../../api/dashboard'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
path: string
|
||||||
|
badge?: number
|
||||||
|
isNew?: boolean
|
||||||
|
requiredRole?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
title: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: NavSection[] = [
|
||||||
|
{
|
||||||
|
title: '蜂群态势',
|
||||||
|
items: [
|
||||||
|
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
|
||||||
|
{ label: 'Gateway 健康', icon: '📡', path: '/gateway', isNew: true },
|
||||||
|
{ label: 'Profile 健康分', icon: '🏥', path: '/health', isNew: true },
|
||||||
|
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '任务编排',
|
||||||
|
items: [
|
||||||
|
{ label: '看板追踪', icon: '📋', path: '/kanban' },
|
||||||
|
{ label: 'Cron 管理', icon: '⏰', path: '/cron', badge: 1 },
|
||||||
|
{ label: '事件管理', icon: '🔔', path: '/events', badge: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '通信频道',
|
||||||
|
items: [
|
||||||
|
{ label: '频道配置', icon: '📡', path: '/channels' },
|
||||||
|
{ label: '频道监控', icon: '📶', path: '/channel-monitor', isNew: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '运维工具',
|
||||||
|
items: [
|
||||||
|
{ label: '密钥管理', icon: '🔑', path: '/keys' },
|
||||||
|
{ label: '文件浏览', icon: '📁', path: '/files', isNew: true },
|
||||||
|
{ label: 'Web 终端', icon: '💻', path: '/terminal', isNew: true },
|
||||||
|
{ label: '蜂群日志', icon: '📜', path: '/logs', isNew: true },
|
||||||
|
{ label: 'MCP 接入管理', icon: '🔌', path: '/mcp-in', isNew: true },
|
||||||
|
{ label: 'MCP 对外服务', icon: '📤', path: '/mcp-out', isNew: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '知识库管理',
|
||||||
|
items: [
|
||||||
|
{ label: '事件档案管理', icon: '📚', path: '/archive', isNew: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统',
|
||||||
|
items: [
|
||||||
|
{ label: '用户管理', icon: '👥', path: '/users', requiredRole: 'owner' },
|
||||||
|
{ label: '审计日志', icon: '📋', path: '/audit', requiredRole: 'owner' },
|
||||||
|
{ label: '系统设置', icon: '⚙', path: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
activePath: string
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAccess(userRole: string | undefined, requiredRole?: string): boolean {
|
||||||
|
if (!requiredRole) return true
|
||||||
|
const hierarchy: Record<string, number> = { viewer: 0, operator: 1, owner: 2 }
|
||||||
|
const userLevel = hierarchy[userRole ?? 'viewer'] ?? 0
|
||||||
|
const requiredLevel = hierarchy[requiredRole] ?? 0
|
||||||
|
return userLevel >= requiredLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [cronFailures, setCronFailures] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDashboardSummary()
|
||||||
|
setCronFailures(res.data.cron_failures_today)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
fetch()
|
||||||
|
const id = setInterval(fetch, 60_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Nav header / logo */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
height: 'var(--header-h)',
|
||||||
|
padding: '0 14px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => onNavigate('/dashboard')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16 }}>🐝</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)' }}>
|
||||||
|
Hive Manage v3
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 8,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section) => {
|
||||||
|
const visible = section.items.filter((item) => hasAccess(user?.role, item.requiredRole))
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.title}>
|
||||||
|
{/* Section title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
padding: '8px 10px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section items */}
|
||||||
|
{visible.map((item) => {
|
||||||
|
const isActive = activePath === item.path
|
||||||
|
const finalBadge =
|
||||||
|
item.path === '/cron'
|
||||||
|
? cronFailures > 0
|
||||||
|
? cronFailures
|
||||||
|
: item.badge
|
||||||
|
: item.badge
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => onNavigate(item.path)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
border: 'none',
|
||||||
|
background: isActive ? 'var(--honey-glow)' : 'transparent',
|
||||||
|
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isActive) {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
e.currentTarget.style.color = 'var(--text)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isActive) {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
e.currentTarget.style.color = 'var(--text-dim)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span style={{ flex: 1 }}>{item.label}</span>
|
||||||
|
{item.isNew && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{finalBadge != null && finalBadge > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--red)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{finalBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: '总览', icon: '📊', path: '/dashboard' },
|
||||||
|
{ label: 'Gateway', icon: '📡', path: '/gateway' },
|
||||||
|
{ label: 'Kanban', icon: '📋', path: '/kanban' },
|
||||||
|
{ label: 'Cron', icon: '⏰', path: '/cron' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface TabBarProps {
|
||||||
|
activePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabBar({ activePath }: TabBarProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 left-0 right-0 flex items-center z-50"
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const isActive = activePath === tab.path
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.path}
|
||||||
|
onClick={() => navigate(tab.path)}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center gap-[2px] cursor-pointer bg-transparent border-none transition-colors duration-100"
|
||||||
|
style={{
|
||||||
|
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20 }}>{tab.icon}</span>
|
||||||
|
<span style={{ fontSize: 11 }}>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
variant?: 'success' | 'danger' | 'warning' | 'info' | 'honey' | 'outline'
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<string, { bg: string; color: string; border?: string }> = {
|
||||||
|
success: { bg: 'rgba(0, 200, 83, 0.15)', color: 'var(--green)' },
|
||||||
|
danger: { bg: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' },
|
||||||
|
warning: { bg: 'rgba(255, 171, 0, 0.15)', color: 'var(--yellow)' },
|
||||||
|
info: { bg: 'rgba(68, 138, 255, 0.15)', color: 'var(--blue)' },
|
||||||
|
honey: { bg: 'rgba(240, 185, 11, 0.15)', color: 'var(--honey)' },
|
||||||
|
outline: { bg: 'transparent', color: 'var(--text-dim)', border: '1px solid var(--border)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({ variant = 'outline', children, className = '' }: BadgeProps) {
|
||||||
|
const s = variantStyles[variant]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold ${className}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: s.bg,
|
||||||
|
color: s.color,
|
||||||
|
border: s.border ?? 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'danger' | 'outline' | 'ghost'
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
loading?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
type = 'button',
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center justify-center rounded-lg font-semibold cursor-pointer border transition-all duration-150'
|
||||||
|
|
||||||
|
const variantStyles: Record<string, string> = {
|
||||||
|
primary:
|
||||||
|
'bg-[var(--honey)] text-black border-[var(--honey)] font-semibold hover:brightness-110',
|
||||||
|
danger:
|
||||||
|
'bg-transparent text-[var(--red)] border-[var(--red)] hover:bg-[var(--red)] hover:text-white',
|
||||||
|
outline:
|
||||||
|
'bg-transparent text-[var(--text-dim)] border-[var(--border)] hover:bg-[var(--bg-hover)]',
|
||||||
|
ghost:
|
||||||
|
'bg-transparent text-[var(--text-dim)] border-transparent hover:bg-[var(--bg-hover)]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<string, string> = {
|
||||||
|
sm: 'px-[10px] py-[4px] text-[12px]',
|
||||||
|
md: 'px-[14px] py-[7px] text-[13px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimClass =
|
||||||
|
disabled || loading ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${base} ${variantStyles[variant]} ${sizeStyles[size]} ${dimClass} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<span className="absolute inline-block w-[14px] h-[14px] border-2 border-transparent border-t-current rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
<span className={loading ? 'text-transparent' : ''}>{children}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface CardProps {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
valueColor?: 'green' | 'red' | 'yellow' | 'blue'
|
||||||
|
change?: string
|
||||||
|
changeDirection?: 'up' | 'down' | 'neutral'
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueColorMap: Record<string, string> = {
|
||||||
|
green: 'var(--green)',
|
||||||
|
red: 'var(--red)',
|
||||||
|
yellow: 'var(--yellow)',
|
||||||
|
blue: 'var(--blue)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionIcon: Record<string, string> = {
|
||||||
|
up: '↑',
|
||||||
|
down: '↓',
|
||||||
|
neutral: '→',
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionColor: Record<string, string> = {
|
||||||
|
up: 'var(--green)',
|
||||||
|
down: 'var(--red)',
|
||||||
|
neutral: 'var(--text-dim)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor,
|
||||||
|
change,
|
||||||
|
changeDirection,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
}: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`bg-[var(--bg-card)] rounded-[var(--radius)] p-4 ${onClick ? 'cursor-pointer hover:bg-[var(--bg-hover)]' : ''} transition-colors duration-150 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="text-[12px] uppercase tracking-wider text-[var(--text-dim)]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[28px] font-bold leading-tight mt-1"
|
||||||
|
style={{ color: valueColor ? valueColorMap[valueColor] : undefined }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{change !== undefined && changeDirection && (
|
||||||
|
<div
|
||||||
|
className="text-[12px] mt-1 flex items-center gap-1"
|
||||||
|
style={{ color: directionColor[changeDirection] }}
|
||||||
|
>
|
||||||
|
<span>{directionIcon[changeDirection]}</span>
|
||||||
|
<span>{change}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useCallback, useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export interface DrawerProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
width?: string | number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Drawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
width = 480,
|
||||||
|
className = '',
|
||||||
|
}: DrawerProps) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
requestAnimationFrame(() => setVisible(true))
|
||||||
|
} else {
|
||||||
|
setVisible(false)
|
||||||
|
const timer = setTimeout(() => setMounted(false), 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [open, handleKeyDown])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999]">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 transition-opacity duration-250 ${
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 right-0 h-full bg-[var(--bg-nav)] shadow-[var(--shadow-lg)] flex flex-col transition-transform duration-[250ms] ease-in-out ${className}`}
|
||||||
|
style={{
|
||||||
|
width: typeof width === 'number' ? `${width}px` : width,
|
||||||
|
transform: visible ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)] shrink-0">
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="px-5 py-4 border-t border-[var(--border)] shrink-0">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useCallback, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
confirmText?: string
|
||||||
|
onConfirm?: () => void
|
||||||
|
confirmDanger?: boolean
|
||||||
|
width?: string | number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
confirmText,
|
||||||
|
onConfirm,
|
||||||
|
confirmDanger,
|
||||||
|
width = 500,
|
||||||
|
className = '',
|
||||||
|
}: ModalProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [open, handleKeyDown])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-[var(--bg-nav)] rounded-[8px] shadow-[var(--shadow-modal)] w-full mx-4 flex flex-col max-h-[85vh] ${className}`}
|
||||||
|
style={{ maxWidth: typeof width === 'number' ? `${width}px` : width }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
{(footer || onConfirm) && (
|
||||||
|
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-[var(--border)]">
|
||||||
|
{footer || (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmDanger ? 'danger' : 'primary'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmText || '确认'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"
|
||||||
|
|
||||||
|
export interface SearchGroup {
|
||||||
|
label: string
|
||||||
|
items: { value: string; label: ReactNode }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchBoxProps {
|
||||||
|
onSearch: (query: string) => SearchGroup[] | Promise<SearchGroup[]>
|
||||||
|
placeholder?: string
|
||||||
|
debounceMs?: number
|
||||||
|
className?: string
|
||||||
|
onItemClick?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBox({
|
||||||
|
onSearch,
|
||||||
|
placeholder = "搜索...",
|
||||||
|
debounceMs = 300,
|
||||||
|
className = "",
|
||||||
|
onItemClick,
|
||||||
|
}: SearchBoxProps) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [groups, setGroups] = useState<SearchGroup[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [activeIdx, setActiveIdx] = useState(-1)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
|
||||||
|
const flatItems = groups.flatMap((g) => g.items)
|
||||||
|
|
||||||
|
const doSearch = useCallback(
|
||||||
|
async (q: string) => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
setGroups([])
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await onSearch(q)
|
||||||
|
setGroups(result)
|
||||||
|
setOpen(result.length > 0 && result.some((g) => g.items.length > 0))
|
||||||
|
setActiveIdx(-1)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSearch],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => doSearch(query), debounceMs)
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
|
||||||
|
}, [query, debounceMs, doSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setActiveIdx(-1)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
panelRef.current &&
|
||||||
|
!panelRef.current.contains(e.target as Node) &&
|
||||||
|
inputRef.current &&
|
||||||
|
!inputRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!open || flatItems.length === 0) return
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0))
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1))
|
||||||
|
} else if (e.key === "Enter" && activeIdx >= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setQuery(String(flatItems[activeIdx].label))
|
||||||
|
setOpen(false)
|
||||||
|
onItemClick?.(flatItems[activeIdx].value)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} style={{ width: "420px", maxWidth: "100%" }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => { if (groups.length > 0) setOpen(true) }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full h-[36px] px-3 text-[13px] rounded-lg border transition-colors duration-150 outline-none bg-[var(--bg-card)] text-[var(--text)] placeholder:text-[var(--text-muted)]"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
onFocusCapture={(e) => {
|
||||||
|
;(e.target as HTMLInputElement).style.borderColor = "var(--honey)"
|
||||||
|
}}
|
||||||
|
onBlurCapture={(e) => {
|
||||||
|
;(e.target as HTMLInputElement).style.borderColor = "var(--border)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 w-[14px] h-[14px] border-2 border-[var(--border)] border-t-[var(--honey)] rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{open && !loading && (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="absolute top-full left-0 right-0 mt-1 rounded-lg shadow-[var(--shadow-lg)] z-50 max-h-[320px] overflow-y-auto"
|
||||||
|
style={{ backgroundColor: "var(--bg-elevated)" }}
|
||||||
|
>
|
||||||
|
{groups.map((group, gi) => {
|
||||||
|
const startIdx = cursor
|
||||||
|
cursor += group.items.length
|
||||||
|
return (
|
||||||
|
<div key={gi}>
|
||||||
|
<div className="px-3 py-2 text-[11px] font-semibold uppercase text-[var(--text-dim)]">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
{group.items.map((item, ii) => {
|
||||||
|
const idx = startIdx + ii
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={`px-3 py-[7px] text-[13px] cursor-pointer transition-colors duration-100 ${
|
||||||
|
idx === activeIdx ? "bg-[var(--bg-hover)]" : ""
|
||||||
|
}`}
|
||||||
|
style={{ color: "var(--text)" }}
|
||||||
|
onMouseEnter={() => setActiveIdx(idx)}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setQuery(String(item.label))
|
||||||
|
setOpen(false)
|
||||||
|
onItemClick?.(item.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
const styleId = "ui-skeleton-keyframes"
|
||||||
|
if (typeof document !== "undefined" && !document.getElementById(styleId)) {
|
||||||
|
const style = document.createElement("style")
|
||||||
|
style.id = styleId
|
||||||
|
style.textContent = `@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkeletonProps {
|
||||||
|
variant?: "card" | "table" | "text"
|
||||||
|
count?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bar({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-[var(--radius-sm)] bg-[var(--bg-hover)] ${className}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%)",
|
||||||
|
backgroundSize: "200% 100%",
|
||||||
|
animation: "shimmer 1500ms infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--bg-card)] rounded-[var(--radius)] p-4 space-y-3">
|
||||||
|
<Bar className="h-3 w-1/3" />
|
||||||
|
<Bar className="h-8 w-1/2" />
|
||||||
|
<Bar className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ rows }: { rows: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<Bar key={i} className="h-5 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Bar key={i} className={`h-3 ${i % 2 === 0 ? "w-3/4" : "w-1/2"}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Skeleton({
|
||||||
|
variant = "text",
|
||||||
|
count = 3,
|
||||||
|
className = "",
|
||||||
|
}: SkeletonProps) {
|
||||||
|
if (variant === "card")
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<CardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (variant === "table") return <TableSkeleton rows={count} />
|
||||||
|
return <TextSkeleton count={count} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface TableColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
width?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableProps {
|
||||||
|
columns: TableColumn[]
|
||||||
|
data: Record<string, ReactNode>[]
|
||||||
|
rowKey: string
|
||||||
|
onRowClick?: (record: Record<string, ReactNode>) => void
|
||||||
|
rowClassName?: (record: Record<string, ReactNode>) => string
|
||||||
|
emptyText?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Table({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
rowKey,
|
||||||
|
onRowClick,
|
||||||
|
rowClassName,
|
||||||
|
emptyText = '暂无数据',
|
||||||
|
className = '',
|
||||||
|
}: TableProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full overflow-auto rounded-[var(--radius)] border border-[var(--border)] ${className}`}
|
||||||
|
>
|
||||||
|
<table className="w-full border-collapse text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="sticky top-0 z-10 bg-[var(--bg-nav)] text-[12px] uppercase text-[var(--text-dim)] font-semibold text-left px-4 h-[44px] whitespace-nowrap"
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center text-[var(--text-dim)] py-8"
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.map((record) => {
|
||||||
|
const extra = rowClassName?.(record) ?? ''
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={String(record[rowKey])}
|
||||||
|
onClick={() => onRowClick?.(record)}
|
||||||
|
className={`h-[44px] border-t border-[var(--border)] transition-colors duration-100 ${
|
||||||
|
onRowClick ? 'cursor-pointer' : ''
|
||||||
|
} hover:bg-[var(--bg-hover)] ${
|
||||||
|
extra.includes('row-danger')
|
||||||
|
? 'bg-[var(--bg-error)] hover:bg-[var(--bg-error)]'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className="px-4">
|
||||||
|
{record[col.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface TimelineEvent {
|
||||||
|
time: string
|
||||||
|
type: string
|
||||||
|
tooltip?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineProps {
|
||||||
|
events: TimelineEvent[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultColors: Record<string, string> = {
|
||||||
|
green: "var(--green)",
|
||||||
|
red: "var(--red)",
|
||||||
|
orange: "var(--orange)",
|
||||||
|
blue: "var(--blue)",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline({ events, className = "" }: TimelineProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-0 ${className}`}>
|
||||||
|
{events.map((evt, i) => {
|
||||||
|
const dotColor = evt.color || defaultColors[evt.type] || "var(--text-dim)"
|
||||||
|
const isLast = i === events.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center flex-1 min-w-0">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-[8px] h-[8px] rounded-full transition-transform duration-150 hover:-translate-y-[2px] group relative"
|
||||||
|
style={{ backgroundColor: dotColor }}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-[11px] whitespace-nowrap rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"
|
||||||
|
style={{ backgroundColor: "var(--bg-elevated)", color: "var(--text)" }}>
|
||||||
|
<div>{evt.time}</div>
|
||||||
|
{evt.tooltip && <div style={{ color: "var(--text-dim)" }}>{evt.tooltip}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
className="flex-1 h-[2px] mx-1 self-start mt-[3px]"
|
||||||
|
style={{ backgroundColor: "var(--border)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect, useCallback, type ReactNode } from "react"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "warn" | "info"
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number
|
||||||
|
type: ToastType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (item: ToastItem) => void
|
||||||
|
|
||||||
|
let emit: Listener | null = null
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
function ToastStack() {
|
||||||
|
const [list, setList] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
emit = (item: ToastItem) => {
|
||||||
|
setList((prev) => {
|
||||||
|
const next = [...prev, item]
|
||||||
|
return next.length > 3 ? next.slice(-3) : next
|
||||||
|
})
|
||||||
|
if (item.type === "success") {
|
||||||
|
setTimeout(() => dismiss(item.id), 3000)
|
||||||
|
} else if (item.type === "error") {
|
||||||
|
setTimeout(() => dismiss(item.id), 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => { emit = null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setList((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (list.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed top-4 left-1/2 -translate-x-1/2 z-[99999] flex flex-col gap-2 w-full pointer-events-none"
|
||||||
|
style={{ maxWidth: "480px" }}
|
||||||
|
>
|
||||||
|
{list.map((item) => {
|
||||||
|
const bgMap: Record<ToastType, string> = {
|
||||||
|
success: "var(--bg-success)",
|
||||||
|
error: "var(--bg-error)",
|
||||||
|
warn: "var(--bg-warn)",
|
||||||
|
info: "var(--bg-info)",
|
||||||
|
}
|
||||||
|
const colorMap: Record<ToastType, string> = {
|
||||||
|
success: "var(--green)",
|
||||||
|
error: "var(--red)",
|
||||||
|
warn: "var(--yellow)",
|
||||||
|
info: "var(--blue)",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-[var(--radius)] shadow-[var(--shadow)] text-[13px] font-medium transition-all duration-250 animate-in"
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgMap[item.type],
|
||||||
|
color: colorMap[item.type],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{item.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(item.id)}
|
||||||
|
className="bg-transparent border-none cursor-pointer text-inherit opacity-60 hover:opacity-100 text-[14px] leading-none p-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = false
|
||||||
|
function ensureMounted() {
|
||||||
|
if (mounted) return
|
||||||
|
mounted = true
|
||||||
|
const el = document.createElement("div")
|
||||||
|
el.id = "ui-toast-root"
|
||||||
|
document.body.appendChild(el)
|
||||||
|
createRoot(el).render(<ToastStack />)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(type: ToastType, message: string) {
|
||||||
|
ensureMounted()
|
||||||
|
const id = ++seq
|
||||||
|
const tryEmit = () => {
|
||||||
|
if (emit) {
|
||||||
|
emit({ id, type, message })
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => tryEmit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tryEmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
success: (msg: string) => toast("success", msg),
|
||||||
|
error: (msg: string) => toast("error", msg),
|
||||||
|
warn: (msg: string) => toast("warn", msg),
|
||||||
|
info: (msg: string) => toast("info", msg),
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface ToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
}: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`inline-flex items-center gap-[8px] text-[13px] ${
|
||||||
|
disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && !disabled) {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(!checked)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative inline-block w-[44px] h-[24px] rounded-[12px] transition-colors duration-200"
|
||||||
|
style={{ backgroundColor: checked ? "var(--honey)" : "var(--bg-hover)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-[2px] left-[2px] w-[20px] h-[20px] rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: checked ? "translateX(20px)" : "translateX(0)" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[var(--text)] select-none">{label}</span>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import * as authApi from '../api/auth'
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
user: AuthUser | null
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeToken(token: string): AuthUser | null {
|
||||||
|
try {
|
||||||
|
const payloadBase64 = token.split('.')[1]
|
||||||
|
const payload = JSON.parse(atob(payloadBase64))
|
||||||
|
return { id: payload.sub, role: payload.role }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem('access_token'))
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
|
const t = localStorage.getItem('access_token')
|
||||||
|
return t ? decodeToken(t) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const res = await authApi.login(username, password)
|
||||||
|
const t: string = res.data.access_token
|
||||||
|
localStorage.setItem('access_token', t)
|
||||||
|
setToken(t)
|
||||||
|
setUser(decodeToken(t))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
setToken(null)
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: !!token && !!user,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextType {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export function usePolling(
|
||||||
|
callback: () => void,
|
||||||
|
intervalMs: number,
|
||||||
|
enabled: boolean = true,
|
||||||
|
) {
|
||||||
|
const savedCallback = useRef(callback)
|
||||||
|
useEffect(() => {
|
||||||
|
savedCallback.current = callback
|
||||||
|
}, [callback])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
savedCallback.current()
|
||||||
|
const id = setInterval(() => savedCallback.current(), intervalMs)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [intervalMs, enabled])
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
return (localStorage.getItem('hive-theme') as Theme) || 'dark'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.setAttribute('data-theme', theme)
|
||||||
|
localStorage.setItem('hive-theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggle = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
|
||||||
|
return { theme, setTheme, toggle }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import './styles/tokens.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode><App /></React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { getAuditLogs } from '../api/audit'
|
||||||
|
import Table, { type TableColumn } from '../components/ui/Table'
|
||||||
|
import Button from '../components/ui/Button'
|
||||||
|
import toast from '../components/ui/Toast'
|
||||||
|
|
||||||
|
interface AuditRecord {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
action: string
|
||||||
|
target: string
|
||||||
|
detail: string | null
|
||||||
|
ip_address: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_OPTIONS = [
|
||||||
|
'',
|
||||||
|
'keys.batch_overwrite',
|
||||||
|
'gateway.toggle',
|
||||||
|
'cron.trigger',
|
||||||
|
'user.login',
|
||||||
|
'user.update_role',
|
||||||
|
'user.approve',
|
||||||
|
'keys.generate',
|
||||||
|
'kaban.create',
|
||||||
|
'kaban.update',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AuditLogPage() {
|
||||||
|
const [logs, setLogs] = useState<AuditRecord[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [actionFilter, setActionFilter] = useState('')
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async (p: number, action?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page: p, page_size: 20 }
|
||||||
|
if (action) params.action = action
|
||||||
|
const res = await getAuditLogs(params)
|
||||||
|
const data: AuditRecord[] = res.data
|
||||||
|
setLogs(data)
|
||||||
|
setHasMore(data.length === 20)
|
||||||
|
} catch {
|
||||||
|
toast.error('加载审计日志失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs(page, actionFilter)
|
||||||
|
}, [page, fetchLogs])
|
||||||
|
|
||||||
|
const handleFilterChange = (val: string) => {
|
||||||
|
setActionFilter(val)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ key: 'created_at', label: '时间', width: 180 },
|
||||||
|
{ key: 'username', label: '用户', width: 100 },
|
||||||
|
{ key: 'action', label: '操作', width: 160 },
|
||||||
|
{ key: 'target', label: '目标', width: 200 },
|
||||||
|
{ key: 'detail', label: '详情' },
|
||||||
|
{ key: 'ip_address', label: 'IP', width: 140 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const data = logs.map((l) => ({
|
||||||
|
created_at: new Date(l.created_at).toLocaleString('zh-CN'),
|
||||||
|
username: l.username,
|
||||||
|
action: l.action,
|
||||||
|
target: l.target,
|
||||||
|
detail: l.detail ?? '-',
|
||||||
|
ip_address: l.ip_address ?? '-',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2>
|
||||||
|
<p style={{ color: 'var(--text-dim)', marginTop: 4, marginBottom: 16, fontSize: 13 }}>
|
||||||
|
操作审计记录
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<label className="text-[12px] font-semibold" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
操作筛选:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => handleFilterChange(e.target.value)}
|
||||||
|
className="px-3 py-[7px] rounded-[var(--radius-sm)] text-[13px] outline-none"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部</option>
|
||||||
|
{ACTION_OPTIONS.filter(Boolean).map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table columns={columns} data={data} rowKey="created_at" />
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
|
第 {page} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!hasMore || loading}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import Table, { type TableColumn } from '../components/ui/Table'
|
||||||
|
import Badge from '../components/ui/Badge'
|
||||||
|
import Button from '../components/ui/Button'
|
||||||
|
import Modal from '../components/ui/Modal'
|
||||||
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
|
import toast from '../components/ui/Toast'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCronJobs,
|
||||||
|
getCronLogs,
|
||||||
|
runCronJob,
|
||||||
|
pauseCronJob,
|
||||||
|
resumeCronJob,
|
||||||
|
type CronJob,
|
||||||
|
type CronLogItem,
|
||||||
|
} from '../api/cron'
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
const isToday = (iso: string | null) => {
|
||||||
|
if (!iso) return false
|
||||||
|
return iso.startsWith(new Date().toISOString().slice(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAct = (role: string | undefined) => role === 'owner' || role === 'operator'
|
||||||
|
|
||||||
|
/* ---------- component ---------- */
|
||||||
|
|
||||||
|
export default function CronPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
/* data */
|
||||||
|
const [jobs, setJobs] = useState<CronJob[]>([])
|
||||||
|
const [hasFailures, setHasFailures] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* filters */
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [profileFilter, setProfileFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
/* log modal */
|
||||||
|
const [logJob, setLogJob] = useState<CronJob | null>(null)
|
||||||
|
const [logs, setLogs] = useState<CronLogItem[]>([])
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false)
|
||||||
|
|
||||||
|
/* confirm modal */
|
||||||
|
const [confirmJob, setConfirmJob] = useState<CronJob | null>(null)
|
||||||
|
const [confirmAction, setConfirmAction] = useState<string>('')
|
||||||
|
const [acting, setActing] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* ---------- fetch ---------- */
|
||||||
|
|
||||||
|
const fetchJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
if (profileFilter !== 'all') params.profile = profileFilter
|
||||||
|
const res = await getCronJobs(params)
|
||||||
|
setJobs(res.data.jobs)
|
||||||
|
setHasFailures(res.data.has_failures)
|
||||||
|
setError(null)
|
||||||
|
} catch {
|
||||||
|
setError('加载 Cron 列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [statusFilter, profileFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchJobs()
|
||||||
|
const id = setInterval(fetchJobs, 30_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [fetchJobs])
|
||||||
|
|
||||||
|
/* ---------- log modal ---------- */
|
||||||
|
|
||||||
|
const openLog = async (job: CronJob) => {
|
||||||
|
setLogJob(job)
|
||||||
|
setLogsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getCronLogs(job.id)
|
||||||
|
setLogs(res.data.logs)
|
||||||
|
} catch {
|
||||||
|
setLogs([])
|
||||||
|
toast.error('加载日志失败')
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- actions ---------- */
|
||||||
|
|
||||||
|
const handleRun = (job: CronJob) => {
|
||||||
|
setConfirmJob(job)
|
||||||
|
setConfirmAction('run')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePause = async (job: CronJob) => {
|
||||||
|
setActing(job.id)
|
||||||
|
try {
|
||||||
|
await pauseCronJob(job.id)
|
||||||
|
toast.success(`已暂停 ${job.job_name}`)
|
||||||
|
fetchJobs()
|
||||||
|
} catch {
|
||||||
|
toast.error(`暂停 ${job.job_name} 失败`)
|
||||||
|
} finally {
|
||||||
|
setActing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResume = async (job: CronJob) => {
|
||||||
|
setActing(job.id)
|
||||||
|
try {
|
||||||
|
await resumeCronJob(job.id)
|
||||||
|
toast.success(`已恢复 ${job.job_name}`)
|
||||||
|
fetchJobs()
|
||||||
|
} catch {
|
||||||
|
toast.error(`恢复 ${job.job_name} 失败`)
|
||||||
|
} finally {
|
||||||
|
setActing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRun = async () => {
|
||||||
|
if (!confirmJob) return
|
||||||
|
setActing(confirmJob.id)
|
||||||
|
setConfirmJob(null)
|
||||||
|
try {
|
||||||
|
await runCronJob(confirmJob.id)
|
||||||
|
toast.success(`已触发 ${confirmJob.job_name}`)
|
||||||
|
fetchJobs()
|
||||||
|
} catch {
|
||||||
|
toast.error(`触发 ${confirmJob.job_name} 失败`)
|
||||||
|
} finally {
|
||||||
|
setActing(null)
|
||||||
|
setConfirmAction('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- profile options ---------- */
|
||||||
|
|
||||||
|
const profileOptions = useMemo(() => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const list: { value: string; label: string }[] = []
|
||||||
|
for (const j of jobs) {
|
||||||
|
const p = j.system ? 'system' : j.profile
|
||||||
|
if (!seen.has(p)) {
|
||||||
|
seen.add(p)
|
||||||
|
list.push({ value: p, label: j.system ? '系统 Cron' : p })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
return list
|
||||||
|
}, [jobs])
|
||||||
|
|
||||||
|
/* ---------- derived ---------- */
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
if (statusFilter !== 'today-fail') return jobs
|
||||||
|
return jobs.filter((j) => isToday(j.last_run) && j.last_run_success === false)
|
||||||
|
}, [jobs, statusFilter])
|
||||||
|
|
||||||
|
/* ---------- render: loading ---------- */
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
Cron 任务管理
|
||||||
|
</h2>
|
||||||
|
<Skeleton variant="card" count={4} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render: error ---------- */
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
Cron 任务管理
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center py-12 gap-3"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 32, color: 'var(--red)' }}>⚠</span>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button variant="outline" onClick={fetchJobs}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render ---------- */
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ key: 'job_name', label: 'Job 名' },
|
||||||
|
{ key: 'profile', label: 'Profile' },
|
||||||
|
{ key: 'schedule', label: 'Cron 表达式' },
|
||||||
|
{ key: 'status', label: '状态' },
|
||||||
|
{ key: 'last_run', label: '上次运行' },
|
||||||
|
{ key: 'success_rate', label: '成功率' },
|
||||||
|
{ key: 'actions', label: '操作' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dangerKeys = useMemo(() => {
|
||||||
|
const s = new Set<string>()
|
||||||
|
for (const j of filteredJobs) {
|
||||||
|
if (j.consecutive_failures >= 2) s.add(j.id)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}, [filteredJobs])
|
||||||
|
|
||||||
|
const rowClassName = (record: Record<string, unknown>) => {
|
||||||
|
if (dangerKeys.has(record.key as string)) return 'row-danger'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData = filteredJobs.map((j) => {
|
||||||
|
const lastRunStr = j.last_run
|
||||||
|
? (() => {
|
||||||
|
const d = new Date(j.last_run)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
})()
|
||||||
|
: '-'
|
||||||
|
|
||||||
|
const successPct = `${j.success_rate}%`
|
||||||
|
const successRateStyle =
|
||||||
|
j.success_rate < 80 ? { color: 'var(--yellow)', fontWeight: 600 } : undefined
|
||||||
|
|
||||||
|
const rowBg = j.system ? { background: 'var(--bg-hover)' } : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: j.id,
|
||||||
|
job_name: (
|
||||||
|
<span className="flex items-center gap-2" style={rowBg}>
|
||||||
|
{j.job_name}
|
||||||
|
{j.system && (
|
||||||
|
<Badge variant="outline" className="!text-[10px]">system</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
profile: (
|
||||||
|
<span style={rowBg}>
|
||||||
|
{j.system ? (
|
||||||
|
<Badge variant="info" className="!text-[10px]">系统 Cron</Badge>
|
||||||
|
) : (
|
||||||
|
j.profile
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
schedule: (
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, ...rowBg }}>
|
||||||
|
{j.schedule}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
status: (
|
||||||
|
<span style={rowBg}>
|
||||||
|
<Badge variant={j.status === 'active' ? 'success' : 'warning'}>
|
||||||
|
{j.status === 'active' ? '活跃' : '暂停'}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
last_run: (
|
||||||
|
<span className="flex items-center gap-1" style={rowBg}>
|
||||||
|
{lastRunStr}
|
||||||
|
{j.last_run_success === true && <span style={{ fontSize: 12 }}>✅</span>}
|
||||||
|
{j.last_run_success === false && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--red)' }}>❌</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
success_rate: (
|
||||||
|
<span style={{ ...successRateStyle, ...rowBg }}>{successPct}</span>
|
||||||
|
),
|
||||||
|
actions: (
|
||||||
|
<span className="flex items-center gap-1" style={rowBg}>
|
||||||
|
{canAct(user?.role) && (
|
||||||
|
<>
|
||||||
|
{j.status === 'active' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
loading={acting === j.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handlePause(j) }}
|
||||||
|
>
|
||||||
|
暂停
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
loading={acting === j.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleResume(j) }}
|
||||||
|
>
|
||||||
|
恢复
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
loading={acting === j.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleRun(j) }}
|
||||||
|
>
|
||||||
|
运行
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{j.has_logs && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openLog(j) }}
|
||||||
|
>
|
||||||
|
📋 日志
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTabs = [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'active', label: '活跃' },
|
||||||
|
{ value: 'paused', label: '暂停' },
|
||||||
|
{ value: 'today-fail', label: '今日失败' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
Cron 任务管理
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
{statusTabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
onClick={() => setStatusFilter(t.value)}
|
||||||
|
className="cursor-pointer bg-transparent border-none text-[13px] font-medium px-3 py-1.5 rounded-[var(--radius-sm)] transition-colors"
|
||||||
|
style={{
|
||||||
|
color: statusFilter === t.value ? 'var(--honey)' : 'var(--text-dim)',
|
||||||
|
background: statusFilter === t.value ? 'var(--honey-glow)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="w-[1px] h-5 bg-[var(--border)] mx-1" />
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={profileFilter}
|
||||||
|
onChange={(e) => setProfileFilter(e.target.value)}
|
||||||
|
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
<option value="all">全部 Profile</option>
|
||||||
|
{profileOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="system">仅系统 Cron</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{tableData.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center py-12"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 32, marginBottom: 8 }}>📭</span>
|
||||||
|
<p>暂无 Cron 任务</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={tableData}
|
||||||
|
rowKey="key"
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log modal */}
|
||||||
|
<Modal
|
||||||
|
open={!!logJob}
|
||||||
|
onClose={() => setLogJob(null)}
|
||||||
|
title={logJob ? `${logJob.job_name} — 最近运行日志` : ''}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{logsLoading ? (
|
||||||
|
<Skeleton variant="card" count={3} />
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-dim)', textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
暂无日志
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{logs.map((log, i) => {
|
||||||
|
const isErr = log.status === 'failure'
|
||||||
|
const levelColor = (msg: string) => {
|
||||||
|
if (msg.startsWith('ERROR')) return 'var(--red)'
|
||||||
|
if (msg.startsWith('WARN')) return 'var(--yellow)'
|
||||||
|
return 'var(--green)'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-3 rounded-[var(--radius-sm)] text-[13px] leading-relaxed"
|
||||||
|
style={{
|
||||||
|
background: isErr ? 'var(--bg-error)' : 'var(--bg-card)',
|
||||||
|
borderLeft: `3px solid ${isErr ? 'var(--red)' : 'var(--border)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}>
|
||||||
|
{log.time}
|
||||||
|
</span>
|
||||||
|
<span>{log.status === 'success' ? '✅' : '❌'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: levelColor(log.message), whiteSpace: 'pre-wrap' }}>
|
||||||
|
{log.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Run confirmation modal */}
|
||||||
|
<Modal
|
||||||
|
open={!!confirmJob}
|
||||||
|
onClose={() => { setConfirmJob(null); setConfirmAction('') }}
|
||||||
|
title="确认执行"
|
||||||
|
confirmText="确认运行"
|
||||||
|
confirmDanger
|
||||||
|
onConfirm={confirmRun}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--text-dim)' }}>
|
||||||
|
确认要手动触发 <strong style={{ color: 'var(--text)' }}>{confirmJob?.job_name}</strong> 执行吗?
|
||||||
|
</p>
|
||||||
|
{confirmJob && (
|
||||||
|
<div className="mt-3 text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<div>Profile: {confirmJob.system ? '系统 Cron' : confirmJob.profile}</div>
|
||||||
|
<div>Cron 表达式: <code style={{ fontFamily: 'var(--font-mono)' }}>{confirmJob.schedule}</code></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDashboardSummary,
|
||||||
|
getHealthDistribution,
|
||||||
|
getGatewayTrend,
|
||||||
|
getKanbanSummary,
|
||||||
|
getCronRecent,
|
||||||
|
type SummaryData,
|
||||||
|
type HealthDistribution,
|
||||||
|
type GatewayTrendItem,
|
||||||
|
type KanbanSummaryItem,
|
||||||
|
type CronRecentItem,
|
||||||
|
} from '../api/dashboard'
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (diff < 5) return '刚刚'
|
||||||
|
if (diff < 60) return diff + ' 秒前'
|
||||||
|
const m = Math.floor(diff / 60)
|
||||||
|
if (m < 60) return m + ' 分钟前'
|
||||||
|
return Math.floor(m / 60) + ' 小时前'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentEvent {
|
||||||
|
time: string
|
||||||
|
profile: string
|
||||||
|
type: string
|
||||||
|
typeClass: string
|
||||||
|
summary: string
|
||||||
|
status: string
|
||||||
|
statusClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_EVENTS: RecentEvent[] = [
|
||||||
|
{ time: '21:13', profile: 'hindsight', type: 'Cron 失败', typeClass: 'badge-offline', summary: 'Hindsight index 更新超时', status: '待处理', statusClass: 'badge-warn' },
|
||||||
|
{ time: '18:45', profile: 'monitor', type: '心跳超时', typeClass: 'badge-warn', summary: 'Gateway 心跳 3 次无响应', status: '待处理', statusClass: 'badge-warn' },
|
||||||
|
{ time: '14:20', profile: 'feishu-gateway', type: '飞书断连', typeClass: 'badge-warn', summary: 'WebSocket 断开 8 分钟后恢复', status: '已恢复', statusClass: 'badge-a' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ---------- Stat card ---------- */
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label, value, valueColor, change, changeDirection,
|
||||||
|
}: {
|
||||||
|
label: string; value: string | number; valueColor?: string
|
||||||
|
change?: string; changeDirection?: 'up' | 'down'
|
||||||
|
}) {
|
||||||
|
const iconColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: valueColor ? `var(--${valueColor})` : 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{change && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
color: changeDirection ? iconColor : 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{changeDirection === 'up' && <ArrowUp size={12} />}
|
||||||
|
{changeDirection === 'down' && <ArrowDown size={12} />}
|
||||||
|
{change}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Health stacked bar chart (horizontal) ---------- */
|
||||||
|
|
||||||
|
const GRADE_META = [
|
||||||
|
{ key: 'A', color: 'var(--green)' },
|
||||||
|
{ key: 'B', color: 'var(--blue)' },
|
||||||
|
{ key: 'C', color: 'var(--yellow)' },
|
||||||
|
{ key: 'D', color: 'var(--red)' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function HealthStackedBar({ dist, onBarClick }: { dist: HealthDistribution; onBarClick: (g: string) => void }) {
|
||||||
|
if (!dist || dist.total === 0) {
|
||||||
|
return <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
||||||
|
}
|
||||||
|
const total = dist.A + dist.B + dist.C + dist.D
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{GRADE_META.map((meta) => {
|
||||||
|
const val = dist[meta.key as keyof HealthDistribution] as number
|
||||||
|
if (val === 0) return null
|
||||||
|
const pct = total > 0 ? (val / total) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={meta.key}
|
||||||
|
onClick={() => onBarClick(meta.key)}
|
||||||
|
style={{
|
||||||
|
width: pct + '%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: meta.color,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
title={meta.key + '级: ' + val}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 20, fontSize: 12 }}>
|
||||||
|
{GRADE_META.map((meta) => {
|
||||||
|
const val = dist[meta.key as keyof HealthDistribution] as number
|
||||||
|
return (
|
||||||
|
<div key={meta.key} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ color: meta.color }}>●</span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>{meta.key} {val}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- Gateway 48-bar timeline ---------- */
|
||||||
|
|
||||||
|
function barColor(item: GatewayTrendItem): string {
|
||||||
|
if (item.heartbeat_timeouts > 0) return 'var(--red)'
|
||||||
|
if (item.feishu_disconnects > 0) return 'var(--orange)'
|
||||||
|
return 'var(--green)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
|
||||||
|
const maxIssues = Math.max(1, ...trend.map(
|
||||||
|
i => i.heartbeat_timeouts + i.feishu_disconnects + i.restarts,
|
||||||
|
))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trend.map((item, i) => {
|
||||||
|
const col = barColor(item)
|
||||||
|
const total = item.heartbeat_timeouts + item.feishu_disconnects + item.restarts
|
||||||
|
const pct = total / maxIssues
|
||||||
|
const height = total === 0 ? 3 : Math.max(3, Math.round(pct * 36))
|
||||||
|
const details: string[] = []
|
||||||
|
if (item.heartbeat_timeouts > 0) details.push('心跳超时 ' + item.heartbeat_timeouts)
|
||||||
|
if (item.feishu_disconnects > 0) details.push('飞书断连 ' + item.feishu_disconnects)
|
||||||
|
if (item.restarts > 0) details.push('重启 ' + item.restarts)
|
||||||
|
const tip = details.length > 0 ? details.join(' / ') : '正常运行'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
title={item.hour + ': ' + tip}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height,
|
||||||
|
borderRadius: '2px 2px 0 0',
|
||||||
|
minWidth: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: col,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span><span style={{ color: 'var(--green)' }}>●</span> 正常运行</span>
|
||||||
|
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
|
||||||
|
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Badge helpers for events table ---------- */
|
||||||
|
|
||||||
|
function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }) {
|
||||||
|
const styleMap: Record<string, React.CSSProperties> = {
|
||||||
|
'badge-offline': { background: 'rgba(255,61,61,.15)', color: 'var(--red)' },
|
||||||
|
'badge-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
|
||||||
|
'badge-a': { background: 'rgba(0,200,83,.15)', color: 'var(--green)' },
|
||||||
|
}
|
||||||
|
const baseStyle: React.CSSProperties = styleMap[cls] || { background: 'var(--bg-hover)', color: 'var(--text-dim)' }
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
...baseStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- main page ---------- */
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState<SummaryData | null>(null)
|
||||||
|
const [health, setHealth] = useState<HealthDistribution | null>(null)
|
||||||
|
const [trend, setTrend] = useState<GatewayTrendItem[]>([])
|
||||||
|
const [kanban, setKanban] = useState<KanbanSummaryItem[]>([])
|
||||||
|
const [cron, setCron] = useState<CronRecentItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await Promise.all([
|
||||||
|
getDashboardSummary(),
|
||||||
|
getHealthDistribution(),
|
||||||
|
getGatewayTrend(),
|
||||||
|
getKanbanSummary(),
|
||||||
|
getCronRecent(),
|
||||||
|
])
|
||||||
|
setSummary(res[0].data)
|
||||||
|
setHealth(res[1].data)
|
||||||
|
setTrend(res[2].data)
|
||||||
|
setKanban(res[3].data)
|
||||||
|
setCron(res[4].data)
|
||||||
|
} catch {
|
||||||
|
setError('加载仪表盘数据失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchAll() }, [fetchAll])
|
||||||
|
|
||||||
|
/* loading */
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
||||||
|
<Skeleton variant='card' count={4} className='mt-4' />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginTop: 20 }}>
|
||||||
|
<Skeleton variant='card' count={1} />
|
||||||
|
<Skeleton variant='card' count={1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* error */
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchAll}
|
||||||
|
style={{
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* empty */
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
||||||
|
<p style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 20 }}>暂无数据</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* page header */}
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
||||||
|
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
|
||||||
|
|
||||||
|
{/* stat cards: 4-column grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'2 vs 昨天'} changeDirection='up' />
|
||||||
|
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'1 vs 昨天'} changeDirection='down' />
|
||||||
|
<StatCard label='运行中任务' value={summary.running_tasks} valueColor='blue' change={'Kanban · ' + kanban.length + ' 个看板活跃'} />
|
||||||
|
<StatCard label='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* health + trend: 3-column layout (2fr 1fr) */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
|
||||||
|
{/* health distribution */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>蜂群健康分布</span>
|
||||||
|
<span
|
||||||
|
onClick={() => navigate('/health')}
|
||||||
|
style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
查看全部 →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
{health ? (
|
||||||
|
<div>
|
||||||
|
<HealthStackedBar dist={health} onBarClick={(g) => navigate('/health?filter=' + g)} />
|
||||||
|
{health.d_profiles && health.d_profiles.length > 0 ? (
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
|
||||||
|
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
|
||||||
|
<span style={{ color: 'var(--red)' }}>{health.d_profiles.join(' · ')}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* gateway trend */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Gateway 异常趋势 (24h)
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
{trend.length > 0 ? <GatewayTimeline trend={trend} /> : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* kanban + cron: 2-column grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
|
||||||
|
{/* kanban overview */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kanban 任务概览
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>看板</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>RUNNING</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>READY</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kanban.length > 0 ? kanban.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.board}
|
||||||
|
onClick={() => navigate('/kanban')}
|
||||||
|
style={{ cursor: 'pointer', height: 44 }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.board}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--green)' }}>{item.running || '—'}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{item.ready || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} style={{ textAlign: 'center', padding: 20, color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* cron recent */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cron 最近执行
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Job</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cron.length > 0 ? cron.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.job}
|
||||||
|
onClick={() => navigate('/cron')}
|
||||||
|
style={{ cursor: 'pointer', height: 44 }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.job}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>
|
||||||
|
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-muted)' }}>{item.time || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} style={{ textAlign: 'center', padding: 20, color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* recent events table */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
最近异常事件
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Profile</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>类型</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>摘要</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{MOCK_EVENTS.map((evt, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 44,
|
||||||
|
...(evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text)' }}>{evt.time}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{evt.profile}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
|
||||||
|
<BadgeSpan cls={evt.typeClass}>{evt.type}</BadgeSpan>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{evt.summary}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
|
||||||
|
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import Badge from '../components/ui/Badge'
|
||||||
|
import Button from '../components/ui/Button'
|
||||||
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
|
import toast from '../components/ui/Toast'
|
||||||
|
|
||||||
|
import { getEvents, type EventItem } from '../api/events'
|
||||||
|
|
||||||
|
/* ---------- constants ---------- */
|
||||||
|
|
||||||
|
const DOMAINS = [
|
||||||
|
'gateway', 'crawler', 'reporter', 'feishu', 'database', 'security',
|
||||||
|
'network', 'storage', 'monitor', 'scheduler', 'api', 'other',
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'active', label: '活跃期' },
|
||||||
|
{ value: 'fading', label: '消减期' },
|
||||||
|
{ value: 'dissipating', label: '消散期' },
|
||||||
|
{ value: 'closed', label: '已完结' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const LEVEL_VARIANTS: Record<string, 'danger' | 'warning' | 'info'> = {
|
||||||
|
A: 'danger',
|
||||||
|
B: 'warning',
|
||||||
|
C: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANTS: Record<string, 'success' | 'warning' | 'honey' | 'outline'> = {
|
||||||
|
active: 'success',
|
||||||
|
fading: 'warning',
|
||||||
|
dissipating: 'honey',
|
||||||
|
closed: 'outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: '活跃期',
|
||||||
|
fading: '消减期',
|
||||||
|
dissipating: '消散期',
|
||||||
|
closed: '已完结',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- component ---------- */
|
||||||
|
|
||||||
|
export default function EventArchivePage() {
|
||||||
|
/* data */
|
||||||
|
const [events, setEvents] = useState<EventItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* filters */
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [domainFilter, setDomainFilter] = useState('')
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
|
/* ---------- fetch ---------- */
|
||||||
|
|
||||||
|
const fetchEvents = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (statusFilter) params.status = statusFilter
|
||||||
|
if (domainFilter) params.domain = domainFilter
|
||||||
|
if (searchText.trim()) params.search = searchText.trim()
|
||||||
|
const res = await getEvents(params)
|
||||||
|
setEvents(res.data.events)
|
||||||
|
} catch {
|
||||||
|
setError('加载事件列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [statusFilter, domainFilter, searchText])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents()
|
||||||
|
}, [fetchEvents])
|
||||||
|
|
||||||
|
/* ---------- handlers ---------- */
|
||||||
|
|
||||||
|
const handleNewEvent = () => {
|
||||||
|
toast.info('新建事件功能即将上线')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (event: EventItem) => {
|
||||||
|
toast.info(`事件: ${event.title}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render: loading ---------- */
|
||||||
|
|
||||||
|
if (loading && events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
事件档案
|
||||||
|
</h2>
|
||||||
|
<Skeleton variant="card" count={6} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render: error ---------- */
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
事件档案
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center py-12 gap-3"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 32, color: 'var(--red)' }}>!</span>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button variant="outline" onClick={fetchEvents}>重试</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render ---------- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
事件档案
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Filter toolbar */}
|
||||||
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={domainFilter}
|
||||||
|
onChange={(e) => setDomainFilter(e.target.value)}
|
||||||
|
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
<option value="">全部领域</option>
|
||||||
|
{DOMAINS.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索事件标题..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)', width: 200 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button size="sm" variant="primary" onClick={handleNewEvent}>
|
||||||
|
+ 新建事件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event cards */}
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<span style={{ fontSize: 28, marginBottom: 8, fontWeight: 600 }}>0</span>
|
||||||
|
<p>无匹配事件</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="grid gap-4"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => handleCardClick(event)}
|
||||||
|
className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--honey-dim)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<h3
|
||||||
|
className="mb-3 truncate"
|
||||||
|
style={{ color: 'var(--text)', fontSize: 15, fontWeight: 700, lineHeight: 1.4 }}
|
||||||
|
title={event.title}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Level badge + Domain tag */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Badge variant={LEVEL_VARIANTS[event.level]}>
|
||||||
|
{event.level}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="info">{event.domain}</Badge>
|
||||||
|
<Badge variant={STATUS_VARIANTS[event.status]}>
|
||||||
|
{STATUS_LABELS[event.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="space-y-1.5 text-[12px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>发生日期</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{event.occurred_date}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>上次复查</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{event.last_reviewed ? new Date(event.last_reviewed).toLocaleString('zh-CN') : '未复查'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>连续无新知</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: event.no_new_info_count >= 5 ? 'var(--yellow)' : 'var(--text-secondary)',
|
||||||
|
fontWeight: event.no_new_info_count >= 5 ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.no_new_info_count} 次
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>下次复查</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{event.next_review ? new Date(event.next_review).toLocaleString('zh-CN') : '无'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overdue badge */}
|
||||||
|
{event.overdue_days > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold"
|
||||||
|
style={{ background: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' }}
|
||||||
|
>
|
||||||
|
超期 {event.overdue_days} 天
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { RotateCcw, Play, Square, AlertTriangle, Search } from 'lucide-react'
|
||||||
|
import Table, { type TableColumn } from '../components/ui/Table'
|
||||||
|
import Badge from '../components/ui/Badge'
|
||||||
|
import Modal from '../components/ui/Modal'
|
||||||
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
|
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
|
||||||
|
import toast from '../components/ui/Toast'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { usePolling } from '../hooks/usePolling'
|
||||||
|
import {
|
||||||
|
getGatewayStatus,
|
||||||
|
getGatewayEvents,
|
||||||
|
startGateway,
|
||||||
|
stopGateway,
|
||||||
|
restartGateway,
|
||||||
|
type GatewayInfo,
|
||||||
|
type GatewayEvent,
|
||||||
|
} from '../api/gateway'
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
const statBadge = (s: string) => {
|
||||||
|
if (s === 'online') return <Badge variant="success">在线</Badge>
|
||||||
|
if (s === 'offline') return <Badge variant="danger">离线</Badge>
|
||||||
|
if (s === 'warn') return <Badge variant="warning">异常</Badge>
|
||||||
|
return <Badge variant="outline">{s}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventToTimeline = (e: GatewayEvent): TimelineEvent => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
heartbeat_timeout: 'var(--red)',
|
||||||
|
feishu_disconnect: 'var(--orange)',
|
||||||
|
restart: 'var(--blue)',
|
||||||
|
}
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
heartbeat_timeout: '心跳超时',
|
||||||
|
feishu_disconnect: '飞书断连',
|
||||||
|
restart: 'Gateway 重启',
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
time: e.time,
|
||||||
|
type: e.type,
|
||||||
|
tooltip: `${labelMap[e.type] || e.type} — ${e.profile}`,
|
||||||
|
color: colorMap[e.type],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDangerRow = (gw: GatewayInfo) =>
|
||||||
|
gw.restarts_today >= 3 || gw.downtime_minutes >= 30
|
||||||
|
|
||||||
|
/* ---------- page ---------- */
|
||||||
|
|
||||||
|
export default function GatewayPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const canOperate = user?.role === 'owner' || user?.role === 'operator'
|
||||||
|
|
||||||
|
const [gateways, setGateways] = useState<GatewayInfo[]>([])
|
||||||
|
const [events, setEvents] = useState<GatewayEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
const [operating, setOperating] = useState<string | null>(null)
|
||||||
|
const [modalAction, setModalAction] = useState<{ profile: string; action: 'start' | 'stop' | 'restart' } | null>(null)
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getGatewayStatus()
|
||||||
|
setGateways(res.data)
|
||||||
|
setLastUpdate(new Date())
|
||||||
|
setError(null)
|
||||||
|
} catch {
|
||||||
|
setError('加载 Gateway 状态失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchEvents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getGatewayEvents()
|
||||||
|
setEvents(res.data)
|
||||||
|
} catch {
|
||||||
|
// event fetch failure is non-critical
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchEvents() }, [fetchEvents])
|
||||||
|
usePolling(fetchStatus, 10_000)
|
||||||
|
|
||||||
|
/* ---- operate ---- */
|
||||||
|
const handleOperate = useCallback(async () => {
|
||||||
|
if (!modalAction) return
|
||||||
|
const { profile, action } = modalAction
|
||||||
|
setOperating(profile)
|
||||||
|
setModalAction(null)
|
||||||
|
try {
|
||||||
|
if (action === 'start') await startGateway(profile)
|
||||||
|
else if (action === 'stop') await stopGateway(profile)
|
||||||
|
else await restartGateway(profile)
|
||||||
|
toast.success(`Gateway "${profile}" ${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}成功`)
|
||||||
|
fetchStatus()
|
||||||
|
} catch {
|
||||||
|
toast.error(`Gateway "${profile}" ${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}失败`)
|
||||||
|
} finally {
|
||||||
|
setOperating(null)
|
||||||
|
}
|
||||||
|
}, [modalAction, fetchStatus])
|
||||||
|
|
||||||
|
/* ---- stats ---- */
|
||||||
|
const totalCount = gateways.length
|
||||||
|
const onlineCount = gateways.filter(g => g.status === 'online').length
|
||||||
|
const offlineCount = gateways.filter(g => g.status === 'offline').length
|
||||||
|
const warnCount = gateways.filter(g => g.status === 'warn').length
|
||||||
|
|
||||||
|
/* ---- filter ---- */
|
||||||
|
const filtered = gateways.filter((g) => {
|
||||||
|
if (statusFilter !== 'all' && g.status !== statusFilter) return false
|
||||||
|
if (typeFilter !== 'all' && g.profile_type !== typeFilter) return false
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.trim().toLowerCase()
|
||||||
|
if (!g.name.toLowerCase().includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const cols: TableColumn[] = [
|
||||||
|
{ key: 'name', label: 'Profile 名' },
|
||||||
|
{ key: 'status', label: '状态' },
|
||||||
|
{ key: 'downtime', label: '掉线时长' },
|
||||||
|
{ key: 'restarts', label: '今日重启' },
|
||||||
|
{ key: 'last_event', label: '最近异常' },
|
||||||
|
]
|
||||||
|
if (canOperate) cols.push({ key: 'actions', label: '操作' })
|
||||||
|
|
||||||
|
const tableData = filtered.map((gw) => ({
|
||||||
|
key: gw.id || gw.name,
|
||||||
|
name: <span style={{ fontWeight: 500, color: 'var(--text)' }}>{gw.name}</span>,
|
||||||
|
status: statBadge(gw.status),
|
||||||
|
downtime: gw.downtime_minutes > 0 ? `${gw.downtime_minutes} 分钟` : '-',
|
||||||
|
restarts: gw.restarts_today > 0 ? (
|
||||||
|
<span style={{ color: gw.restarts_today >= 3 ? 'var(--red)' : 'var(--yellow)' }}>
|
||||||
|
{gw.restarts_today} 次
|
||||||
|
</span>
|
||||||
|
) : '-',
|
||||||
|
last_event: gw.last_event || '-',
|
||||||
|
actions: canOperate ? (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
disabled={operating === gw.name}
|
||||||
|
onClick={() => setModalAction({ profile: gw.name, action: 'restart' })}
|
||||||
|
title="重启"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--honey)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: operating === gw.name ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} />
|
||||||
|
</button>
|
||||||
|
{gw.status === 'online' ? (
|
||||||
|
<button
|
||||||
|
disabled={operating === gw.name}
|
||||||
|
onClick={() => setModalAction({ profile: gw.name, action: 'stop' })}
|
||||||
|
title="停止"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--honey)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: operating === gw.name ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Square size={13} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled={operating === gw.name}
|
||||||
|
onClick={() => setModalAction({ profile: gw.name, action: 'start' })}
|
||||||
|
title="启动"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--honey)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: operating === gw.name ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rowClassName = (record: Record<string, unknown>) => {
|
||||||
|
const gw = gateways.find((g) => g.name === record.name)
|
||||||
|
return gw && isDangerRow(gw) ? 'row-danger' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineEvents = events.map(eventToTimeline)
|
||||||
|
|
||||||
|
/* ---- last update text ---- */
|
||||||
|
const secondsAgo = Math.floor((Date.now() - lastUpdate.getTime()) / 1000)
|
||||||
|
|
||||||
|
/* ---- status filter buttons ---- */
|
||||||
|
const statusFilters = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'online', label: '在线\u{1F535}' },
|
||||||
|
{ key: 'offline', label: '离线\u{1F534}' },
|
||||||
|
{ key: 'warn', label: '异常\u{1F7E0}' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ---------- render ---------- */
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
|
||||||
|
<Skeleton variant="card" count={1} className="mt-4" />
|
||||||
|
<Skeleton variant="table" count={5} className="mt-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && gateways.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
|
||||||
|
<div
|
||||||
|
className="mt-4 p-5 rounded-[var(--radius)] text-center"
|
||||||
|
style={{ background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchStatus}
|
||||||
|
style={{
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Title + stats */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>
|
||||||
|
Gateway 健康监控
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 4 }}>
|
||||||
|
共 {totalCount} 个 · 在线 {onlineCount} · 离线 {offlineCount} · 异常 {warnCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
最后更新 {secondsAgo} 秒前
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- event timeline ---- */}
|
||||||
|
{timelineEvents.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-4 rounded-[var(--radius)] p-4"
|
||||||
|
style={{ background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
||||||
|
异常事件时间线 (24h)
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<Timeline events={timelineEvents} className="min-w-[600px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 mt-3 text-[12px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
|
||||||
|
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
|
||||||
|
<span><span style={{ color: 'var(--blue)' }}>●</span> Gateway 重启</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- filter bar ---- */}
|
||||||
|
<div
|
||||||
|
className="mb-3"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* status filter buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{statusFilters.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => setStatusFilter(f.key)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
background: statusFilter === f.key ? 'var(--honey)' : 'var(--bg-card)',
|
||||||
|
color: statusFilter === f.key ? '#000' : 'var(--text-dim)',
|
||||||
|
fontWeight: statusFilter === f.key ? 600 : 400,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* search + type filter */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 10,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="搜索 Gateway..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
height: 32,
|
||||||
|
padding: '0 12px 0 30px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
height: 32,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">全部蜂型</option>
|
||||||
|
<option value="worker">工蜂</option>
|
||||||
|
<option value="queen">蜂王</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- table ---- */}
|
||||||
|
<div>
|
||||||
|
{filtered.length === 0 && !loading ? (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--radius)] p-8 text-center"
|
||||||
|
style={{ background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--text-dim)', fontSize: 14 }}>全部 Gateway 正常运行</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={cols}
|
||||||
|
data={tableData}
|
||||||
|
rowKey="key"
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- confirm modal ---- */}
|
||||||
|
<Modal
|
||||||
|
open={!!modalAction}
|
||||||
|
onClose={() => setModalAction(null)}
|
||||||
|
title="确认操作"
|
||||||
|
confirmText={modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'}
|
||||||
|
confirmDanger
|
||||||
|
onConfirm={handleOperate}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle size={20} style={{ color: 'var(--red)', flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'var(--text)', fontSize: 14, marginBottom: 8 }}>
|
||||||
|
确认{modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'}
|
||||||
|
<strong> {modalAction?.profile}</strong> Gateway?
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--text-dim)', fontSize: 12 }}>
|
||||||
|
{modalAction?.action === 'stop'
|
||||||
|
? '停止后该 Gateway 上的所有连接将立即断开。'
|
||||||
|
: modalAction?.action === 'restart'
|
||||||
|
? '重启期间服务将暂时不可用。'
|
||||||
|
: '启动后将恢复服务。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import Badge from '../components/ui/Badge'
|
||||||
|
import Button from '../components/ui/Button'
|
||||||
|
import Table, { type TableColumn } from '../components/ui/Table'
|
||||||
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
|
import Modal from '../components/ui/Modal'
|
||||||
|
import toast from '../components/ui/Toast'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getHealthScores, getProfileScore, compareProfiles, recalculateScores,
|
||||||
|
type ScoreListResponse, type ProfileScoreDetail, type CompareResponse,
|
||||||
|
} from '../api/health'
|
||||||
|
|
||||||
|
const GRADE_META: Record<string, { color: string; bg: string; label: string }> = {
|
||||||
|
A: { color: 'var(--green)', bg: 'var(--bg-success)', label: '优秀' },
|
||||||
|
B: { color: 'var(--blue)', bg: 'var(--bg-info)', label: '良好' },
|
||||||
|
C: { color: 'var(--yellow)', bg: 'var(--bg-warn)', label: '一般' },
|
||||||
|
D: { color: 'var(--red)', bg: 'var(--bg-error)', label: '危险' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradeBadge({ grade }: { grade: string }) {
|
||||||
|
const m = GRADE_META[grade]
|
||||||
|
if (!m) return <Badge variant="outline">{grade}</Badge>
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold"
|
||||||
|
style={{ backgroundColor: m.bg, color: m.color }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: m.color, display: 'inline-block' }} />
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradeCard({ grade, count, active, onClick }: {
|
||||||
|
grade: string; count: number; active: boolean; onClick: () => void
|
||||||
|
}) {
|
||||||
|
const m = GRADE_META[grade]
|
||||||
|
const as = active ? { boxShadow: '0 0 0 2px ' + m.color } : {}
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
|
||||||
|
style={{ background: m.bg, ...as }}>
|
||||||
|
<div className="text-[12px] uppercase tracking-wider" style={{ color: m.color }}>{grade}</div>
|
||||||
|
<div className="text-[28px] font-bold leading-tight mt-1" style={{ color: m.color }}>{count}</div>
|
||||||
|
<div className="text-[12px] mt-1" style={{ color: m.color, opacity: 0.7 }}>{m.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanel({ profile }: { profile: string }) {
|
||||||
|
const [detail, setDetail] = useState<ProfileScoreDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
getProfileScore(profile).then(r => setDetail(r.data))
|
||||||
|
.catch(() => toast.error('加载详情失败'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [profile])
|
||||||
|
if (loading) return <div className="p-4"><Skeleton variant="text" count={4} /></div>
|
||||||
|
if (!detail) return <div className="p-4 text-[var(--text-dim)]">暂无数据</div>
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{detail.dimensions.map((dim, i) => {
|
||||||
|
const ok = dim.score / dim.max_score >= 0.7
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-[var(--radius-sm)] p-3" style={{ background: "var(--bg-hover)" }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: "var(--text)" }}>{dim.name}</span>
|
||||||
|
<span className="text-[12px]" style={{ color: ok ? "var(--green)" : "var(--red)" }}>
|
||||||
|
{dim.score}/{dim.max_score} ({dim.weight})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dim.deductions.length > 0 && (
|
||||||
|
<div className="space-y-1 mb-2">
|
||||||
|
<div className="text-[11px] font-semibold" style={{ color: "var(--red)" }}>扣分项:</div>
|
||||||
|
{dim.deductions.map((d, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2022 {d}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dim.suggestions.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[11px] font-semibold" style={{ color: "var(--honey)" }}>修复建议:</div>
|
||||||
|
{dim.suggestions.map((s, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2192 {s}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dim.deductions.length === 0 && <div className="text-[12px]" style={{ color: "var(--green)" }}>\u2713 满分</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareTable({ data, showDiffOnly, profiles: p }: {
|
||||||
|
data: CompareResponse; showDiffOnly: boolean; profiles: string[]
|
||||||
|
}) {
|
||||||
|
const rows = showDiffOnly ? data.rows.filter(r => r.has_diff) : data.rows
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <div className="p-4 text-center text-[var(--text-dim)]">没有差异</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto rounded-[var(--radius)] border border-[var(--border)]">
|
||||||
|
<table className="w-full border-collapse text-[12px]">
|
||||||
|
<thead><tr>
|
||||||
|
<th className="sticky top-0 z-10 bg-[var(--bg-nav)] text-left px-3 h-[36px] uppercase text-[var(--text-dim)] font-semibold">字段</th>
|
||||||
|
{p.map(n =>
|
||||||
|
<th key={n} className="sticky top-0 z-10 bg-[var(--bg-nav)] text-center px-3 h-[36px] text-[var(--text-dim)] font-semibold">{n}</th>
|
||||||
|
)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const vals = p.map(n => String(row[n] || ''))
|
||||||
|
const hasDiff = new Set(vals).size > 1
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-t border-[var(--border)]" style={hasDiff ? { background: "var(--bg-warn)" } : {}}>
|
||||||
|
<td className="px-3 font-medium h-[36px]" style={{ color: "var(--text)" }}>{row.field}</td>
|
||||||
|
{p.map(n => {
|
||||||
|
const v = String(row[n] || '')
|
||||||
|
const c = v === '\u2705' ? 'var(--green)' : v === '\u274c' ? 'var(--red)' : 'var(--honey)'
|
||||||
|
return <td key={n} className="px-3 text-center" style={{ color: c }}>{v}</td>
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [data, setData] = useState<ScoreListResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const gradeFilter = searchParams.get('filter') || ''
|
||||||
|
const [sortAsc, setSortAsc] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [compareData, setCompareData] = useState<CompareResponse | null>(null)
|
||||||
|
const [compareLoading, setCompareLoading] = useState(false)
|
||||||
|
const [showCompare, setShowCompare] = useState(false)
|
||||||
|
const [showDiffOnly, setShowDiffOnly] = useState(false)
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||||
|
const [recalcLoading, setRecalcLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { sort: sortAsc ? 'asc' : 'desc' }
|
||||||
|
if (gradeFilter) params.grade = gradeFilter
|
||||||
|
const r = await getHealthScores(params)
|
||||||
|
setData(r.data)
|
||||||
|
} catch { setError('加载健康分数据失败') }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [gradeFilter, sortAsc])
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
const toggleSelect = (name: string) => {
|
||||||
|
setSelected(prev => { const n = new Set(prev); if (n.has(name)) n.delete(name); else n.add(name); return n })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompare = async () => {
|
||||||
|
if (selected.size < 2) { toast.error('至少选择 2 个 Profile'); return }
|
||||||
|
setCompareLoading(true)
|
||||||
|
try { const r = await compareProfiles(Array.from(selected)); setCompareData(r.data); setShowCompare(true) }
|
||||||
|
catch { toast.error('对比失败') } finally { setCompareLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalcLoading(true)
|
||||||
|
try { await recalculateScores(); toast.success('已重新计算'); fetchData() }
|
||||||
|
catch { toast.error('重算失败') } finally { setRecalcLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGradeFilter = (g: string) => {
|
||||||
|
const p = new URLSearchParams(searchParams)
|
||||||
|
if (g && g !== gradeFilter) p.set('filter', g); else p.delete('filter')
|
||||||
|
setSearchParams(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ key: '_select', label: '', width: 40 },
|
||||||
|
{ key: 'rank', label: '排名', width: 50 },
|
||||||
|
{ key: 'name', label: 'Profile' },
|
||||||
|
{ key: 'total', label: '总分', width: 70 },
|
||||||
|
{ key: 'grade', label: '等级', width: 60 },
|
||||||
|
{ key: 'dims', label: '四维度得分', width: 200 },
|
||||||
|
{ key: 'deduct', label: '主要扣分' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dist = data?.distribution ?? { A: 0, B: 0, C: 0, D: 0 }
|
||||||
|
const scores = data?.scores ?? []
|
||||||
|
|
||||||
|
/* loading */
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||||
|
{['A','B','C','D'].map(g => <div key={g} className="rounded-[var(--radius)] p-4" style={{ background: 'var(--bg-card)' }}><Skeleton variant="text" count={2} /></div>)}
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="table" count={8} className="mt-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* error */
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||||
|
<div className="mt-4 p-5 rounded-[var(--radius)] text-center" style={{ background: "var(--bg-card)" }}>
|
||||||
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||||
|
<Button onClick={fetchData}>重试</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* table rows */
|
||||||
|
const tableData = scores.map(s => {
|
||||||
|
const tc = s.total_score >= 80 ? 'var(--green)' : s.total_score >= 60 ? 'var(--blue)' : s.total_score >= 40 ? 'var(--yellow)' : 'var(--red)'
|
||||||
|
return {
|
||||||
|
key: s.name,
|
||||||
|
_select: <input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} className="cursor-pointer" style={{ accentColor: 'var(--honey)' }} />,
|
||||||
|
rank: <span style={{ color: 'var(--text-dim)' }}>{s.rank}</span>,
|
||||||
|
name: <span style={{ color: 'var(--text)', fontWeight: 500 }}>{s.name}</span>,
|
||||||
|
total: <span style={{ color: tc, fontWeight: 700, fontSize: 15 }}>{s.total_score}</span>,
|
||||||
|
grade: <GradeBadge grade={s.grade} />,
|
||||||
|
dims: (
|
||||||
|
<div className="flex gap-3 text-[12px]">
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>G:<span style={{ color: 'var(--green)' }}>{s.gateway_score}</span></span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>C:<span style={{ color: 'var(--blue)' }}>{s.config_score}</span></span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>S:<span style={{ color: 'var(--honey)' }}>{s.skill_score}</span></span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>R:<span style={{ color: 'var(--yellow)' }}>{s.cron_score}</span></span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
deduct: <div className="text-[12px] truncate max-w-[200px]" style={{ color: 'var(--text-dim)' }}>{s.deduct_summary[0] || '-'}</div>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||||
|
|
||||||
|
{/* grade cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||||
|
{Object.entries(GRADE_META).map(([g]) =>
|
||||||
|
<GradeCard key={g} grade={g} count={dist[g as keyof typeof dist]} active={gradeFilter === g} onClick={() => handleGradeFilter(g)} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* filter bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-4 mb-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['', 'A', 'B', 'C', 'D'].map(g =>
|
||||||
|
<button key={g} onClick={() => handleGradeFilter(g)}
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-[var(--radius-sm)] font-semibold cursor-pointer border-none transition-colors"
|
||||||
|
style={{ background: gradeFilter === g ? 'var(--honey)' : 'var(--bg-hover)', color: gradeFilter === g ? '#000' : 'var(--text-dim)' }}>
|
||||||
|
{g || '全部'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" disabled={selected.size < 2} loading={compareLoading} onClick={handleCompare}>对比选中 ({selected.size})</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setSortAsc(!sortAsc) }}>{sortAsc ? '\u2191 低到高' : '\u2193 高到低'}</Button>
|
||||||
|
{user?.role === 'owner' && <Button size="sm" variant="outline" loading={recalcLoading} onClick={handleRecalculate}>重新计算</Button>}
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>共 {scores.length} 个 Profile</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* table */}
|
||||||
|
<Table columns={columns} data={tableData as any} rowKey="key"
|
||||||
|
onRowClick={(record) => setExpandedRow(expandedRow === record.key ? null : String(record.key))}
|
||||||
|
rowClassName={(record: any) => String(record._grade || '') === 'D' ? 'row-danger' : ''} />
|
||||||
|
|
||||||
|
{/* expanded detail */}
|
||||||
|
{expandedRow && (
|
||||||
|
<div className="mt-2 mb-4 rounded-[var(--radius)] border border-[var(--border)] overflow-hidden"
|
||||||
|
style={{ background: "var(--bg-card)" }}>
|
||||||
|
<DetailPanel profile={expandedRow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* compare modal */}
|
||||||
|
<Modal open={showCompare} onClose={() => setShowCompare(false)} title="Profile \u5bf9\u6bd4"
|
||||||
|
footer={
|
||||||
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer" style={{ color: "var(--text-dim)" }}>
|
||||||
|
<input type="checkbox" checked={showDiffOnly} onChange={() => setShowDiffOnly(!showDiffOnly)} style={{ accentColor: 'var(--honey)' }} />
|
||||||
|
只看差异
|
||||||
|
</label>
|
||||||
|
}>
|
||||||
|
{compareData && <CompareTable data={compareData} showDiffOnly={showDiffOnly} profiles={Array.from(selected)} />}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user