feat: Event archive management — Event model, migration, API, frontend page (Phase 8)

- Event ORM model with status/level enums, domain categories, review tracking
- Alembic migration e7f3a9b1c5d0 for events table
- Event crawler service with placeholder data (12 sample events)
- GET /api/v1/events with status/domain/search filtering and overdue_days
- EventArchivePage with filter toolbar (status, domain, search) and adaptive card grid
- Status badges, level badges, domain tags, overdue markers on event cards
- Loading/error/empty states, hover-to-honey card interaction

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 17:34:19 +08:00
co-authored by Claude
parent 5c0352c768
commit eaf13e5f51
8 changed files with 623 additions and 4 deletions
@@ -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')
+65
View File
@@ -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}
+2 -1
View File
@@ -4,7 +4,7 @@ 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
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys, kanban, events
from app.middleware.audit import AuditMiddleware
@@ -39,6 +39,7 @@ 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")
+3 -1
View File
@@ -1 +1,3 @@
from app.models.user import User
from app.models.audit import AuditLog
from app.models.event import Event
+53
View File
@@ -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)
+162
View File
@@ -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 执行结束")