feat: persist system settings and refine admin layouts

This commit is contained in:
rayd1o
2026-03-25 02:57:58 +08:00
parent 81a0ca5e7a
commit ef0fefdfc7
19 changed files with 2091 additions and 1231 deletions

View File

@@ -1,155 +1,66 @@
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, func
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.user import User
from app.models.collected_data import CollectedData
from app.models.datasource import DataSource
from app.models.task import CollectionTask
from app.models.collected_data import CollectedData
from app.core.security import get_current_user
from app.services.collectors.registry import collector_registry
from app.models.user import User
from app.services.scheduler import run_collector_now, sync_datasource_job
router = APIRouter()
COLLECTOR_INFO = {
"top500": {
"id": 1,
"name": "TOP500 Supercomputers",
"module": "L1",
"priority": "P0",
"frequency_hours": 4,
},
"epoch_ai_gpu": {
"id": 2,
"name": "Epoch AI GPU Clusters",
"module": "L1",
"priority": "P0",
"frequency_hours": 6,
},
"huggingface_models": {
"id": 3,
"name": "HuggingFace Models",
"module": "L2",
"priority": "P1",
"frequency_hours": 12,
},
"huggingface_datasets": {
"id": 4,
"name": "HuggingFace Datasets",
"module": "L2",
"priority": "P1",
"frequency_hours": 12,
},
"huggingface_spaces": {
"id": 5,
"name": "HuggingFace Spaces",
"module": "L2",
"priority": "P2",
"frequency_hours": 24,
},
"peeringdb_ixp": {
"id": 6,
"name": "PeeringDB IXP",
"module": "L2",
"priority": "P1",
"frequency_hours": 24,
},
"peeringdb_network": {
"id": 7,
"name": "PeeringDB Networks",
"module": "L2",
"priority": "P2",
"frequency_hours": 48,
},
"peeringdb_facility": {
"id": 8,
"name": "PeeringDB Facilities",
"module": "L2",
"priority": "P2",
"frequency_hours": 48,
},
"telegeography_cables": {
"id": 9,
"name": "Submarine Cables",
"module": "L2",
"priority": "P1",
"frequency_hours": 168,
},
"telegeography_landing": {
"id": 10,
"name": "Cable Landing Points",
"module": "L2",
"priority": "P2",
"frequency_hours": 168,
},
"telegeography_systems": {
"id": 11,
"name": "Cable Systems",
"module": "L2",
"priority": "P2",
"frequency_hours": 168,
},
"arcgis_cables": {
"id": 15,
"name": "ArcGIS Submarine Cables",
"module": "L2",
"priority": "P1",
"frequency_hours": 168,
},
"arcgis_landing_points": {
"id": 16,
"name": "ArcGIS Landing Points",
"module": "L2",
"priority": "P1",
"frequency_hours": 168,
},
"arcgis_cable_landing_relation": {
"id": 17,
"name": "ArcGIS Cable-Landing Relations",
"module": "L2",
"priority": "P1",
"frequency_hours": 168,
},
"fao_landing_points": {
"id": 18,
"name": "FAO Landing Points",
"module": "L2",
"priority": "P1",
"frequency_hours": 168,
},
"spacetrack_tle": {
"id": 19,
"name": "Space-Track TLE",
"module": "L3",
"priority": "P2",
"frequency_hours": 24,
},
"celestrak_tle": {
"id": 20,
"name": "CelesTrak TLE",
"module": "L3",
"priority": "P2",
"frequency_hours": 24,
},
}
ID_TO_COLLECTOR = {info["id"]: name for name, info in COLLECTOR_INFO.items()}
COLLECTOR_TO_ID = {name: info["id"] for name, info in COLLECTOR_INFO.items()}
def format_frequency_label(minutes: int) -> str:
if minutes % 1440 == 0:
return f"{minutes // 1440}d"
if minutes % 60 == 0:
return f"{minutes // 60}h"
return f"{minutes}m"
def get_collector_name(source_id: str) -> Optional[str]:
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
datasource = None
try:
numeric_id = int(source_id)
if numeric_id in ID_TO_COLLECTOR:
return ID_TO_COLLECTOR[numeric_id]
datasource = await db.get(DataSource, int(source_id))
except ValueError:
pass
if source_id in COLLECTOR_INFO:
return source_id
return None
if datasource is not None:
return datasource
result = await db.execute(
select(DataSource).where(
(DataSource.source == source_id) | (DataSource.collector_class == source_id)
)
)
return result.scalar_one_or_none()
async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]:
result = await db.execute(
select(CollectionTask)
.where(CollectionTask.datasource_id == datasource_id)
.where(CollectionTask.completed_at.isnot(None))
.order_by(CollectionTask.completed_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]:
result = await db.execute(
select(CollectionTask)
.where(CollectionTask.datasource_id == datasource_id)
.where(CollectionTask.status == "running")
.order_by(CollectionTask.started_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
@router.get("")
@@ -160,48 +71,24 @@ async def list_datasources(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(DataSource)
filters = []
query = select(DataSource).order_by(DataSource.module, DataSource.id)
if module:
filters.append(DataSource.module == module)
query = query.where(DataSource.module == module)
if is_active is not None:
filters.append(DataSource.is_active == is_active)
query = query.where(DataSource.is_active == is_active)
if priority:
filters.append(DataSource.priority == priority)
if filters:
query = query.where(*filters)
query = query.where(DataSource.priority == priority)
result = await db.execute(query)
datasources = result.scalars().all()
collector_list = []
for name, info in COLLECTOR_INFO.items():
is_active_status = collector_registry.is_active(name)
running_task_query = (
select(CollectionTask)
.where(CollectionTask.datasource_id == info["id"])
.where(CollectionTask.status == "running")
.order_by(CollectionTask.started_at.desc())
.limit(1)
for datasource in datasources:
running_task = await get_running_task(db, datasource.id)
last_task = await get_last_completed_task(db, datasource.id)
data_count_result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
)
running_result = await db.execute(running_task_query)
running_task = running_result.scalar_one_or_none()
last_run_query = (
select(CollectionTask)
.where(CollectionTask.datasource_id == info["id"])
.where(CollectionTask.completed_at.isnot(None))
.order_by(CollectionTask.completed_at.desc())
.limit(1)
)
last_run_result = await db.execute(last_run_query)
last_task = last_run_result.scalar_one_or_none()
data_count_query = select(func.count(CollectedData.id)).where(CollectedData.source == name)
data_count_result = await db.execute(data_count_query)
data_count = data_count_result.scalar() or 0
last_run = None
@@ -210,13 +97,14 @@ async def list_datasources(
collector_list.append(
{
"id": info["id"],
"name": info["name"],
"module": info["module"],
"priority": info["priority"],
"frequency": f"{info['frequency_hours']}h",
"is_active": is_active_status,
"collector_class": name,
"id": datasource.id,
"name": datasource.name,
"module": datasource.module,
"priority": datasource.priority,
"frequency": format_frequency_label(datasource.frequency_minutes),
"frequency_minutes": datasource.frequency_minutes,
"is_active": datasource.is_active,
"collector_class": datasource.collector_class,
"last_run": last_run,
"is_running": running_task is not None,
"task_id": running_task.id if running_task else None,
@@ -226,15 +114,7 @@ async def list_datasources(
}
)
if module:
collector_list = [c for c in collector_list if c["module"] == module]
if priority:
collector_list = [c for c in collector_list if c["priority"] == priority]
return {
"total": len(collector_list),
"data": collector_list,
}
return {"total": len(collector_list), "data": collector_list}
@router.get("/{source_id}")
@@ -243,19 +123,20 @@ async def get_datasource(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name]
return {
"id": info["id"],
"name": info["name"],
"module": info["module"],
"priority": info["priority"],
"frequency": f"{info['frequency_hours']}h",
"collector_class": collector_name,
"is_active": collector_registry.is_active(collector_name),
"id": datasource.id,
"name": datasource.name,
"module": datasource.module,
"priority": datasource.priority,
"frequency": format_frequency_label(datasource.frequency_minutes),
"frequency_minutes": datasource.frequency_minutes,
"collector_class": datasource.collector_class,
"source": datasource.source,
"is_active": datasource.is_active,
}
@@ -263,24 +144,32 @@ async def get_datasource(
async def enable_datasource(
source_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
collector_registry.set_active(collector_name, True)
return {"status": "enabled", "source_id": source_id}
datasource.is_active = True
await db.commit()
await sync_datasource_job(datasource.id)
return {"status": "enabled", "source_id": datasource.id}
@router.post("/{source_id}/disable")
async def disable_datasource(
source_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
collector_registry.set_active(collector_name, False)
return {"status": "disabled", "source_id": source_id}
datasource.is_active = False
await db.commit()
await sync_datasource_job(datasource.id)
return {"status": "disabled", "source_id": datasource.id}
@router.get("/{source_id}/stats")
@@ -289,26 +178,19 @@ async def get_datasource_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name]
source_name = info["name"]
query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name)
result = await db.execute(query)
result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
)
total = result.scalar() or 0
if total == 0:
query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name)
result = await db.execute(query)
total = result.scalar() or 0
return {
"source_id": source_id,
"collector_name": collector_name,
"name": info["name"],
"source_id": datasource.id,
"collector_name": datasource.collector_class,
"name": datasource.name,
"total_records": total,
}
@@ -317,30 +199,25 @@ async def get_datasource_stats(
async def trigger_datasource(
source_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
from app.services.scheduler import run_collector_now
if not collector_registry.is_active(collector_name):
if not datasource.is_active:
raise HTTPException(status_code=400, detail="Data source is disabled")
success = run_collector_now(collector_name)
success = run_collector_now(datasource.source)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'")
if success:
return {
"status": "triggered",
"source_id": source_id,
"collector_name": collector_name,
"message": f"Collector '{collector_name}' has been triggered",
}
else:
raise HTTPException(
status_code=500,
detail=f"Failed to trigger collector '{collector_name}'",
)
return {
"status": "triggered",
"source_id": datasource.id,
"collector_name": datasource.source,
"message": f"Collector '{datasource.source}' has been triggered",
}
@router.delete("/{source_id}/data")
@@ -349,39 +226,25 @@ async def clear_datasource_data(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name]
source_name = info["name"]
query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name)
result = await db.execute(query)
result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
)
count = result.scalar() or 0
if count == 0:
query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name)
result = await db.execute(query)
count = result.scalar() or 0
delete_source = source_name
else:
delete_source = collector_name
return {"status": "success", "message": "No data to clear", "deleted_count": 0}
if count == 0:
return {
"status": "success",
"message": "No data to clear",
"deleted_count": 0,
}
delete_query = CollectedData.__table__.delete().where(CollectedData.source == delete_source)
delete_query = CollectedData.__table__.delete().where(CollectedData.source == datasource.source)
await db.execute(delete_query)
await db.commit()
return {
"status": "success",
"message": f"Cleared {count} records for data source '{info['name']}'",
"message": f"Cleared {count} records for data source '{datasource.name}'",
"deleted_count": count,
}
@@ -391,22 +254,11 @@ async def get_task_status(
source_id: str,
db: AsyncSession = Depends(get_db),
):
collector_name = get_collector_name(source_id)
if not collector_name:
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name]
running_task_query = (
select(CollectionTask)
.where(CollectionTask.datasource_id == info["id"])
.where(CollectionTask.status == "running")
.order_by(CollectionTask.started_at.desc())
.limit(1)
)
running_result = await db.execute(running_task_query)
running_task = running_result.scalar_one_or_none()
running_task = await get_running_task(db, datasource.id)
if not running_task:
return {"is_running": False, "task_id": None, "progress": None}

View File

@@ -1,13 +1,21 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from app.models.user import User
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.datasource import DataSource
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.services.scheduler import sync_datasource_job
router = APIRouter()
default_settings = {
DEFAULT_SETTINGS = {
"system": {
"system_name": "智能星球",
"refresh_interval": 60,
@@ -29,17 +37,13 @@ default_settings = {
},
}
system_settings = default_settings["system"].copy()
notification_settings = default_settings["notifications"].copy()
security_settings = default_settings["security"].copy()
class SystemSettingsUpdate(BaseModel):
system_name: str = "智能星球"
refresh_interval: int = 60
refresh_interval: int = Field(default=60, ge=10, le=3600)
auto_refresh: bool = True
data_retention_days: int = 30
max_concurrent_tasks: int = 5
data_retention_days: int = Field(default=30, ge=1, le=3650)
max_concurrent_tasks: int = Field(default=5, ge=1, le=50)
class NotificationSettingsUpdate(BaseModel):
@@ -51,60 +55,166 @@ class NotificationSettingsUpdate(BaseModel):
class SecuritySettingsUpdate(BaseModel):
session_timeout: int = 60
max_login_attempts: int = 5
password_policy: str = "medium"
session_timeout: int = Field(default=60, ge=5, le=1440)
max_login_attempts: int = Field(default=5, ge=1, le=20)
password_policy: str = Field(default="medium")
class CollectorSettingsUpdate(BaseModel):
is_active: bool
priority: str = Field(default="P1")
frequency_minutes: int = Field(default=60, ge=1, le=10080)
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
merged = DEFAULT_SETTINGS[category].copy()
if payload:
merged.update(payload)
return merged
async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]:
result = await db.execute(select(SystemSetting).where(SystemSetting.category == category))
return result.scalar_one_or_none()
async def get_setting_payload(db: AsyncSession, category: str) -> dict:
record = await get_setting_record(db, category)
return merge_with_defaults(category, record.payload if record else None)
async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict:
record = await get_setting_record(db, category)
if record is None:
record = SystemSetting(category=category, payload=payload)
db.add(record)
else:
record.payload = payload
await db.commit()
await db.refresh(record)
return merge_with_defaults(category, record.payload)
def format_frequency_label(minutes: int) -> str:
if minutes % 1440 == 0:
return f"{minutes // 1440}d"
if minutes % 60 == 0:
return f"{minutes // 60}h"
return f"{minutes}m"
def serialize_collector(datasource: DataSource) -> dict:
return {
"id": datasource.id,
"name": datasource.name,
"source": datasource.source,
"module": datasource.module,
"priority": datasource.priority,
"frequency_minutes": datasource.frequency_minutes,
"frequency": format_frequency_label(datasource.frequency_minutes),
"is_active": datasource.is_active,
"last_run_at": datasource.last_run_at.isoformat() if datasource.last_run_at else None,
"last_status": datasource.last_status,
"next_run_at": datasource.next_run_at.isoformat() if datasource.next_run_at else None,
}
@router.get("/system")
async def get_system_settings(current_user: User = Depends(get_current_user)):
return {"system": system_settings}
async def get_system_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"system": await get_setting_payload(db, "system")}
@router.put("/system")
async def update_system_settings(
settings: SystemSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
global system_settings
system_settings = settings.model_dump()
return {"status": "updated", "system": system_settings}
payload = await save_setting_payload(db, "system", settings.model_dump())
return {"status": "updated", "system": payload}
@router.get("/notifications")
async def get_notification_settings(current_user: User = Depends(get_current_user)):
return {"notifications": notification_settings}
async def get_notification_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"notifications": await get_setting_payload(db, "notifications")}
@router.put("/notifications")
async def update_notification_settings(
settings: NotificationSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
global notification_settings
notification_settings = settings.model_dump()
return {"status": "updated", "notifications": notification_settings}
payload = await save_setting_payload(db, "notifications", settings.model_dump())
return {"status": "updated", "notifications": payload}
@router.get("/security")
async def get_security_settings(current_user: User = Depends(get_current_user)):
return {"security": security_settings}
async def get_security_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"security": await get_setting_payload(db, "security")}
@router.put("/security")
async def update_security_settings(
settings: SecuritySettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
global security_settings
security_settings = settings.model_dump()
return {"status": "updated", "security": security_settings}
payload = await save_setting_payload(db, "security", settings.model_dump())
return {"status": "updated", "security": payload}
@router.get("/collectors")
async def get_collector_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
datasources = result.scalars().all()
return {"collectors": [serialize_collector(datasource) for datasource in datasources]}
@router.put("/collectors/{datasource_id}")
async def update_collector_settings(
datasource_id: int,
settings: CollectorSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
datasource = await db.get(DataSource, datasource_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
datasource.is_active = settings.is_active
datasource.priority = settings.priority
datasource.frequency_minutes = settings.frequency_minutes
await db.commit()
await db.refresh(datasource)
await sync_datasource_job(datasource.id)
return {"status": "updated", "collector": serialize_collector(datasource)}
@router.get("")
async def get_all_settings(current_user: User = Depends(get_current_user)):
async def get_all_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
datasources = result.scalars().all()
return {
"system": system_settings,
"notifications": notification_settings,
"security": security_settings,
"system": await get_setting_payload(db, "system"),
"notifications": await get_setting_payload(db, "notifications"),
"security": await get_setting_payload(db, "security"),
"collectors": [serialize_collector(datasource) for datasource in datasources],
"generated_at": datetime.utcnow().isoformat() + "Z",
}

View File

@@ -0,0 +1,126 @@
"""Default built-in datasource definitions."""
DEFAULT_DATASOURCES = {
"top500": {
"id": 1,
"name": "TOP500 Supercomputers",
"module": "L1",
"priority": "P0",
"frequency_minutes": 240,
},
"epoch_ai_gpu": {
"id": 2,
"name": "Epoch AI GPU Clusters",
"module": "L1",
"priority": "P0",
"frequency_minutes": 360,
},
"huggingface_models": {
"id": 3,
"name": "HuggingFace Models",
"module": "L2",
"priority": "P1",
"frequency_minutes": 720,
},
"huggingface_datasets": {
"id": 4,
"name": "HuggingFace Datasets",
"module": "L2",
"priority": "P1",
"frequency_minutes": 720,
},
"huggingface_spaces": {
"id": 5,
"name": "HuggingFace Spaces",
"module": "L2",
"priority": "P2",
"frequency_minutes": 1440,
},
"peeringdb_ixp": {
"id": 6,
"name": "PeeringDB IXP",
"module": "L2",
"priority": "P1",
"frequency_minutes": 1440,
},
"peeringdb_network": {
"id": 7,
"name": "PeeringDB Networks",
"module": "L2",
"priority": "P2",
"frequency_minutes": 2880,
},
"peeringdb_facility": {
"id": 8,
"name": "PeeringDB Facilities",
"module": "L2",
"priority": "P2",
"frequency_minutes": 2880,
},
"telegeography_cables": {
"id": 9,
"name": "Submarine Cables",
"module": "L2",
"priority": "P1",
"frequency_minutes": 10080,
},
"telegeography_landing": {
"id": 10,
"name": "Cable Landing Points",
"module": "L2",
"priority": "P2",
"frequency_minutes": 10080,
},
"telegeography_systems": {
"id": 11,
"name": "Cable Systems",
"module": "L2",
"priority": "P2",
"frequency_minutes": 10080,
},
"arcgis_cables": {
"id": 15,
"name": "ArcGIS Submarine Cables",
"module": "L2",
"priority": "P1",
"frequency_minutes": 10080,
},
"arcgis_landing_points": {
"id": 16,
"name": "ArcGIS Landing Points",
"module": "L2",
"priority": "P1",
"frequency_minutes": 10080,
},
"arcgis_cable_landing_relation": {
"id": 17,
"name": "ArcGIS Cable-Landing Relations",
"module": "L2",
"priority": "P1",
"frequency_minutes": 10080,
},
"fao_landing_points": {
"id": 18,
"name": "FAO Landing Points",
"module": "L2",
"priority": "P1",
"frequency_minutes": 10080,
},
"spacetrack_tle": {
"id": 19,
"name": "Space-Track TLE",
"module": "L3",
"priority": "P2",
"frequency_minutes": 1440,
},
"celestrak_tle": {
"id": 20,
"name": "CelesTrak TLE",
"module": "L3",
"priority": "P2",
"frequency_minutes": 1440,
},
}
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
COLLECTOR_TO_ID = {name: info["id"] for name, info in DEFAULT_DATASOURCES.items()}

View File

@@ -25,11 +25,52 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
raise
async def seed_default_datasources(session: AsyncSession):
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.models.datasource import DataSource
for source, info in DEFAULT_DATASOURCES.items():
existing = await session.get(DataSource, info["id"])
if existing:
existing.name = info["name"]
existing.source = source
existing.module = info["module"]
existing.priority = info["priority"]
existing.frequency_minutes = info["frequency_minutes"]
existing.collector_class = source
if existing.config is None:
existing.config = "{}"
continue
session.add(
DataSource(
id=info["id"],
name=info["name"],
source=source,
module=info["module"],
priority=info["priority"],
frequency_minutes=info["frequency_minutes"],
collector_class=source,
config="{}",
is_active=True,
)
)
await session.commit()
async def init_db():
import app.models.user # noqa: F401
import app.models.gpu_cluster # noqa: F401
import app.models.task # noqa: F401
import app.models.datasource # noqa: F401
import app.models.datasource_config # noqa: F401
import app.models.alert # noqa: F401
import app.models.collected_data # noqa: F401
import app.models.system_setting # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session_factory() as session:
await seed_default_datasources(session)

View File

@@ -2,15 +2,14 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
from app.core.websocket.broadcaster import broadcaster
from app.db.session import init_db, async_session_factory
from app.api.main import api_router
from app.api.v1 import websocket
from app.services.scheduler import start_scheduler, stop_scheduler
from app.core.config import settings
from app.core.websocket.broadcaster import broadcaster
from app.db.session import init_db
from app.services.scheduler import start_scheduler, stop_scheduler, sync_scheduler_with_datasources
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
@@ -28,6 +27,7 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware):
async def lifespan(app: FastAPI):
await init_db()
start_scheduler()
await sync_scheduler_with_datasources()
broadcaster.start()
yield
broadcaster.stop()
@@ -60,16 +60,11 @@ app.include_router(websocket.router)
@app.get("/health")
async def health_check():
"""健康检查端点"""
return {
"status": "healthy",
"version": settings.VERSION,
}
return {"status": "healthy", "version": settings.VERSION}
@app.get("/")
async def root():
"""API根目录"""
return {
"name": settings.PROJECT_NAME,
"version": settings.VERSION,
@@ -80,7 +75,6 @@ async def root():
@app.get("/api/v1/scheduler/jobs")
async def get_scheduler_jobs():
"""获取调度任务列表"""
from app.services.scheduler import get_scheduler_jobs
return {"jobs": get_scheduler_jobs()}

View File

@@ -2,13 +2,17 @@ from app.models.user import User
from app.models.gpu_cluster import GPUCluster
from app.models.task import CollectionTask
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.models.system_setting import SystemSetting
__all__ = [
"User",
"GPUCluster",
"CollectionTask",
"DataSource",
"DataSourceConfig",
"SystemSetting",
"Alert",
"AlertSeverity",
"AlertStatus",

View File

@@ -0,0 +1,19 @@
"""Persistent system settings model."""
from sqlalchemy import JSON, Column, DateTime, Integer, String, UniqueConstraint
from sqlalchemy.sql import func
from app.db.session import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
__table_args__ = (UniqueConstraint("category", name="uq_system_settings_category"),)
id = Column(Integer, primary_key=True, autoincrement=True)
category = Column(String(50), nullable=False)
payload = Column(JSON, nullable=False, default={})
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f"<SystemSetting {self.category}>"

View File

@@ -1,15 +1,16 @@
"""Task Scheduler for running collection jobs"""
"""Task Scheduler for running collection jobs."""
import asyncio
import logging
from datetime import datetime
from typing import Dict, Any
from typing import Any, Dict
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import async_session_factory
from app.models.datasource import DataSource
from app.services.collectors.registry import collector_registry
logger = logging.getLogger(__name__)
@@ -17,77 +18,119 @@ logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
COLLECTOR_TO_ID = {
"top500": 1,
"epoch_ai_gpu": 2,
"huggingface_models": 3,
"huggingface_datasets": 4,
"huggingface_spaces": 5,
"peeringdb_ixp": 6,
"peeringdb_network": 7,
"peeringdb_facility": 8,
"telegeography_cables": 9,
"telegeography_landing": 10,
"telegeography_systems": 11,
"arcgis_cables": 15,
"arcgis_landing_points": 16,
"arcgis_cable_landing_relation": 17,
"fao_landing_points": 18,
"spacetrack_tle": 19,
"celestrak_tle": 20,
}
async def _update_next_run_at(datasource: DataSource, session) -> None:
job = scheduler.get_job(datasource.source)
datasource.next_run_at = job.next_run_time if job else None
await session.commit()
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
collector = collector_registry.get(datasource.source)
if not collector:
logger.warning("Collector not found for datasource %s", datasource.source)
return
collector_registry.set_active(datasource.source, datasource.is_active)
existing_job = scheduler.get_job(datasource.source)
if existing_job:
scheduler.remove_job(datasource.source)
if datasource.is_active:
scheduler.add_job(
run_collector_task,
trigger=IntervalTrigger(minutes=max(1, datasource.frequency_minutes)),
id=datasource.source,
name=datasource.name,
replace_existing=True,
kwargs={"collector_name": datasource.source},
)
logger.info(
"Scheduled collector: %s (every %sm)",
datasource.source,
datasource.frequency_minutes,
)
else:
logger.info("Collector disabled: %s", datasource.source)
await _update_next_run_at(datasource, session)
async def run_collector_task(collector_name: str):
"""Run a single collector task"""
"""Run a single collector task."""
collector = collector_registry.get(collector_name)
if not collector:
logger.error(f"Collector not found: {collector_name}")
logger.error("Collector not found: %s", collector_name)
return
# Get the correct datasource_id
datasource_id = COLLECTOR_TO_ID.get(collector_name, 1)
async with async_session_factory() as db:
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
datasource = result.scalar_one_or_none()
if not datasource:
logger.error("Datasource not found for collector: %s", collector_name)
return
if not datasource.is_active:
logger.info("Skipping disabled collector: %s", collector_name)
return
try:
# Set the datasource_id on the collector instance
collector._datasource_id = datasource_id
logger.info(f"Running collector: {collector_name} (datasource_id={datasource_id})")
result = await collector.run(db)
logger.info(f"Collector {collector_name} completed: {result}")
except Exception as e:
logger.error(f"Collector {collector_name} failed: {e}")
collector._datasource_id = datasource.id
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
task_result = await collector.run(db)
datasource.last_run_at = datetime.utcnow()
datasource.last_status = task_result.get("status")
await _update_next_run_at(datasource, db)
logger.info("Collector %s completed: %s", collector_name, task_result)
except Exception as exc:
datasource.last_run_at = datetime.utcnow()
datasource.last_status = "failed"
await db.commit()
logger.exception("Collector %s failed: %s", collector_name, exc)
def start_scheduler():
"""Start the scheduler with all registered collectors"""
collectors = collector_registry.all()
for name, collector in collectors.items():
if collector_registry.is_active(name):
scheduler.add_job(
run_collector_task,
trigger=IntervalTrigger(hours=collector.frequency_hours),
id=name,
name=name,
replace_existing=True,
kwargs={"collector_name": name},
)
logger.info(f"Scheduled collector: {name} (every {collector.frequency_hours}h)")
scheduler.start()
logger.info("Scheduler started")
def start_scheduler() -> None:
"""Start the scheduler."""
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started")
def stop_scheduler():
"""Stop the scheduler"""
scheduler.shutdown()
logger.info("Scheduler stopped")
def stop_scheduler() -> None:
"""Stop the scheduler."""
if scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler stopped")
async def sync_scheduler_with_datasources() -> None:
"""Synchronize scheduler jobs with datasource table."""
async with async_session_factory() as db:
result = await db.execute(select(DataSource).order_by(DataSource.id))
datasources = result.scalars().all()
configured_sources = {datasource.source for datasource in datasources}
for job in list(scheduler.get_jobs()):
if job.id not in configured_sources:
scheduler.remove_job(job.id)
for datasource in datasources:
await _apply_datasource_schedule(datasource, db)
async def sync_datasource_job(datasource_id: int) -> bool:
"""Synchronize a single datasource job after settings changes."""
async with async_session_factory() as db:
datasource = await db.get(DataSource, datasource_id)
if not datasource:
return False
await _apply_datasource_schedule(datasource, db)
return True
def get_scheduler_jobs() -> list[Dict[str, Any]]:
"""Get all scheduled jobs"""
"""Get all scheduled jobs."""
jobs = []
for job in scheduler.get_jobs():
jobs.append(
@@ -101,52 +144,17 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]:
return jobs
def add_job(collector_name: str, hours: int = 4):
"""Add a new scheduled job"""
collector = collector_registry.get(collector_name)
if not collector:
raise ValueError(f"Collector not found: {collector_name}")
scheduler.add_job(
run_collector_task,
trigger=IntervalTrigger(hours=hours),
id=collector_name,
name=collector_name,
replace_existing=True,
kwargs={"collector_name": collector_name},
)
logger.info(f"Added scheduled job: {collector_name} (every {hours}h)")
def remove_job(collector_name: str):
"""Remove a scheduled job"""
scheduler.remove_job(collector_name)
logger.info(f"Removed scheduled job: {collector_name}")
def pause_job(collector_name: str):
"""Pause a scheduled job"""
scheduler.pause_job(collector_name)
logger.info(f"Paused job: {collector_name}")
def resume_job(collector_name: str):
"""Resume a scheduled job"""
scheduler.resume_job(collector_name)
logger.info(f"Resumed job: {collector_name}")
def run_collector_now(collector_name: str) -> bool:
"""Run a collector immediately (not scheduled)"""
"""Run a collector immediately (not scheduled)."""
collector = collector_registry.get(collector_name)
if not collector:
logger.error(f"Collector not found: {collector_name}")
logger.error("Collector not found: %s", collector_name)
return False
try:
asyncio.create_task(run_collector_task(collector_name))
logger.info(f"Triggered collector: {collector_name}")
logger.info("Triggered collector: %s", collector_name)
return True
except Exception as e:
logger.error(f"Failed to trigger collector {collector_name}: {e}")
except Exception as exc:
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
return False

View File

@@ -0,0 +1,47 @@
# 系统配置中心开发计划
## 目标
将当前仅保存于内存中的“系统配置”页面升级为真正可用的配置中心,优先服务以下两类能力:
1. 系统级配置持久化
2. 采集调度配置管理
## 第一阶段范围
### 1. 系统配置持久化
- 新增 `system_settings` 表,用于保存分类配置
- 将系统、通知、安全配置从进程内存迁移到数据库
- 提供统一读取接口,页面刷新和服务重启后保持不丢失
### 2. 采集调度配置接入真实数据源
- 统一内置采集器默认定义
- 启动时自动初始化 `data_sources`
- 配置页允许修改:
- 是否启用
- 采集频率(分钟)
- 优先级
- 修改后实时同步到调度器
### 3. 前端配置页重构
- 将当前通用模板页调整为项目专用配置中心
- 增加“采集调度”Tab
- 保留“系统显示 / 通知 / 安全”三类配置
- 将设置页正式接入主路由
## 非本阶段内容
- 邮件发送能力本身
- 配置审计历史
- 敏感凭证加密管理
- 多租户或按角色细粒度配置
## 验收标准
- 设置项修改后重启服务仍然存在
- 配置页可以查看并修改所有内置采集器的启停与采集频率
- 调整采集频率后,调度器任务随之更新
- `/settings` 页面可从主导航进入并正常工作

View File

@@ -6,6 +6,7 @@ import Users from './pages/Users/Users'
import DataSources from './pages/DataSources/DataSources'
import DataList from './pages/DataList/DataList'
import Earth from './pages/Earth/Earth'
import Settings from './pages/Settings/Settings'
function App() {
const { token } = useAuthStore()
@@ -23,6 +24,7 @@ function App() {
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -1,5 +1,5 @@
import { ReactNode, useState } from 'react'
import { Layout, Menu, Typography, Button } from 'antd'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
DashboardOutlined,
DatabaseOutlined,
@@ -12,7 +12,7 @@ import {
import { Link, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
const { Header, Sider, Content } = Layout
const { Sider, Content } = Layout
const { Text } = Typography
interface AppLayoutProps {
@@ -23,6 +23,7 @@ function AppLayout({ children }: AppLayoutProps) {
const location = useLocation()
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
const showBanner = true
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
@@ -35,42 +36,55 @@ function AppLayout({ children }: AppLayoutProps) {
return (
<Layout className="dashboard-layout">
<Sider
width={240}
collapsedWidth={80}
width={208}
collapsedWidth={72}
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
className="dashboard-sider"
>
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{collapsed ? (
<Text strong style={{ color: 'white', fontSize: 20 }}>🌏</Text>
) : (
<Text strong style={{ color: 'white', fontSize: 18 }}></Text>
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
/>
</Sider>
<Layout>
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px' }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: 16 }}
/>
<Text strong>, {user?.username}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button type="link" danger onClick={logout}>退</Button>
<div className="dashboard-sider-inner">
<div>
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={(event) => {
event.stopPropagation()
setCollapsed(!collapsed)
}}
className={`dashboard-sider-toggle ${collapsed ? 'dashboard-sider-toggle--collapsed' : ''}`}
/>
{!collapsed ? (
<Text strong style={{ color: 'white', fontSize: 18 }}></Text>
) : null}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
/>
</div>
</Header>
<Content className="dashboard-content" style={{ padding: 24, minHeight: '100%', overflow: 'auto' }}>
{children}
{showBanner && !collapsed ? (
<div className="dashboard-sider-banner">
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div>
<Text className="dashboard-sider-banner-label"></Text>
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
</div>
<Button type="primary" danger ghost block onClick={logout}>
退
</Button>
</Space>
</div>
) : null}
</div>
</Sider>
<Layout style={{ minWidth: 0, minHeight: 0, height: '100%' }}>
<Content className="dashboard-content" style={{ padding: 24, minHeight: 0, height: '100%' }}>
<div className="dashboard-content-inner">{children}</div>
</Content>
</Layout>
</Layout>

View File

@@ -31,29 +31,247 @@ body {
}
.dashboard-layout {
min-height: 100vh;
height: 100vh;
}
.dashboard-layout .ant-layout,
.dashboard-layout .ant-layout-content {
min-width: 0;
min-height: 0;
}
.dashboard-layout > .ant-layout {
height: 100%;
}
.dashboard-sider {
background: #001529 !important;
}
.ant-layout-sider-trigger {
display: none !important;
.dashboard-sider-inner {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.dashboard-header {
background: white;
padding: 0 24px;
.dashboard-brand {
position: relative;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
padding-left: 12px;
padding-right: 40px;
}
.dashboard-brand .ant-typography {
margin-right: auto;
padding-left: 24px;
transform: none;
}
.dashboard-brand--collapsed {
cursor: pointer;
}
.dashboard-sider-toggle {
position: absolute;
top: 50%;
right: 10px;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.88) !important;
}
.dashboard-sider-toggle--collapsed {
left: 50%;
right: auto;
width: 32px;
transform: translate(-50%, -50%);
justify-content: center;
}
.dashboard-sider-banner {
margin: 12px;
padding: 14px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.dashboard-sider-banner-label {
display: block;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
}
.dashboard-sider-banner-value {
display: block;
color: white !important;
font-size: 14px;
}
.dashboard-sider-logout {
width: 100%;
color: #ff7875 !important;
}
.ant-layout-sider-trigger {
display: none !important;
}
.dashboard-content {
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
height: 100%;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dashboard-content-inner {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.dashboard-content-inner > * {
min-width: 0;
min-height: 0;
}
.page-shell {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-shell__header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-shell__body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-shell__body > * {
min-width: 0;
min-height: 0;
}
.data-source-tabs-shell,
.data-source-tabs,
.data-source-tabs .ant-tabs-content-holder,
.data-source-tabs .ant-tabs-content,
.data-source-tabs .ant-tabs-tabpane {
min-width: 0;
min-height: 0;
height: 100%;
}
.data-source-tabs-shell {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.data-source-tabs {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.data-source-tabs .ant-tabs-nav {
flex: 0 0 auto;
margin-bottom: 12px;
}
.data-source-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.data-source-tabs .ant-tabs-content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.data-source-tabs .ant-tabs-tabpane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.data-source-custom-tab {
gap: 12px;
}
.data-source-custom-toolbar {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
}
.data-source-table-region {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container,
.data-source-table-region .ant-table,
.data-source-table-region .ant-table-container,
.data-source-table-region .ant-table-body {
height: 100%;
min-height: 0;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container {
display: flex;
flex-direction: column;
}
.data-source-empty-state {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 12px;
}
.stat-card {
@@ -88,37 +306,6 @@ body {
color: #ff4d4f;
}
/* Table column resize */
.ant-table-wrapper .ant-table-thead > tr > th {
position: relative;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::before {
content: '';
width: 2px;
height: 20px;
background: #d9d9d9;
border-radius: 1px;
}
.resize-handle:hover::before {
background: #1890ff;
}
/* Table cell fixed width */
.ant-table-wrapper .ant-table-tbody > tr > td {
max-width: 0;
@@ -126,3 +313,405 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
.ant-table-wrapper {
width: 100%;
}
.ant-table-wrapper .ant-table-container,
.ant-table-wrapper .ant-table-content,
.ant-table-wrapper .ant-table-body {
overflow-x: auto !important;
}
.table-scroll-region {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.table-scroll-region .ant-table-wrapper {
width: 100%;
min-width: 0;
}
.table-scroll-region .ant-table {
min-width: 100%;
}
.data-list-workspace {
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.data-list-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.data-list-controls-shell {
flex: 1 1 auto;
min-height: 0;
}
.data-list-split-layout {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(280px, 0.95fr) 12px minmax(0, 1fr);
gap: 0;
}
.data-list-summary-card,
.data-list-table-shell {
min-width: 0;
min-height: 0;
}
.data-list-summary-card--panel,
.data-list-summary-card--panel .ant-card-body {
height: 100%;
}
.data-list-summary-card--panel .ant-card-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.data-list-summary-card .ant-card-head,
.data-list-table-shell .ant-card-head {
padding-inline: 16px;
}
.data-list-summary-card .ant-card-body {
overflow: auto;
}
.data-list-right-column {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.data-list-summary-treemap {
min-height: 100%;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(56px, 1fr);
grid-auto-flow: dense;
gap: 10px;
}
.data-list-treemap-tile {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.55);
color: #0f172a;
overflow: hidden;
}
.data-list-treemap-tile--ocean {
background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%);
}
.data-list-treemap-tile--sky {
background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 100%);
}
.data-list-treemap-tile--mint {
background: linear-gradient(135deg, #dcfce7 0%, #86efac 100%);
}
.data-list-treemap-tile--amber {
background: linear-gradient(135deg, #fef3c7 0%, #fcd34d 100%);
}
.data-list-treemap-tile--rose {
background: linear-gradient(135deg, #ffe4e6 0%, #fda4af 100%);
}
.data-list-treemap-tile--violet {
background: linear-gradient(135deg, #ede9fe 0%, #c4b5fd 100%);
}
.data-list-treemap-tile--slate {
background: linear-gradient(135deg, #e2e8f0 0%, #94a3b8 100%);
}
.data-list-treemap-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.data-list-treemap-label {
min-width: 0;
font-size: clamp(11px, 0.75vw, 13px);
line-height: 1.2;
color: rgba(15, 23, 42, 0.78);
}
.data-list-treemap-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.data-list-summary-tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
color: #0f172a;
flex: 0 0 auto;
}
.data-list-summary-tile-value {
font-size: clamp(12px, 1vw, 16px);
line-height: 1.1;
color: #0f172a;
}
.data-list-treemap-meta {
color: rgba(15, 23, 42, 0.72) !important;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar {
width: 10px;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.8);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-track {
background: transparent;
}
.data-list-filter-grid {
min-width: 0;
display: flex;
flex-wrap: nowrap;
gap: 10px;
align-items: center;
}
.data-list-filter-grid--balanced > * {
flex: 1 1 0;
min-width: 0;
}
.data-list-filter-grid--header {
padding-bottom: 4px;
}
.data-list-table-shell {
min-height: 0;
display: flex;
flex-direction: column;
}
.data-list-table-shell .ant-card-body {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.data-list-table-header {
padding: 12px 14px 0 14px;
flex: 0 0 auto;
}
.data-list-table-header--with-filters {
display: flex;
flex-direction: column;
gap: 10px;
}
.data-list-table-header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.data-list-table-region {
flex: 1 1 auto;
min-height: 0;
overflow: visible;
}
.data-list-table-region .ant-table-wrapper,
.data-list-table-region .ant-spin-nested-loading,
.data-list-table-region .ant-spin-container {
height: 100%;
min-height: 0;
}
.data-list-table-region .ant-table-wrapper {
display: flex;
flex-direction: column;
}
.data-list-table-region .ant-spin-nested-loading,
.data-list-table-region .ant-spin-container {
display: flex;
flex-direction: column;
}
.data-list-table-region .ant-table {
flex: 1 1 auto;
}
.data-list-table-region .ant-table-pagination {
flex: 0 0 auto;
margin: 12px 0 0;
}
.data-list-resize-handle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
touch-action: none;
}
.data-list-resize-handle::before {
content: '';
display: block;
border-radius: 999px;
background: #d0d7e2;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.data-list-resize-handle:hover::before {
background: #8fb4ff;
}
.data-list-resize-handle--vertical {
cursor: col-resize;
}
.data-list-resize-handle--vertical::before {
width: 4px;
height: 56px;
}
.data-list-resize-handle--horizontal {
cursor: row-resize;
}
.data-list-resize-handle--horizontal::before {
width: 56px;
height: 4px;
}
@media (min-width: 1201px) and (orientation: landscape) {
.data-list-summary-treemap {
grid-auto-rows: minmax(48px, 1fr);
}
.data-list-treemap-tile {
padding: 10px 12px;
}
.data-list-summary-tile-value {
font-size: 15px;
}
}
@media (max-width: 1200px) {
.data-list-summary-treemap {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.data-list-split-layout {
grid-template-columns: minmax(240px, 0.9fr) 12px minmax(0, 1fr);
}
}
@media (max-width: 992px) {
.dashboard-content {
padding: 12px;
}
.data-list-workspace {
gap: 10px;
}
.data-list-topbar {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.data-list-split-layout {
grid-template-columns: 1fr;
gap: 10px;
height: auto;
}
.data-list-right-column {
grid-template-rows: auto auto;
gap: 10px;
height: auto;
}
.data-list-summary-treemap {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(88px, 1fr);
}
.data-list-filter-grid {
flex-wrap: wrap;
}
.data-list-filter-grid--balanced > * {
flex: 1 1 180px;
min-width: 160px;
}
}
@media (max-width: 640px) {
.data-list-summary-treemap {
grid-template-columns: 1fr;
}
.data-list-filter-grid {
flex-wrap: wrap;
}
.data-list-filter-grid--balanced > * {
flex-basis: 100%;
min-width: 100%;
}
}

View File

@@ -174,7 +174,9 @@ function Alerts() {
title="告警列表"
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}></Button>}
>
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
<div className="table-scroll-region">
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</div>
</Card>
<Modal

View File

@@ -1,20 +1,16 @@
import { useEffect, useState } from 'react'
import { Layout, Menu, Card, Row, Col, Statistic, Typography, Button, Tag, Spin } from 'antd'
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space } from 'antd'
import {
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
SettingOutlined,
BarChartOutlined,
AlertOutlined,
WifiOutlined,
DisconnectOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
const { Header, Sider, Content } = Layout
const { Title, Text } = Typography
interface Stats {
@@ -31,7 +27,7 @@ interface Stats {
}
function Dashboard() {
const { user, logout, token, clearAuth } = useAuthStore()
const { token, clearAuth } = useAuthStore()
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [wsConnected, setWsConnected] = useState(false)
@@ -63,7 +59,7 @@ function Dashboard() {
}
fetchStats()
}, [token])
}, [token, clearAuth])
useEffect(() => {
if (!token) return
@@ -112,28 +108,10 @@ function Dashboard() {
}
}, [token])
const handleLogout = () => {
logout()
window.location.href = '/'
}
const handleClearAuth = () => {
clearAuth()
window.location.href = '/'
}
const handleRetry = () => {
window.location.reload()
}
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources"></Link> },
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data"></Link> },
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users"></Link> },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
]
if (loading && !stats) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
@@ -143,81 +121,78 @@ function Dashboard() {
}
return (
<Layout className="dashboard-layout">
<Sider width={240} className="dashboard-sider">
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title level={4} style={{ color: 'white', margin: 0 }}></Title>
</div>
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/']} items={menuItems} />
</Sider>
<Layout>
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text strong>, {user?.username}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<AppLayout>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text>
</div>
<Space wrap>
{wsConnected ? (
<Tag icon={<WifiOutlined />} color="success"></Tag>
) : (
<Tag icon={<DisconnectOutlined />} color="default">线</Tag>
)}
<Button type="link" danger onClick={handleLogout}>退</Button>
<Button type="link" onClick={handleClearAuth}></Button>
<Button type="link" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
</div>
</Header>
<Content className="dashboard-content">
{error && (
<Card style={{ marginBottom: 16, borderColor: '#ff4d4f' }}>
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
<Button type="default" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
</Space>
</div>
{error && (
<Card style={{ borderColor: '#ff4d4f' }}>
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
</Card>
)}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
</Card>
)}
<Row gutter={[16, 16]}>
<Col span={6}>
<Card>
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
</Card>
</Col>
</Row>
{stats?.last_updated && (
<div style={{ marginTop: 16, textAlign: 'center', color: '#8c8c8c' }}>
: {new Date(stats.last_updated).toLocaleString('zh-CN')}
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}
</Content>
</Layout>
</Layout>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="警" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
</Card>
</Col>
</Row>
{stats?.last_updated && (
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
: {new Date(stats.last_updated).toLocaleString('zh-CN')}
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}
</div>
</AppLayout>
)
}

View File

@@ -1,16 +1,19 @@
import { useEffect, useState, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Table, Tag, Space, Card, Row, Col, Select, Input, Button,
Statistic, Modal, Descriptions, Spin, Empty, Tooltip
Table, Tag, Space, Card, Select, Input, Button,
Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
AppstoreOutlined, EyeOutlined, SearchOutlined
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
const { Title, Text } = Typography
const { useBreakpoint } = Grid
interface CollectedData {
id: number
source: string
@@ -38,6 +41,21 @@ interface Summary {
}
function DataList() {
const screens = useBreakpoint()
const isCompact = !screens.lg
const topbarRef = useRef<HTMLDivElement | null>(null)
const workspaceRef = useRef<HTMLDivElement | null>(null)
const mainAreaRef = useRef<HTMLDivElement | null>(null)
const rightColumnRef = useRef<HTMLDivElement | null>(null)
const tableHeaderRef = useRef<HTMLDivElement | null>(null)
const hasCustomLeftWidthRef = useRef(false)
const [mainAreaWidth, setMainAreaWidth] = useState(0)
const [mainAreaHeight, setMainAreaHeight] = useState(0)
const [rightColumnHeight, setRightColumnHeight] = useState(0)
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [data, setData] = useState<CollectedData[]>([])
const [loading, setLoading] = useState(false)
const [summary, setSummary] = useState<Summary | null>(null)
@@ -55,6 +73,73 @@ function DataList() {
const [detailData, setDetailData] = useState<CollectedData | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
useEffect(() => {
const updateLayout = () => {
setMainAreaWidth(mainAreaRef.current?.offsetWidth || 0)
setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0)
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
}
updateLayout()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateLayout)
if (workspaceRef.current) observer.observe(workspaceRef.current)
if (topbarRef.current) observer.observe(topbarRef.current)
if (mainAreaRef.current) observer.observe(mainAreaRef.current)
if (rightColumnRef.current) observer.observe(rightColumnRef.current)
if (tableHeaderRef.current) observer.observe(tableHeaderRef.current)
return () => observer.disconnect()
}, [isCompact])
useEffect(() => {
if (isCompact || mainAreaWidth === 0) {
return
}
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
setLeftPanelWidth((current) => {
if (!hasCustomLeftWidthRef.current) {
return preferredLeft
}
return Math.max(minLeft, Math.min(current, maxLeft))
})
}, [isCompact, mainAreaWidth])
const beginHorizontalResize = (event: React.MouseEvent<HTMLDivElement>) => {
if (isCompact) return
event.preventDefault()
hasCustomLeftWidthRef.current = true
const startX = event.clientX
const startWidth = leftPanelWidth
const containerWidth = mainAreaRef.current?.offsetWidth || 0
const onMove = (moveEvent: MouseEvent) => {
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, containerWidth - minRight - 12)
const nextWidth = startWidth + moveEvent.clientX - startX
setLeftPanelWidth(Math.max(minLeft, Math.min(nextWidth, maxLeft)))
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const fetchData = async () => {
setLoading(true)
try {
@@ -115,6 +200,15 @@ function DataList() {
fetchData()
}
const handleReset = () => {
setSourceFilter(undefined)
setTypeFilter(undefined)
setCountryFilter(undefined)
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
}
const handleViewDetail = async (id: number) => {
setDetailVisible(true)
setDetailLoading(true)
@@ -130,102 +224,115 @@ function DataList() {
const getSourceIcon = (source: string) => {
const iconMap: Record<string, React.ReactNode> = {
'top500': <CloudServerOutlined />,
'huggingface_models': <AppstoreOutlined />,
'huggingface_datasets': <DatabaseOutlined />,
'huggingface_spaces': <AppstoreOutlined />,
'telegeography_cables': <GlobalOutlined />,
'epoch_ai_gpu': <CloudServerOutlined />,
top500: <CloudServerOutlined />,
huggingface_models: <AppstoreOutlined />,
huggingface_datasets: <DatabaseOutlined />,
huggingface_spaces: <AppstoreOutlined />,
telegeography_cables: <GlobalOutlined />,
epoch_ai_gpu: <CloudServerOutlined />,
}
return iconMap[source] || <DatabaseOutlined />
}
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
'supercomputer': 'red',
'model': 'blue',
'dataset': 'green',
'space': 'purple',
'submarine_cable': 'cyan',
'gpu_cluster': 'orange',
'ixp': 'magenta',
'network': 'gold',
'facility': 'lime',
supercomputer: 'red',
model: 'blue',
dataset: 'green',
space: 'purple',
submarine_cable: 'cyan',
gpu_cluster: 'orange',
ixp: 'magenta',
network: 'gold',
facility: 'lime',
}
return colors[type] || 'default'
}
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>({
id: 60,
name: 300,
source: 150,
data_type: 100,
country: 100,
value: 100,
collected_at: 160,
action: 80,
})
const activeFilterCount = useMemo(
() => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, countryFilter, searchText]
)
const resizeRef = useRef<{ startX: number; startWidth: number; key: string } | null>(null)
const summaryItems = useMemo(() => {
const items = [
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
{ key: 'sources', label: '数据源数', value: sources.length, icon: <DatabaseOutlined /> },
]
const handleResizeStart = (key: string) => (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
resizeRef.current = {
startX: e.clientX,
startWidth: columnsWidth[key],
key,
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
items.push({
key: item.source,
label: item.source,
value: item.count,
icon: getSourceIcon(item.source),
})
}
document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', handleResizeEnd)
}
const handleResizeMove = (e: MouseEvent) => {
if (!resizeRef.current) return
const diff = e.clientX - resizeRef.current.startX
const newWidth = Math.max(50, resizeRef.current.startWidth + diff)
setColumnsWidth((prev) => ({
...prev,
[resizeRef.current!.key]: newWidth,
}))
}
return items
}, [summary, total, activeFilterCount, isCompact, sources.length])
const handleResizeEnd = () => {
resizeRef.current = null
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', handleResizeEnd)
}
const treemapColumns = useMemo(() => {
if (isCompact) return 1
if (leftPanelWidth < 360) return 2
if (leftPanelWidth < 520) return 3
return 4
}, [isCompact, leftPanelWidth])
const treemapRowHeight = useMemo(() => {
if (isCompact) return 88
if (leftPanelWidth < 360) return 44
if (leftPanelWidth < 520) return 48
return 56
}, [isCompact, leftPanelWidth])
const treemapItems = useMemo(() => {
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
const allowTallTiles = !isCompact && leftPanelWidth >= 520
return summaryItems.map((item, index) => {
const ratio = item.value / maxValue
let colSpan = 1
let rowSpan = 1
if (allowTallTiles && index === 0) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowTallTiles && ratio >= 0.7) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowTallTiles && ratio >= 0.35) {
rowSpan = 2
}
return {
...item,
colSpan,
rowSpan,
tone: palette[index % palette.length],
}
})
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
const pageHeight = '100%'
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
const compactTableHeight = mainAreaHeight - tableHeaderHeight - 156
const tableHeight = Math.max(180, isCompact ? compactTableHeight : desktopTableHeight)
const splitLayoutStyle = isCompact
? undefined
: { gridTemplateColumns: `${leftPanelWidth}px 12px minmax(0, 1fr)` }
const columns: ColumnsType<CollectedData> = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span>ID</span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('id')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'id',
key: 'id',
width: columnsWidth.id,
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('name')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '名称',
dataIndex: 'name',
key: 'name',
width: columnsWidth.name,
width: 280,
ellipsis: true,
render: (name: string, record: CollectedData) => (
<Tooltip title={name}>
@@ -236,101 +343,40 @@ function DataList() {
),
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('source')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '数据源',
dataIndex: 'source',
key: 'source',
width: columnsWidth.source,
render: (source: string) => (
<Tag icon={getSourceIcon(source)}>{source}</Tag>
),
width: 170,
render: (source: string) => <Tag icon={getSourceIcon(source)}>{source}</Tag>,
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('data_type')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '类型',
dataIndex: 'data_type',
key: 'data_type',
width: columnsWidth.data_type,
render: (type: string) => (
<Tag color={getTypeColor(type)}>{type}</Tag>
),
width: 120,
render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>,
},
{ title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true },
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span>/</span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('country')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'country',
key: 'country',
width: columnsWidth.country,
ellipsis: true,
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('value')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '数值',
dataIndex: 'value',
key: 'value',
width: columnsWidth.value,
render: (value: string | null, record: CollectedData) => (
value ? `${value} ${record.unit || ''}` : '-'
),
width: 140,
render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'),
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('collected_at')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '采集时间',
dataIndex: 'collected_at',
key: 'collected_at',
width: columnsWidth.collected_at,
width: 180,
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: columnsWidth.action,
width: 96,
render: (_: unknown, record: CollectedData) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
),
@@ -339,93 +385,160 @@ function DataList() {
return (
<AppLayout>
<h2></h2>
<div ref={workspaceRef} className="data-list-workspace" style={{ height: pageHeight }}>
<div ref={topbarRef} className="data-list-topbar">
<div>
<Title level={4} style={{ margin: 0 }}></Title>
</div>
<Space size={8} wrap>
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
{total.toLocaleString()}
</Tag>
<Tag color="default" style={{ marginInlineEnd: 0 }}>
{activeFilterCount}
</Tag>
</Space>
</div>
{/* Summary Cards */}
{summary && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="总记录数"
value={summary.total_records}
prefix={<DatabaseOutlined />}
/>
<div ref={mainAreaRef} className="data-list-controls-shell">
<div className="data-list-split-layout" style={splitLayoutStyle}>
<Card
className="data-list-summary-card data-list-summary-card--panel"
title="数据概览"
size="small"
bodyStyle={{ padding: isCompact ? 12 : 16 }}
>
<div
className="data-list-summary-treemap"
style={{
gridTemplateColumns: `repeat(${treemapColumns}, minmax(0, 1fr))`,
gridAutoRows: `minmax(${treemapRowHeight}px, 1fr)`,
}}
>
{treemapItems.map((item) => (
<div
key={item.key}
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
style={{
gridColumn: `span ${item.colSpan}`,
gridRow: `span ${item.rowSpan}`,
}}
>
<div className="data-list-treemap-head">
<span className="data-list-summary-tile-icon">{item.icon}</span>
<Text className="data-list-treemap-label">{item.label}</Text>
</div>
<div className="data-list-treemap-body">
<Text strong className="data-list-summary-tile-value">
{item.value.toLocaleString()}
</Text>
</div>
</div>
))}
</div>
</Card>
</Col>
{summary.source_totals.slice(0, 4).map((item) => (
<Col span={6} key={item.source}>
<Card>
<Statistic
title={item.source}
value={item.count}
prefix={getSourceIcon(item.source)}
/>
{!isCompact && (
<div
className="data-list-resize-handle data-list-resize-handle--vertical"
onMouseDown={beginHorizontalResize}
role="separator"
aria-orientation="vertical"
aria-label="调整左右分栏宽度"
/>
)}
<div ref={rightColumnRef} className="data-list-right-column">
<Card className="data-list-table-shell" bodyStyle={{ padding: 0 }}>
<div ref={tableHeaderRef} className="data-list-table-header data-list-table-header--with-filters">
<div className="data-list-table-header-main">
<Space size={8} wrap>
<Text strong></Text>
<Text type="secondary"> {total.toLocaleString()} </Text>
</Space>
<Space size={8} wrap>
<Button size="small" onClick={handleReset}></Button>
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}></Button>
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</div>
<div className="data-list-filter-grid data-list-filter-grid--balanced data-list-filter-grid--header">
<Select
size="middle"
placeholder="数据源"
allowClear
value={sourceFilter}
onChange={(value) => {
setSourceFilter(value)
setPage(1)
}}
options={sources.map((source) => ({ label: source, value: source }))}
style={{ width: '100%' }}
/>
<Select
size="middle"
placeholder="数据类型"
allowClear
value={typeFilter}
onChange={(value) => {
setTypeFilter(value)
setPage(1)
}}
options={types.map((type) => ({ label: type, value: type }))}
style={{ width: '100%' }}
/>
<Select
size="middle"
placeholder="国家"
allowClear
value={countryFilter}
onChange={(value) => {
setCountryFilter(value)
setPage(1)
}}
options={countries.map((country) => ({ label: country, value: country }))}
style={{ width: '100%' }}
/>
<Input
size="middle"
placeholder="搜索名称"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
onPressEnter={handleSearch}
/>
</div>
</div>
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
virtual
scroll={{ x: 'max-content', y: tableHeight }}
tableLayout="fixed"
size={isCompact ? 'small' : 'middle'}
pagination={{
current: page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showSizeChanger: true,
showTotal: (count) => `${count}`,
}}
/>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
</div>
</div>
</div>
{/* Filters */}
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Select
placeholder="数据源"
allowClear
style={{ width: 180 }}
value={sourceFilter}
onChange={(v) => { setSourceFilter(v); setPage(1); }}
options={sources.map(s => ({ label: s, value: s }))}
/>
<Select
placeholder="数据类型"
allowClear
style={{ width: 150 }}
value={typeFilter}
onChange={(v) => { setTypeFilter(v); setPage(1); }}
options={types.map(t => ({ label: t, value: t }))}
/>
<Select
placeholder="国家"
allowClear
style={{ width: 150 }}
value={countryFilter}
onChange={(v) => { setCountryFilter(v); setPage(1); }}
options={countries.map(c => ({ label: c, value: c }))}
/>
<Input
placeholder="搜索名称"
style={{ width: 200 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onPressEnter={handleSearch}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</Card>
{/* Data Table */}
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
/>
{/* Detail Modal */}
<Modal
title="数据详情"
open={detailVisible}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Table, Tag, Space, message, Button, Form, Input, Select,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
@@ -67,6 +67,10 @@ function DataSources() {
const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<any>(null)
const builtinTableRegionRef = useRef<HTMLDivElement | null>(null)
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
const [customTableHeight, setCustomTableHeight] = useState(360)
const [form] = Form.useForm()
const fetchData = async () => {
@@ -91,6 +95,28 @@ function DataSources() {
fetchData()
}, [])
useEffect(() => {
const updateHeights = () => {
const builtinRegionHeight = builtinTableRegionRef.current?.offsetHeight || 0
const customRegionHeight = customTableRegionRef.current?.offsetHeight || 0
setBuiltinTableHeight(Math.max(220, builtinRegionHeight - 56))
setCustomTableHeight(Math.max(220, customRegionHeight - 56))
}
updateHeights()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateHeights)
if (builtinTableRegionRef.current) observer.observe(builtinTableRegionRef.current)
if (customTableRegionRef.current) observer.observe(customTableRegionRef.current)
return () => observer.disconnect()
}, [activeTab, builtInSources.length, customSources.length])
useEffect(() => {
const runningSources = builtInSources.filter(s => s.is_running)
if (runningSources.length === 0) return
@@ -440,16 +466,21 @@ function DataSources() {
key: 'builtin',
label: '内置数据源',
children: (
<Table
columns={builtinColumns}
dataSource={builtInSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: 'auto' }}
tableLayout="fixed"
size="small"
/>
<div className="page-shell__body">
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={builtinColumns}
dataSource={builtInSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: builtinTableHeight }}
tableLayout="fixed"
size="small"
virtual
/>
</div>
</div>
),
},
{
@@ -460,35 +491,48 @@ function DataSources() {
</span>
),
children: (
<>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<div className="page-shell__body data-source-custom-tab">
<div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
</div>
{customSources.length === 0 ? (
<Empty description="暂无自定义数据源" />
<div className="data-source-empty-state">
<Empty description="暂无自定义数据源" />
</div>
) : (
<Table
columns={customColumns}
dataSource={customSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 600, y: 'auto' }}
tableLayout="fixed"
size="small"
/>
<div ref={customTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={customColumns}
dataSource={customSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 600, y: customTableHeight }}
tableLayout="fixed"
size="small"
virtual
/>
</div>
)}
</>
</div>
),
},
]
return (
<AppLayout>
<h2></h2>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<div className="page-shell">
<div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2>
</div>
<div className="page-shell__body">
<div className="data-source-tabs-shell">
<Tabs className="data-source-tabs" activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</div>
</div>
</div>
<Drawer
title={editingConfig ? '编辑数据源' : '添加数据源'}

View File

@@ -1,37 +1,23 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import {
Layout,
Menu,
Card,
Row,
Col,
Typography,
Button,
Card,
Form,
Input,
Switch,
Select,
Divider,
message,
Spin,
Tabs,
InputNumber,
message,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import {
SettingOutlined,
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
BellOutlined,
SafetyOutlined,
SaveOutlined,
} from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
const { Header, Sider, Content } = Layout
const { Title, Text } = Typography
const { TabPane } = Tabs
interface SystemSettings {
system_name: string
@@ -55,367 +41,293 @@ interface SecuritySettings {
password_policy: string
}
function Settings() {
const { user, logout, token, clearAuth } = useAuthStore()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
system_name: '智能星球',
refresh_interval: 60,
auto_refresh: true,
data_retention_days: 30,
max_concurrent_tasks: 5,
})
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
email_enabled: false,
email_address: '',
critical_alerts: true,
warning_alerts: true,
daily_summary: false,
})
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
session_timeout: 60,
max_login_attempts: 5,
password_policy: 'medium',
})
const [form] = Form.useForm()
interface CollectorSettings {
id: number
name: string
source: string
module: string
priority: string
frequency_minutes: number
frequency: string
is_active: boolean
last_run_at: string | null
last_status: string | null
next_run_at: string | null
}
useEffect(() => {
if (!token) {
navigate('/')
return
}
fetchSettings()
}, [token, navigate])
function Settings() {
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>()
const fetchSettings = async () => {
try {
setLoading(true)
const res = await fetch('/api/v1/settings/system', {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401) {
clearAuth()
navigate('/')
return
}
if (res.ok) {
const data = await res.json()
setSystemSettings(data.system || systemSettings)
setNotificationSettings(data.notifications || notificationSettings)
setSecuritySettings(data.security || securitySettings)
form.setFieldsValue({
...data.system,
...data.notifications,
...data.security,
})
}
} catch (err) {
message.error('获取设置失败')
console.error(err)
const response = await axios.get('/api/v1/settings')
systemForm.setFieldsValue(response.data.system)
notificationForm.setFieldsValue(response.data.notifications)
securityForm.setFieldsValue(response.data.security)
setCollectors(response.data.collectors || [])
} catch (error) {
message.error('获取系统配置失败')
console.error(error)
} finally {
setLoading(false)
}
}
const handleSaveSystem = async (values: any) => {
useEffect(() => {
fetchSettings()
}, [])
const saveSection = async (section: 'system' | 'notifications' | 'security', values: object) => {
try {
setSaving(true)
const res = await fetch('/api/v1/settings/system', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('系统设置已保存')
setSystemSettings(values)
} else {
message.error('保存失败')
}
} catch (err) {
message.error('保存设置失败')
console.error(err)
} finally {
setSaving(false)
await axios.put(`/api/v1/settings/${section}`, values)
message.success('配置已保存')
await fetchSettings()
} catch (error) {
message.error('保存失败')
console.error(error)
}
}
const handleSaveNotifications = async (values: any) => {
try {
setSaving(true)
const res = await fetch('/api/v1/settings/notifications', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('通知设置已保存')
setNotificationSettings(values)
} else {
message.error('保存失败')
}
} catch (err) {
message.error('保存设置失败')
console.error(err)
} finally {
setSaving(false)
}
}
const handleSaveSecurity = async (values: any) => {
try {
setSaving(true)
const res = await fetch('/api/v1/settings/security', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('安全设置已保存')
setSecuritySettings(values)
} else {
message.error('保存失败')
}
} catch (err) {
message.error('保存设置失败')
console.error(err)
} finally {
setSaving(false)
}
}
const handleLogout = () => {
logout()
navigate('/')
}
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources"></Link> },
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users"></Link> },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
]
if (loading && !token) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" tip="加载中..." />
</div>
const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => {
setCollectors((prev) =>
prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector))
)
}
return (
<Layout className="dashboard-layout">
<Sider width={240} className="dashboard-sider">
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title level={4} style={{ color: 'white', margin: 0 }}></Title>
const saveCollector = async (collector: CollectorSettings) => {
try {
setSavingCollectorId(collector.id)
await axios.put(`/api/v1/settings/collectors/${collector.id}`, {
is_active: collector.is_active,
priority: collector.priority,
frequency_minutes: collector.frequency_minutes,
})
message.success(`${collector.name} 配置已更新`)
await fetchSettings()
} catch (error) {
message.error('采集调度配置保存失败')
console.error(error)
} finally {
setSavingCollectorId(null)
}
}
const collectorColumns = [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
render: (_: string, record: CollectorSettings) => (
<div>
<div>{record.name}</div>
<Text type="secondary">{record.source}</Text>
</div>
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/settings']} items={menuItems} />
</Sider>
<Layout>
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text strong>, {user?.username}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button type="link" danger onClick={handleLogout}>退</Button>
</div>
</Header>
<Content className="dashboard-content">
<Title level={3}><SettingOutlined /> </Title>
<Tabs defaultActiveKey="system" tabPosition="left">
<TabPane
tab={<span><SettingOutlined /> </span>}
key="system"
>
<Card title="基本设置">
<Form
form={form}
layout="vertical"
onFinish={handleSaveSystem}
initialValues={systemSettings}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="system_name"
label="系统名称"
rules={[{ required: true, message: '请输入系统名称' }]}
>
<Input placeholder="智能星球" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="refresh_interval"
label="数据刷新间隔 (秒)"
>
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="data_retention_days"
label="数据保留天数"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="max_concurrent_tasks"
label="最大并发任务数"
>
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="auto_refresh"
label="自动刷新"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
</Form.Item>
</Form>
</Card>
</TabPane>
<TabPane
tab={<span><BellOutlined /> </span>}
key="notifications"
>
<Card title="通知配置">
<Form
form={form}
layout="vertical"
onFinish={handleSaveNotifications}
initialValues={notificationSettings}
>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="email_enabled"
label="启用邮件通知"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="email_address"
label="通知邮箱"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input placeholder="admin@example.com" disabled={!notificationSettings.email_enabled} />
</Form.Item>
</Col>
</Row>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="critical_alerts"
label="严重告警"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="warning_alerts"
label="警告告警"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="daily_summary"
label="每日摘要"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
</Form.Item>
</Form>
</Card>
</TabPane>
<TabPane
tab={<span><SafetyOutlined /> </span>}
key="security"
>
<Card title="安全配置">
<Form
form={form}
layout="vertical"
onFinish={handleSaveSecurity}
initialValues={securitySettings}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="session_timeout"
label="会话超时 (分钟)"
>
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="max_login_attempts"
label="最大登录尝试次数"
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="password_policy"
label="密码策略"
>
<Select>
<Select.Option value="low"> (6)</Select.Option>
<Select.Option value="medium"> (8,)</Select.Option>
<Select.Option value="high"> (12,)</Select.Option>
</Select>
</Form.Item>
<Divider />
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
</Form>
</Card>
</TabPane>
</Tabs>
</Content>
</Layout>
</Layout>
),
},
{
title: '层级',
dataIndex: 'module',
key: 'module',
width: 90,
render: (module: string) => <Tag color="blue">{module}</Tag>,
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 130,
render: (priority: string, record: CollectorSettings) => (
<Select
value={priority}
style={{ width: '100%' }}
onChange={(value) => updateCollectorField(record.id, 'priority', value)}
options={[
{ value: 'P0', label: 'P0' },
{ value: 'P1', label: 'P1' },
{ value: 'P2', label: 'P2' },
]}
/>
),
},
{
title: '频率(分钟)',
dataIndex: 'frequency_minutes',
key: 'frequency_minutes',
width: 150,
render: (value: number, record: CollectorSettings) => (
<InputNumber
min={1}
max={10080}
value={value}
style={{ width: '100%' }}
onChange={(nextValue) => updateCollectorField(record.id, 'frequency_minutes', nextValue || 1)}
/>
),
},
{
title: '启用',
dataIndex: 'is_active',
key: 'is_active',
width: 90,
render: (value: boolean, record: CollectorSettings) => (
<Switch checked={value} onChange={(checked) => updateCollectorField(record.id, 'is_active', checked)} />
),
},
{
title: '上次执行',
dataIndex: 'last_run_at',
key: 'last_run_at',
width: 180,
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
},
{
title: '下次执行',
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 180,
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
},
{
title: '状态',
dataIndex: 'last_status',
key: 'last_status',
width: 120,
render: (value: string | null) => {
if (!value) return <Tag></Tag>
const color = value === 'success' ? 'success' : value === 'failed' ? 'error' : 'default'
return <Tag color={color}>{value}</Tag>
},
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right' as const,
render: (_: unknown, record: CollectorSettings) => (
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
</Button>
),
},
]
return (
<AppLayout>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary"></Text>
</div>
<Tabs
items={[
{
key: 'system',
label: '系统显示',
children: (
<Card loading={loading}>
<Form form={systemForm} layout="vertical" onFinish={(values) => saveSection('system', values)}>
<Form.Item name="system_name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}>
<Input />
</Form.Item>
<Form.Item name="refresh_interval" label="默认刷新间隔(秒)">
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="data_retention_days" label="数据保留天数">
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_concurrent_tasks" label="最大并发任务数">
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
),
},
{
key: 'notifications',
label: '通知策略',
children: (
<Card loading={loading}>
<Form form={notificationForm} layout="vertical" onFinish={(values) => saveSection('notifications', values)}>
<Form.Item name="email_enabled" label="启用邮件通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="email_address" label="通知邮箱">
<Input />
</Form.Item>
<Form.Item name="critical_alerts" label="严重告警通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="warning_alerts" label="警告告警通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
),
},
{
key: 'security',
label: '安全策略',
children: (
<Card loading={loading}>
<Form form={securityForm} layout="vertical" onFinish={(values) => saveSection('security', values)}>
<Form.Item name="session_timeout" label="会话超时(分钟)">
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_login_attempts" label="最大登录尝试次数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="password_policy" label="密码策略">
<Select
options={[
{ value: 'low', label: '简单' },
{ value: 'medium', label: '中等' },
{ value: 'high', label: '严格' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
),
},
{
key: 'collectors',
label: '采集调度',
children: (
<Card loading={loading}>
<div className="table-scroll-region">
<Table
rowKey="id"
columns={collectorColumns}
dataSource={collectors}
pagination={false}
scroll={{ x: 1200, y: 'calc(100% - 360px)' }}
/>
</div>
</Card>
),
},
]}
/>
</Space>
</AppLayout>
)
}
export default Settings

View File

@@ -145,7 +145,9 @@ function Tasks() {
</Button>
}
>
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
<div className="table-scroll-region">
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</div>
</Card>
</AppLayout>
)

View File

@@ -115,11 +115,17 @@ function Users() {
return (
<AppLayout>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
<div className="page-shell">
<div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<div className="page-shell__body">
<div className="table-scroll-region" style={{ height: '100%' }}>
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content', y: 'calc(100% - 72px)' }} tableLayout="fixed" />
</div>
</div>
</div>
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content' }} tableLayout="fixed" />
<Modal
title={editingUser ? '编辑用户' : '添加用户'}
open={modalVisible}