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 typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.db.session import get_db 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.datasource import DataSource
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.models.collected_data import CollectedData from app.models.user import User
from app.core.security import get_current_user from app.services.scheduler import run_collector_now, sync_datasource_job
from app.services.collectors.registry import collector_registry
router = APIRouter() 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()} def format_frequency_label(minutes: int) -> str:
COLLECTOR_TO_ID = {name: info["id"] for name, info in COLLECTOR_INFO.items()} 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: try:
numeric_id = int(source_id) datasource = await db.get(DataSource, int(source_id))
if numeric_id in ID_TO_COLLECTOR:
return ID_TO_COLLECTOR[numeric_id]
except ValueError: except ValueError:
pass pass
if source_id in COLLECTOR_INFO:
return source_id if datasource is not None:
return 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("") @router.get("")
@@ -160,48 +71,24 @@ async def list_datasources(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query = select(DataSource) query = select(DataSource).order_by(DataSource.module, DataSource.id)
filters = []
if module: if module:
filters.append(DataSource.module == module) query = query.where(DataSource.module == module)
if is_active is not None: if is_active is not None:
filters.append(DataSource.is_active == is_active) query = query.where(DataSource.is_active == is_active)
if priority: if priority:
filters.append(DataSource.priority == priority) query = query.where(DataSource.priority == priority)
if filters:
query = query.where(*filters)
result = await db.execute(query) result = await db.execute(query)
datasources = result.scalars().all() datasources = result.scalars().all()
collector_list = [] collector_list = []
for name, info in COLLECTOR_INFO.items(): for datasource in datasources:
is_active_status = collector_registry.is_active(name) running_task = await get_running_task(db, datasource.id)
last_task = await get_last_completed_task(db, datasource.id)
running_task_query = ( data_count_result = await db.execute(
select(CollectionTask) select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
.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()
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 data_count = data_count_result.scalar() or 0
last_run = None last_run = None
@@ -210,13 +97,14 @@ async def list_datasources(
collector_list.append( collector_list.append(
{ {
"id": info["id"], "id": datasource.id,
"name": info["name"], "name": datasource.name,
"module": info["module"], "module": datasource.module,
"priority": info["priority"], "priority": datasource.priority,
"frequency": f"{info['frequency_hours']}h", "frequency": format_frequency_label(datasource.frequency_minutes),
"is_active": is_active_status, "frequency_minutes": datasource.frequency_minutes,
"collector_class": name, "is_active": datasource.is_active,
"collector_class": datasource.collector_class,
"last_run": last_run, "last_run": last_run,
"is_running": running_task is not None, "is_running": running_task is not None,
"task_id": running_task.id if running_task else None, "task_id": running_task.id if running_task else None,
@@ -226,15 +114,7 @@ async def list_datasources(
} }
) )
if module: return {"total": len(collector_list), "data": collector_list}
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,
}
@router.get("/{source_id}") @router.get("/{source_id}")
@@ -243,19 +123,20 @@ async def get_datasource(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name]
return { return {
"id": info["id"], "id": datasource.id,
"name": info["name"], "name": datasource.name,
"module": info["module"], "module": datasource.module,
"priority": info["priority"], "priority": datasource.priority,
"frequency": f"{info['frequency_hours']}h", "frequency": format_frequency_label(datasource.frequency_minutes),
"collector_class": collector_name, "frequency_minutes": datasource.frequency_minutes,
"is_active": collector_registry.is_active(collector_name), "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( async def enable_datasource(
source_id: str, source_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") 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") @router.post("/{source_id}/disable")
async def disable_datasource( async def disable_datasource(
source_id: str, source_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") 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") @router.get("/{source_id}/stats")
@@ -289,26 +178,19 @@ async def get_datasource_stats(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name] result = await db.execute(
source_name = info["name"] select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
)
query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name)
result = await db.execute(query)
total = result.scalar() or 0 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 { return {
"source_id": source_id, "source_id": datasource.id,
"collector_name": collector_name, "collector_name": datasource.collector_class,
"name": info["name"], "name": datasource.name,
"total_records": total, "total_records": total,
} }
@@ -317,30 +199,25 @@ async def get_datasource_stats(
async def trigger_datasource( async def trigger_datasource(
source_id: str, source_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") raise HTTPException(status_code=404, detail="Data source not found")
from app.services.scheduler import run_collector_now if not datasource.is_active:
if not collector_registry.is_active(collector_name):
raise HTTPException(status_code=400, detail="Data source is disabled") 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 {
return { "status": "triggered",
"status": "triggered", "source_id": datasource.id,
"source_id": source_id, "collector_name": datasource.source,
"collector_name": collector_name, "message": f"Collector '{datasource.source}' has been triggered",
"message": f"Collector '{collector_name}' has been triggered", }
}
else:
raise HTTPException(
status_code=500,
detail=f"Failed to trigger collector '{collector_name}'",
)
@router.delete("/{source_id}/data") @router.delete("/{source_id}/data")
@@ -349,39 +226,25 @@ async def clear_datasource_data(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name] result = await db.execute(
source_name = info["name"] select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
)
query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name)
result = await db.execute(query)
count = result.scalar() or 0 count = result.scalar() or 0
if count == 0: if count == 0:
query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name) return {"status": "success", "message": "No data to clear", "deleted_count": 0}
result = await db.execute(query)
count = result.scalar() or 0
delete_source = source_name
else:
delete_source = collector_name
if count == 0: delete_query = CollectedData.__table__.delete().where(CollectedData.source == datasource.source)
return {
"status": "success",
"message": "No data to clear",
"deleted_count": 0,
}
delete_query = CollectedData.__table__.delete().where(CollectedData.source == delete_source)
await db.execute(delete_query) await db.execute(delete_query)
await db.commit() await db.commit()
return { return {
"status": "success", "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, "deleted_count": count,
} }
@@ -391,22 +254,11 @@ async def get_task_status(
source_id: str, source_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
collector_name = get_collector_name(source_id) datasource = await get_datasource_record(db, source_id)
if not collector_name: if not datasource:
raise HTTPException(status_code=404, detail="Data source not found") raise HTTPException(status_code=404, detail="Data source not found")
info = COLLECTOR_INFO[collector_name] running_task = await get_running_task(db, datasource.id)
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()
if not running_task: if not running_task:
return {"is_running": False, "task_id": None, "progress": None} return {"is_running": False, "task_id": None, "progress": None}
@@ -417,4 +269,4 @@ async def get_task_status(
"records_processed": running_task.records_processed, "records_processed": running_task.records_processed,
"total_records": running_task.total_records, "total_records": running_task.total_records,
"status": running_task.status, "status": running_task.status,
} }

View File

@@ -1,13 +1,21 @@
from datetime import datetime
from typing import Optional 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.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() router = APIRouter()
default_settings = { DEFAULT_SETTINGS = {
"system": { "system": {
"system_name": "智能星球", "system_name": "智能星球",
"refresh_interval": 60, "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): class SystemSettingsUpdate(BaseModel):
system_name: str = "智能星球" system_name: str = "智能星球"
refresh_interval: int = 60 refresh_interval: int = Field(default=60, ge=10, le=3600)
auto_refresh: bool = True auto_refresh: bool = True
data_retention_days: int = 30 data_retention_days: int = Field(default=30, ge=1, le=3650)
max_concurrent_tasks: int = 5 max_concurrent_tasks: int = Field(default=5, ge=1, le=50)
class NotificationSettingsUpdate(BaseModel): class NotificationSettingsUpdate(BaseModel):
@@ -51,60 +55,166 @@ class NotificationSettingsUpdate(BaseModel):
class SecuritySettingsUpdate(BaseModel): class SecuritySettingsUpdate(BaseModel):
session_timeout: int = 60 session_timeout: int = Field(default=60, ge=5, le=1440)
max_login_attempts: int = 5 max_login_attempts: int = Field(default=5, ge=1, le=20)
password_policy: str = "medium" 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") @router.get("/system")
async def get_system_settings(current_user: User = Depends(get_current_user)): async def get_system_settings(
return {"system": 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") @router.put("/system")
async def update_system_settings( async def update_system_settings(
settings: SystemSettingsUpdate, settings: SystemSettingsUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
global system_settings payload = await save_setting_payload(db, "system", settings.model_dump())
system_settings = settings.model_dump() return {"status": "updated", "system": payload}
return {"status": "updated", "system": system_settings}
@router.get("/notifications") @router.get("/notifications")
async def get_notification_settings(current_user: User = Depends(get_current_user)): async def get_notification_settings(
return {"notifications": 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") @router.put("/notifications")
async def update_notification_settings( async def update_notification_settings(
settings: NotificationSettingsUpdate, settings: NotificationSettingsUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
global notification_settings payload = await save_setting_payload(db, "notifications", settings.model_dump())
notification_settings = settings.model_dump() return {"status": "updated", "notifications": payload}
return {"status": "updated", "notifications": notification_settings}
@router.get("/security") @router.get("/security")
async def get_security_settings(current_user: User = Depends(get_current_user)): async def get_security_settings(
return {"security": 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") @router.put("/security")
async def update_security_settings( async def update_security_settings(
settings: SecuritySettingsUpdate, settings: SecuritySettingsUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
global security_settings payload = await save_setting_payload(db, "security", settings.model_dump())
security_settings = settings.model_dump() return {"status": "updated", "security": payload}
return {"status": "updated", "security": security_settings}
@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("") @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 { return {
"system": system_settings, "system": await get_setting_payload(db, "system"),
"notifications": notification_settings, "notifications": await get_setting_payload(db, "notifications"),
"security": security_settings, "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 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(): async def init_db():
import app.models.user # noqa: F401 import app.models.user # noqa: F401
import app.models.gpu_cluster # noqa: F401 import app.models.gpu_cluster # noqa: F401
import app.models.task # noqa: F401 import app.models.task # noqa: F401
import app.models.datasource # 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: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware 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.main import api_router
from app.api.v1 import websocket 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): class WebSocketCORSMiddleware(BaseHTTPMiddleware):
@@ -28,6 +27,7 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware):
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
start_scheduler() start_scheduler()
await sync_scheduler_with_datasources()
broadcaster.start() broadcaster.start()
yield yield
broadcaster.stop() broadcaster.stop()
@@ -60,16 +60,11 @@ app.include_router(websocket.router)
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""健康检查端点""" return {"status": "healthy", "version": settings.VERSION}
return {
"status": "healthy",
"version": settings.VERSION,
}
@app.get("/") @app.get("/")
async def root(): async def root():
"""API根目录"""
return { return {
"name": settings.PROJECT_NAME, "name": settings.PROJECT_NAME,
"version": settings.VERSION, "version": settings.VERSION,
@@ -80,7 +75,6 @@ async def root():
@app.get("/api/v1/scheduler/jobs") @app.get("/api/v1/scheduler/jobs")
async def get_scheduler_jobs(): async def get_scheduler_jobs():
"""获取调度任务列表"""
from app.services.scheduler import get_scheduler_jobs from app.services.scheduler import get_scheduler_jobs
return {"jobs": get_scheduler_jobs()} return {"jobs": get_scheduler_jobs()}

View File

@@ -2,14 +2,18 @@ from app.models.user import User
from app.models.gpu_cluster import GPUCluster from app.models.gpu_cluster import GPUCluster
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.alert import Alert, AlertSeverity, AlertStatus from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.models.system_setting import SystemSetting
__all__ = [ __all__ = [
"User", "User",
"GPUCluster", "GPUCluster",
"CollectionTask", "CollectionTask",
"DataSource", "DataSource",
"DataSourceConfig",
"SystemSetting",
"Alert", "Alert",
"AlertSeverity", "AlertSeverity",
"AlertStatus", "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 asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, Any from typing import Any, Dict
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger 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.db.session import async_session_factory
from app.models.datasource import DataSource
from app.services.collectors.registry import collector_registry from app.services.collectors.registry import collector_registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,77 +18,119 @@ logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
COLLECTOR_TO_ID = { async def _update_next_run_at(datasource: DataSource, session) -> None:
"top500": 1, job = scheduler.get_job(datasource.source)
"epoch_ai_gpu": 2, datasource.next_run_at = job.next_run_time if job else None
"huggingface_models": 3, await session.commit()
"huggingface_datasets": 4,
"huggingface_spaces": 5,
"peeringdb_ixp": 6, async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
"peeringdb_network": 7, collector = collector_registry.get(datasource.source)
"peeringdb_facility": 8, if not collector:
"telegeography_cables": 9, logger.warning("Collector not found for datasource %s", datasource.source)
"telegeography_landing": 10, return
"telegeography_systems": 11,
"arcgis_cables": 15, collector_registry.set_active(datasource.source, datasource.is_active)
"arcgis_landing_points": 16,
"arcgis_cable_landing_relation": 17, existing_job = scheduler.get_job(datasource.source)
"fao_landing_points": 18, if existing_job:
"spacetrack_tle": 19, scheduler.remove_job(datasource.source)
"celestrak_tle": 20,
} 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): async def run_collector_task(collector_name: str):
"""Run a single collector task""" """Run a single collector task."""
collector = collector_registry.get(collector_name) collector = collector_registry.get(collector_name)
if not collector: if not collector:
logger.error(f"Collector not found: {collector_name}") logger.error("Collector not found: %s", collector_name)
return return
# Get the correct datasource_id
datasource_id = COLLECTOR_TO_ID.get(collector_name, 1)
async with async_session_factory() as db: 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: try:
# Set the datasource_id on the collector instance collector._datasource_id = datasource.id
collector._datasource_id = datasource_id logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
task_result = await collector.run(db)
logger.info(f"Running collector: {collector_name} (datasource_id={datasource_id})") datasource.last_run_at = datetime.utcnow()
result = await collector.run(db) datasource.last_status = task_result.get("status")
logger.info(f"Collector {collector_name} completed: {result}") await _update_next_run_at(datasource, db)
except Exception as e: logger.info("Collector %s completed: %s", collector_name, task_result)
logger.error(f"Collector {collector_name} failed: {e}") 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(): def start_scheduler() -> None:
"""Start the scheduler with all registered collectors""" """Start the scheduler."""
collectors = collector_registry.all() if not scheduler.running:
scheduler.start()
for name, collector in collectors.items(): logger.info("Scheduler started")
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 stop_scheduler(): def stop_scheduler() -> None:
"""Stop the scheduler""" """Stop the scheduler."""
scheduler.shutdown() if scheduler.running:
logger.info("Scheduler stopped") 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]]: def get_scheduler_jobs() -> list[Dict[str, Any]]:
"""Get all scheduled jobs""" """Get all scheduled jobs."""
jobs = [] jobs = []
for job in scheduler.get_jobs(): for job in scheduler.get_jobs():
jobs.append( jobs.append(
@@ -101,52 +144,17 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]:
return jobs 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: 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) collector = collector_registry.get(collector_name)
if not collector: if not collector:
logger.error(f"Collector not found: {collector_name}") logger.error("Collector not found: %s", collector_name)
return False return False
try: try:
asyncio.create_task(run_collector_task(collector_name)) asyncio.create_task(run_collector_task(collector_name))
logger.info(f"Triggered collector: {collector_name}") logger.info("Triggered collector: %s", collector_name)
return True return True
except Exception as e: except Exception as exc:
logger.error(f"Failed to trigger collector {collector_name}: {e}") logger.error("Failed to trigger collector %s: %s", collector_name, exc)
return False 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 DataSources from './pages/DataSources/DataSources'
import DataList from './pages/DataList/DataList' import DataList from './pages/DataList/DataList'
import Earth from './pages/Earth/Earth' import Earth from './pages/Earth/Earth'
import Settings from './pages/Settings/Settings'
function App() { function App() {
const { token } = useAuthStore() const { token } = useAuthStore()
@@ -23,9 +24,10 @@ function App() {
<Route path="/users" element={<Users />} /> <Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} /> <Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} /> <Route path="/data" element={<DataList />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )
} }
export default App export default App

View File

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

View File

@@ -31,29 +31,247 @@ body {
} }
.dashboard-layout { .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 { .dashboard-sider {
background: #001529 !important; background: #001529 !important;
} }
.ant-layout-sider-trigger { .dashboard-sider-inner {
display: none !important; height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
} }
.dashboard-header { .dashboard-brand {
background: white; position: relative;
padding: 0 24px; height: 64px;
display: flex; display: flex;
align-items: center; 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 { .dashboard-content {
padding: 24px; padding: 24px;
background: #f0f2f5; 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 { .stat-card {
@@ -88,37 +306,6 @@ body {
color: #ff4d4f; 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 */ /* Table cell fixed width */
.ant-table-wrapper .ant-table-tbody > tr > td { .ant-table-wrapper .ant-table-tbody > tr > td {
max-width: 0; max-width: 0;
@@ -126,3 +313,405 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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="告警列表" title="告警列表"
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}></Button>} 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> </Card>
<Modal <Modal

View File

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

View File

@@ -1,16 +1,19 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
Table, Tag, Space, Card, Row, Col, Select, Input, Button, Table, Tag, Space, Card, Select, Input, Button,
Statistic, Modal, Descriptions, Spin, Empty, Tooltip Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid
} from 'antd' } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { import {
DatabaseOutlined, GlobalOutlined, CloudServerOutlined, DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
AppstoreOutlined, EyeOutlined, SearchOutlined AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
const { Title, Text } = Typography
const { useBreakpoint } = Grid
interface CollectedData { interface CollectedData {
id: number id: number
source: string source: string
@@ -38,6 +41,21 @@ interface Summary {
} }
function DataList() { 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 [data, setData] = useState<CollectedData[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [summary, setSummary] = useState<Summary | null>(null) const [summary, setSummary] = useState<Summary | null>(null)
@@ -55,6 +73,73 @@ function DataList() {
const [detailData, setDetailData] = useState<CollectedData | null>(null) const [detailData, setDetailData] = useState<CollectedData | null>(null)
const [detailLoading, setDetailLoading] = useState(false) 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 () => { const fetchData = async () => {
setLoading(true) setLoading(true)
try { try {
@@ -115,6 +200,15 @@ function DataList() {
fetchData() fetchData()
} }
const handleReset = () => {
setSourceFilter(undefined)
setTypeFilter(undefined)
setCountryFilter(undefined)
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
}
const handleViewDetail = async (id: number) => { const handleViewDetail = async (id: number) => {
setDetailVisible(true) setDetailVisible(true)
setDetailLoading(true) setDetailLoading(true)
@@ -130,102 +224,115 @@ function DataList() {
const getSourceIcon = (source: string) => { const getSourceIcon = (source: string) => {
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
'top500': <CloudServerOutlined />, top500: <CloudServerOutlined />,
'huggingface_models': <AppstoreOutlined />, huggingface_models: <AppstoreOutlined />,
'huggingface_datasets': <DatabaseOutlined />, huggingface_datasets: <DatabaseOutlined />,
'huggingface_spaces': <AppstoreOutlined />, huggingface_spaces: <AppstoreOutlined />,
'telegeography_cables': <GlobalOutlined />, telegeography_cables: <GlobalOutlined />,
'epoch_ai_gpu': <CloudServerOutlined />, epoch_ai_gpu: <CloudServerOutlined />,
} }
return iconMap[source] || <DatabaseOutlined /> return iconMap[source] || <DatabaseOutlined />
} }
const getTypeColor = (type: string) => { const getTypeColor = (type: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
'supercomputer': 'red', supercomputer: 'red',
'model': 'blue', model: 'blue',
'dataset': 'green', dataset: 'green',
'space': 'purple', space: 'purple',
'submarine_cable': 'cyan', submarine_cable: 'cyan',
'gpu_cluster': 'orange', gpu_cluster: 'orange',
'ixp': 'magenta', ixp: 'magenta',
'network': 'gold', network: 'gold',
'facility': 'lime', facility: 'lime',
} }
return colors[type] || 'default' return colors[type] || 'default'
} }
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>({ const activeFilterCount = useMemo(
id: 60, () => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length,
name: 300, [sourceFilter, typeFilter, countryFilter, searchText]
source: 150, )
data_type: 100,
country: 100,
value: 100,
collected_at: 160,
action: 80,
})
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) => { for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
e.preventDefault() items.push({
e.stopPropagation() key: item.source,
resizeRef.current = { label: item.source,
startX: e.clientX, value: item.count,
startWidth: columnsWidth[key], icon: getSourceIcon(item.source),
key, })
} }
document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', handleResizeEnd)
}
const handleResizeMove = (e: MouseEvent) => { return items
if (!resizeRef.current) return }, [summary, total, activeFilterCount, isCompact, sources.length])
const diff = e.clientX - resizeRef.current.startX
const newWidth = Math.max(50, resizeRef.current.startWidth + diff)
setColumnsWidth((prev) => ({
...prev,
[resizeRef.current!.key]: newWidth,
}))
}
const handleResizeEnd = () => { const treemapColumns = useMemo(() => {
resizeRef.current = null if (isCompact) return 1
document.removeEventListener('mousemove', handleResizeMove) if (leftPanelWidth < 360) return 2
document.removeEventListener('mouseup', handleResizeEnd) 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> = [ const columns: ColumnsType<CollectedData> = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ {
title: () => ( 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>
),
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: columnsWidth.name, width: 280,
ellipsis: true, ellipsis: true,
render: (name: string, record: CollectedData) => ( render: (name: string, record: CollectedData) => (
<Tooltip title={name}> <Tooltip title={name}>
@@ -236,101 +343,40 @@ function DataList() {
), ),
}, },
{ {
title: () => ( title: '数据源',
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('source')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'source', dataIndex: 'source',
key: 'source', key: 'source',
width: columnsWidth.source, width: 170,
render: (source: string) => ( render: (source: string) => <Tag icon={getSourceIcon(source)}>{source}</Tag>,
<Tag icon={getSourceIcon(source)}>{source}</Tag>
),
}, },
{ {
title: () => ( title: '类型',
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('data_type')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'data_type', dataIndex: 'data_type',
key: 'data_type', key: 'data_type',
width: columnsWidth.data_type, width: 120,
render: (type: string) => ( render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>,
<Tag color={getTypeColor(type)}>{type}</Tag>
),
}, },
{ title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true },
{ {
title: () => ( 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>
),
dataIndex: 'value', dataIndex: 'value',
key: 'value', key: 'value',
width: columnsWidth.value, width: 140,
render: (value: string | null, record: CollectedData) => ( render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'),
value ? `${value} ${record.unit || ''}` : '-'
),
}, },
{ {
title: () => ( title: '采集时间',
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('collected_at')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'collected_at', dataIndex: 'collected_at',
key: 'collected_at', key: 'collected_at',
width: columnsWidth.collected_at, width: 180,
render: (time: string) => new Date(time).toLocaleString('zh-CN'), render: (time: string) => new Date(time).toLocaleString('zh-CN'),
}, },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: columnsWidth.action, width: 96,
render: (_: unknown, record: CollectedData) => ( render: (_: unknown, record: CollectedData) => (
<Button <Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
</Button> </Button>
), ),
@@ -339,93 +385,160 @@ function DataList() {
return ( return (
<AppLayout> <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 */} <div ref={mainAreaRef} className="data-list-controls-shell">
{summary && ( <div className="data-list-split-layout" style={splitLayoutStyle}>
<Row gutter={16} style={{ marginBottom: 24 }}> <Card
<Col span={6}> className="data-list-summary-card data-list-summary-card--panel"
<Card> title="数据概览"
<Statistic size="small"
title="总记录数" bodyStyle={{ padding: isCompact ? 12 : 16 }}
value={summary.total_records} >
prefix={<DatabaseOutlined />} <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> </Card>
</Col>
{summary.source_totals.slice(0, 4).map((item) => ( {!isCompact && (
<Col span={6} key={item.source}> <div
<Card> className="data-list-resize-handle data-list-resize-handle--vertical"
<Statistic onMouseDown={beginHorizontalResize}
title={item.source} role="separator"
value={item.count} aria-orientation="vertical"
prefix={getSourceIcon(item.source)} 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> </Card>
</Col> </div>
))} </div>
</Row> </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 <Modal
title="数据详情" title="数据详情"
open={detailVisible} open={detailVisible}

View File

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

View File

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

View File

@@ -145,7 +145,9 @@ function Tasks() {
</Button> </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> </Card>
</AppLayout> </AppLayout>
) )

View File

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